Compare commits
71 Commits
fix/vue-no
...
bl-exotic-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9d5ce7f3f | ||
|
|
19d98a09ea | ||
|
|
39d305df86 | ||
|
|
68274134c8 | ||
|
|
fb54669dc3 | ||
|
|
662974b222 | ||
|
|
2bf45f23dc | ||
|
|
2f87acf9aa | ||
|
|
497bafcaeb | ||
|
|
c6988380c2 | ||
|
|
d50a2fabc0 | ||
|
|
9c5f8a619c | ||
|
|
c7eac496c1 | ||
|
|
1e066ee6c8 | ||
|
|
5c330fdd25 | ||
|
|
2b7b100e2e | ||
|
|
379af28678 | ||
|
|
c263111eeb | ||
|
|
e887d69cdc | ||
|
|
7ff8bcfea3 | ||
|
|
573cda853b | ||
|
|
49824824e6 | ||
|
|
8e006bb8a3 | ||
|
|
a5f1eb0b92 | ||
|
|
1b30880e6c | ||
|
|
6d22562d40 | ||
|
|
04158deb02 | ||
|
|
072f1f6ced | ||
|
|
202dc3bbb2 | ||
|
|
2c437acff6 | ||
|
|
b97b21add0 | ||
|
|
dfe098897a | ||
|
|
d76c59cb14 | ||
|
|
c298d8a870 | ||
|
|
b50b34ac95 | ||
|
|
7a6cc39c39 | ||
|
|
2b751200be | ||
|
|
5b03d3fcbc | ||
|
|
1caf3fdb0c | ||
|
|
f39faf6b8e | ||
|
|
3ee921119d | ||
|
|
f61bfe666e | ||
|
|
d8795ec997 | ||
|
|
d3aa8dfc88 | ||
|
|
2cce0fe611 | ||
|
|
b9cb335255 | ||
|
|
2f89eb070c | ||
|
|
d94e0720f3 | ||
|
|
fe47a487bb | ||
|
|
63f68543e4 | ||
|
|
9806ba807a | ||
|
|
7433f470fc | ||
|
|
490bb22bd3 | ||
|
|
895775c319 | ||
|
|
e248ecfa4e | ||
|
|
923695ffde | ||
|
|
df653d6ce1 | ||
|
|
28dc9314d2 | ||
|
|
3acf9aae0a | ||
|
|
a055241e2e | ||
|
|
9d131a4267 | ||
|
|
c57ceaf826 | ||
|
|
29dbfa3f60 | ||
|
|
83f04490ba | ||
|
|
c5fe617347 | ||
|
|
8b2c1fc45d | ||
|
|
df66a96976 | ||
|
|
96d12330bb | ||
|
|
4b87b1fdc5 | ||
|
|
08b256c29d | ||
|
|
e6332046b0 |
292
.github/workflows/release-weekly-comfyui.yaml
vendored
Normal file
@@ -0,0 +1,292 @@
|
||||
# Automated weekly workflow to bump ComfyUI frontend RC releases
|
||||
name: "Release: Weekly ComfyUI"
|
||||
|
||||
on:
|
||||
# Schedule for Monday at 12:00 PM PST (20:00 UTC)
|
||||
schedule:
|
||||
- cron: '0 20 * * 1'
|
||||
|
||||
# Allow manual triggering
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
comfyui_fork:
|
||||
description: 'ComfyUI fork to use for PR (e.g., Comfy-Org/ComfyUI)'
|
||||
required: false
|
||||
default: 'Comfy-Org/ComfyUI'
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
resolve-version:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
current_version: ${{ steps.resolve.outputs.current_version }}
|
||||
target_version: ${{ steps.resolve.outputs.target_version }}
|
||||
target_minor: ${{ steps.resolve.outputs.target_minor }}
|
||||
target_branch: ${{ steps.resolve.outputs.target_branch }}
|
||||
needs_release: ${{ steps.resolve.outputs.needs_release }}
|
||||
diff_url: ${{ steps.resolve.outputs.diff_url }}
|
||||
latest_patch_tag: ${{ steps.resolve.outputs.latest_patch_tag }}
|
||||
|
||||
steps:
|
||||
- name: Checkout ComfyUI_frontend
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
path: frontend
|
||||
|
||||
- name: Checkout ComfyUI (sparse)
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
repository: comfyanonymous/ComfyUI
|
||||
sparse-checkout: |
|
||||
requirements.txt
|
||||
path: comfyui
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: frontend
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Resolve release information
|
||||
id: resolve
|
||||
working-directory: frontend
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Run the resolver script
|
||||
if ! RESULT=$(pnpm exec tsx scripts/cicd/resolve-comfyui-release.ts ../comfyui .); then
|
||||
echo "Failed to resolve release information"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Resolver output:"
|
||||
echo "$RESULT"
|
||||
|
||||
# Validate JSON output
|
||||
if ! echo "$RESULT" | jq empty 2>/dev/null; then
|
||||
echo "Invalid JSON output from resolver"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Parse JSON output and set outputs
|
||||
echo "current_version=$(echo "$RESULT" | jq -r '.current_version')" >> $GITHUB_OUTPUT
|
||||
echo "target_version=$(echo "$RESULT" | jq -r '.target_version')" >> $GITHUB_OUTPUT
|
||||
echo "target_minor=$(echo "$RESULT" | jq -r '.target_minor')" >> $GITHUB_OUTPUT
|
||||
echo "target_branch=$(echo "$RESULT" | jq -r '.target_branch')" >> $GITHUB_OUTPUT
|
||||
echo "needs_release=$(echo "$RESULT" | jq -r '.needs_release')" >> $GITHUB_OUTPUT
|
||||
echo "diff_url=$(echo "$RESULT" | jq -r '.diff_url')" >> $GITHUB_OUTPUT
|
||||
echo "latest_patch_tag=$(echo "$RESULT" | jq -r '.latest_patch_tag')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
echo "## Release Information" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Current version: ${{ steps.resolve.outputs.current_version }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Target version: ${{ steps.resolve.outputs.target_version }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Target branch: ${{ steps.resolve.outputs.target_branch }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Needs release: ${{ steps.resolve.outputs.needs_release }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Diff: [${{ steps.resolve.outputs.current_version }}...${{ steps.resolve.outputs.target_version }}](${{ steps.resolve.outputs.diff_url }})" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
trigger-release-if-needed:
|
||||
needs: resolve-version
|
||||
if: needs.resolve-version.outputs.needs_release == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Trigger release workflow
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PR_GH_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
echo "Triggering release workflow for branch ${{ needs.resolve-version.outputs.target_branch }}"
|
||||
|
||||
# Trigger the release-version-bump workflow
|
||||
if ! gh workflow run release-version-bump.yaml \
|
||||
--repo Comfy-Org/ComfyUI_frontend \
|
||||
--ref main \
|
||||
--field version_type=patch \
|
||||
--field branch=${{ needs.resolve-version.outputs.target_branch }}; then
|
||||
echo "Failed to trigger release workflow"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Release workflow triggered successfully for ${{ needs.resolve-version.outputs.target_branch }}"
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
echo "## Release Workflow Triggered" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Branch: ${{ needs.resolve-version.outputs.target_branch }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Target version: ${{ needs.resolve-version.outputs.target_version }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- [View workflow runs](https://github.com/Comfy-Org/ComfyUI_frontend/actions/workflows/release-version-bump.yaml)" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
create-comfyui-pr:
|
||||
needs: [resolve-version, trigger-release-if-needed]
|
||||
if: always() && needs.resolve-version.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout ComfyUI fork
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
repository: ${{ inputs.comfyui_fork || 'Comfy-Org/ComfyUI' }}
|
||||
token: ${{ secrets.PR_GH_TOKEN }}
|
||||
path: comfyui
|
||||
|
||||
- name: Sync with upstream
|
||||
working-directory: comfyui
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Fetch latest upstream to base our branch on fresh code
|
||||
# Note: This only affects the local checkout, NOT the fork's master branch
|
||||
# We only push the automation branch, leaving the fork's master untouched
|
||||
echo "Fetching upstream master..."
|
||||
if ! git fetch https://github.com/comfyanonymous/ComfyUI.git master; then
|
||||
echo "Failed to fetch upstream master"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Checking out upstream master..."
|
||||
if ! git checkout FETCH_HEAD; then
|
||||
echo "Failed to checkout FETCH_HEAD"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Successfully synced with upstream master"
|
||||
|
||||
- name: Update requirements.txt
|
||||
working-directory: comfyui
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
TARGET_VERSION="${{ needs.resolve-version.outputs.target_version }}"
|
||||
echo "Updating comfyui-frontend-package to ${TARGET_VERSION}"
|
||||
|
||||
# Update the comfyui-frontend-package version (POSIX-compatible)
|
||||
sed -i.bak "s/comfyui-frontend-package==[0-9.][0-9.]*/comfyui-frontend-package==${TARGET_VERSION}/" requirements.txt
|
||||
rm requirements.txt.bak
|
||||
|
||||
# Verify the change was made
|
||||
if ! grep -q "comfyui-frontend-package==${TARGET_VERSION}" requirements.txt; then
|
||||
echo "Failed to update requirements.txt"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Updated requirements.txt:"
|
||||
grep comfyui-frontend-package requirements.txt
|
||||
|
||||
- name: Build PR description
|
||||
id: pr-body
|
||||
run: |
|
||||
BODY=$(cat <<'EOF'
|
||||
Bumps frontend to ${{ needs.resolve-version.outputs.target_version }}
|
||||
|
||||
Test quickly:
|
||||
|
||||
```bash
|
||||
python main.py --front-end-version Comfy-Org/ComfyUI_frontend@${{ needs.resolve-version.outputs.target_version }}
|
||||
```
|
||||
|
||||
- Diff: [v${{ needs.resolve-version.outputs.current_version }}...v${{ needs.resolve-version.outputs.target_version }}](${{ needs.resolve-version.outputs.diff_url }})
|
||||
- PyPI: https://pypi.org/project/comfyui-frontend-package/${{ needs.resolve-version.outputs.target_version }}/
|
||||
- npm: https://www.npmjs.com/package/@comfyorg/comfyui-frontend-types/v/${{ needs.resolve-version.outputs.target_version }}
|
||||
EOF
|
||||
)
|
||||
|
||||
# Add release PR note if release was triggered
|
||||
if [ "${{ needs.resolve-version.outputs.needs_release }}" = "true" ]; then
|
||||
RELEASE_NOTE="⚠️ **Release PR must be merged first** - check [release workflow runs](https://github.com/Comfy-Org/ComfyUI_frontend/actions/workflows/release-version-bump.yaml)"
|
||||
BODY=$''"${RELEASE_NOTE}"$'\n\n'"${BODY}"
|
||||
fi
|
||||
|
||||
# Save to file for later use
|
||||
printf '%s\n' "$BODY" > pr-body.txt
|
||||
cat pr-body.txt
|
||||
|
||||
- name: Create PR to ComfyUI
|
||||
working-directory: comfyui
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PR_GH_TOKEN }}
|
||||
COMFYUI_FORK: ${{ inputs.comfyui_fork || 'Comfy-Org/ComfyUI' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Extract fork owner from repository name
|
||||
FORK_OWNER=$(echo "$COMFYUI_FORK" | cut -d'/' -f1)
|
||||
|
||||
echo "Creating PR from ${COMFYUI_FORK} to comfyanonymous/ComfyUI"
|
||||
|
||||
# Configure git
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
# Create/update branch (reuse same branch name each week)
|
||||
BRANCH="automation/comfyui-frontend-bump"
|
||||
git checkout -B "$BRANCH"
|
||||
git add requirements.txt
|
||||
|
||||
if ! git diff --cached --quiet; then
|
||||
git commit -m "Bump comfyui-frontend-package to ${{ needs.resolve-version.outputs.target_version }}"
|
||||
else
|
||||
echo "No changes to commit"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Force push to fork (overwrites previous week's branch)
|
||||
# Note: This intentionally destroys branch history to maintain a single PR
|
||||
# Any review comments or manual commits will need to be re-applied
|
||||
if ! git push -f origin "$BRANCH"; then
|
||||
echo "Failed to push branch to fork"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create draft PR from fork to upstream
|
||||
PR_BODY=$(cat ../pr-body.txt)
|
||||
|
||||
# Try to create PR, ignore error if it already exists
|
||||
if ! gh pr create \
|
||||
--repo comfyanonymous/ComfyUI \
|
||||
--head "${FORK_OWNER}:${BRANCH}" \
|
||||
--base master \
|
||||
--title "Bump comfyui-frontend-package to ${{ needs.resolve-version.outputs.target_version }}" \
|
||||
--body "$PR_BODY" \
|
||||
--draft 2>&1; then
|
||||
|
||||
# Check if PR already exists
|
||||
set +e
|
||||
EXISTING_PR=$(gh pr list --repo comfyanonymous/ComfyUI --head "${FORK_OWNER}:${BRANCH}" --json number --jq '.[0].number' 2>&1)
|
||||
PR_LIST_EXIT=$?
|
||||
set -e
|
||||
|
||||
if [ $PR_LIST_EXIT -ne 0 ]; then
|
||||
echo "Failed to check for existing PR: $EXISTING_PR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -n "$EXISTING_PR" ] && [ "$EXISTING_PR" != "null" ]; then
|
||||
echo "PR already exists (#${EXISTING_PR}), updating branch will update the PR"
|
||||
else
|
||||
echo "Failed to create PR and no existing PR found"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
echo "## ComfyUI PR Created" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Draft PR created in comfyanonymous/ComfyUI" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### PR Body:" >> $GITHUB_STEP_SUMMARY
|
||||
cat pr-body.txt >> $GITHUB_STEP_SUMMARY
|
||||
@@ -9,7 +9,7 @@ module.exports = defineConfig({
|
||||
entry: 'src/locales/en',
|
||||
entryLocale: 'en',
|
||||
output: 'src/locales',
|
||||
outputLocales: ['zh', 'zh-TW', 'ru', 'ja', 'ko', 'fr', 'es', 'ar', 'tr'],
|
||||
outputLocales: ['zh', 'zh-TW', 'ru', 'ja', 'ko', 'fr', 'es', 'ar', 'tr', 'pt-BR'],
|
||||
reference: `Special names to keep untranslated: flux, photomaker, clip, vae, cfg, stable audio, stable cascade, stable zero, controlnet, lora, HiDream.
|
||||
'latent' is the short form of 'latent space'.
|
||||
'mask' is in the context of image processing.
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"typescript/no-redundant-type-constituents": "off",
|
||||
"typescript/restrict-template-expressions": "off",
|
||||
"typescript/unbound-method": "off",
|
||||
"typescript/no-floating-promises": "error"
|
||||
"typescript/no-floating-promises": "error",
|
||||
"vue/no-import-compiler-macros": "error"
|
||||
}
|
||||
}
|
||||
@@ -54,10 +54,11 @@
|
||||
|
||||
# Translations
|
||||
/src/locales/ @Yorha4D @KarryCharon @shinshin86 @Comfy-Org/comfy_maintainer @Comfy-org/comfy_frontend_devs
|
||||
/src/locales/pt-BR/ @JonatanAtila @Yorha4D @KarryCharon @shinshin86
|
||||
|
||||
# LLM Instructions (blank on purpose)
|
||||
.claude/
|
||||
.cursor/
|
||||
.cursorrules
|
||||
**/AGENTS.md
|
||||
**/CLAUDE.md
|
||||
**/CLAUDE.md
|
||||
|
||||
@@ -59,7 +59,8 @@ const LOCALES = [
|
||||
['fr', 'Français'],
|
||||
['es', 'Español'],
|
||||
['ar', 'عربي'],
|
||||
['tr', 'Türkçe']
|
||||
['tr', 'Türkçe'],
|
||||
['pt-BR', 'Português (BR)']
|
||||
] as const satisfies ReadonlyArray<[string, string]>
|
||||
|
||||
type SupportedLocale = (typeof LOCALES)[number][0]
|
||||
|
||||
@@ -40,7 +40,8 @@ const localeLoaders: Record<
|
||||
ru: () => import('@frontend-locales/ru/main.json'),
|
||||
tr: () => import('@frontend-locales/tr/main.json'),
|
||||
zh: () => import('@frontend-locales/zh/main.json'),
|
||||
'zh-TW': () => import('@frontend-locales/zh-TW/main.json')
|
||||
'zh-TW': () => import('@frontend-locales/zh-TW/main.json'),
|
||||
'pt-BR': () => import('@frontend-locales/pt-BR/main.json')
|
||||
}
|
||||
|
||||
const nodeDefsLoaders: Record<
|
||||
@@ -55,7 +56,8 @@ const nodeDefsLoaders: Record<
|
||||
ru: () => import('@frontend-locales/ru/nodeDefs.json'),
|
||||
tr: () => import('@frontend-locales/tr/nodeDefs.json'),
|
||||
zh: () => import('@frontend-locales/zh/nodeDefs.json'),
|
||||
'zh-TW': () => import('@frontend-locales/zh-TW/nodeDefs.json')
|
||||
'zh-TW': () => import('@frontend-locales/zh-TW/nodeDefs.json'),
|
||||
'pt-BR': () => import('@frontend-locales/pt-BR/nodeDefs.json')
|
||||
}
|
||||
|
||||
const commandsLoaders: Record<
|
||||
@@ -70,7 +72,8 @@ const commandsLoaders: Record<
|
||||
ru: () => import('@frontend-locales/ru/commands.json'),
|
||||
tr: () => import('@frontend-locales/tr/commands.json'),
|
||||
zh: () => import('@frontend-locales/zh/commands.json'),
|
||||
'zh-TW': () => import('@frontend-locales/zh-TW/commands.json')
|
||||
'zh-TW': () => import('@frontend-locales/zh-TW/commands.json'),
|
||||
'pt-BR': () => import('@frontend-locales/pt-BR/commands.json')
|
||||
}
|
||||
|
||||
const settingsLoaders: Record<
|
||||
@@ -85,7 +88,8 @@ const settingsLoaders: Record<
|
||||
ru: () => import('@frontend-locales/ru/settings.json'),
|
||||
tr: () => import('@frontend-locales/tr/settings.json'),
|
||||
zh: () => import('@frontend-locales/zh/settings.json'),
|
||||
'zh-TW': () => import('@frontend-locales/zh-TW/settings.json')
|
||||
'zh-TW': () => import('@frontend-locales/zh-TW/settings.json'),
|
||||
'pt-BR': () => import('@frontend-locales/pt-BR/settings.json')
|
||||
}
|
||||
|
||||
// Track which locales have been loaded
|
||||
|
||||
@@ -1651,7 +1651,10 @@ export const comfyPageFixture = base.extend<{
|
||||
// Set tutorial completed to true to avoid loading the tutorial workflow.
|
||||
'Comfy.TutorialCompleted': true,
|
||||
'Comfy.SnapToGrid.GridSize': testComfySnapToGridGridSize,
|
||||
'Comfy.VueNodes.AutoScaleLayout': false
|
||||
'Comfy.VueNodes.AutoScaleLayout': false,
|
||||
// Disable toast warning about version compatibility, as they may or
|
||||
// may not appear - depending on upstream ComfyUI dependencies
|
||||
'Comfy.VersionCompatibility.DisableWarnings': true
|
||||
})
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
|
||||
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 36 KiB |
@@ -94,7 +94,7 @@ async function connectSlots(
|
||||
const fromLoc = slotLocator(page, from.nodeId, from.index, false)
|
||||
const toLoc = slotLocator(page, to.nodeId, to.index, true)
|
||||
await expectVisibleAll(fromLoc, toLoc)
|
||||
await fromLoc.dragTo(toLoc)
|
||||
await fromLoc.dragTo(toLoc, { force: true })
|
||||
await nextFrame()
|
||||
}
|
||||
|
||||
@@ -183,7 +183,7 @@ test.describe('Vue Node Link Interaction', () => {
|
||||
const inputSlot = slotLocator(comfyPage.page, clipNode.id, 0, true)
|
||||
await expectVisibleAll(outputSlot, inputSlot)
|
||||
|
||||
await outputSlot.dragTo(inputSlot)
|
||||
await outputSlot.dragTo(inputSlot, { force: true })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await samplerOutput.getLinkCount()).toBe(0)
|
||||
@@ -210,7 +210,7 @@ test.describe('Vue Node Link Interaction', () => {
|
||||
const inputSlot = slotLocator(comfyPage.page, samplerNode.id, 3, true)
|
||||
await expectVisibleAll(outputSlot, inputSlot)
|
||||
|
||||
await outputSlot.dragTo(inputSlot)
|
||||
await outputSlot.dragTo(inputSlot, { force: true })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await samplerOutput.getLinkCount()).toBe(0)
|
||||
@@ -828,55 +828,55 @@ test.describe('Vue Node Link Interaction', () => {
|
||||
})
|
||||
|
||||
test.describe('Release actions (Shift-drop)', () => {
|
||||
test('Context menu opens and endpoint is pinned on Shift-drop', async ({
|
||||
comfyPage,
|
||||
comfyMouse
|
||||
}) => {
|
||||
await comfyPage.setSetting(
|
||||
'Comfy.LinkRelease.ActionShift',
|
||||
'context menu'
|
||||
)
|
||||
test.fixme(
|
||||
'Context menu opens and endpoint is pinned on Shift-drop',
|
||||
async ({ comfyPage, comfyMouse }) => {
|
||||
await comfyPage.setSetting(
|
||||
'Comfy.LinkRelease.ActionShift',
|
||||
'context menu'
|
||||
)
|
||||
|
||||
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
|
||||
expect(samplerNode).toBeTruthy()
|
||||
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
|
||||
expect(samplerNode).toBeTruthy()
|
||||
|
||||
const outputCenter = await getSlotCenter(
|
||||
comfyPage.page,
|
||||
samplerNode.id,
|
||||
0,
|
||||
false
|
||||
)
|
||||
const outputCenter = await getSlotCenter(
|
||||
comfyPage.page,
|
||||
samplerNode.id,
|
||||
0,
|
||||
false
|
||||
)
|
||||
|
||||
const dropPos = { x: outputCenter.x + 180, y: outputCenter.y - 140 }
|
||||
const dropPos = { x: outputCenter.x + 180, y: outputCenter.y - 140 }
|
||||
|
||||
await comfyMouse.move(outputCenter)
|
||||
await comfyPage.page.keyboard.down('Shift')
|
||||
try {
|
||||
await comfyMouse.drag(dropPos)
|
||||
await comfyMouse.drop()
|
||||
} finally {
|
||||
await comfyPage.page.keyboard.up('Shift').catch(() => {})
|
||||
await comfyMouse.move(outputCenter)
|
||||
await comfyPage.page.keyboard.down('Shift')
|
||||
try {
|
||||
await comfyMouse.drag(dropPos)
|
||||
await comfyMouse.drop()
|
||||
} finally {
|
||||
await comfyPage.page.keyboard.up('Shift').catch(() => {})
|
||||
}
|
||||
|
||||
// Context menu should be visible
|
||||
const contextMenu = comfyPage.page.locator('.litecontextmenu')
|
||||
await expect(contextMenu).toBeVisible()
|
||||
|
||||
// Pinned endpoint should not change with mouse movement while menu is open
|
||||
const before = await comfyPage.page.evaluate(() => {
|
||||
const snap = window['app']?.canvas?.linkConnector?.state?.snapLinksPos
|
||||
return Array.isArray(snap) ? [snap[0], snap[1]] : null
|
||||
})
|
||||
expect(before).not.toBeNull()
|
||||
|
||||
// Move mouse elsewhere and verify snap position is unchanged
|
||||
await comfyMouse.move({ x: dropPos.x + 160, y: dropPos.y + 100 })
|
||||
const after = await comfyPage.page.evaluate(() => {
|
||||
const snap = window['app']?.canvas?.linkConnector?.state?.snapLinksPos
|
||||
return Array.isArray(snap) ? [snap[0], snap[1]] : null
|
||||
})
|
||||
expect(after).toEqual(before)
|
||||
}
|
||||
|
||||
// Context menu should be visible
|
||||
const contextMenu = comfyPage.page.locator('.litecontextmenu')
|
||||
await expect(contextMenu).toBeVisible()
|
||||
|
||||
// Pinned endpoint should not change with mouse movement while menu is open
|
||||
const before = await comfyPage.page.evaluate(() => {
|
||||
const snap = window['app']?.canvas?.linkConnector?.state?.snapLinksPos
|
||||
return Array.isArray(snap) ? [snap[0], snap[1]] : null
|
||||
})
|
||||
expect(before).not.toBeNull()
|
||||
|
||||
// Move mouse elsewhere and verify snap position is unchanged
|
||||
await comfyMouse.move({ x: dropPos.x + 160, y: dropPos.y + 100 })
|
||||
const after = await comfyPage.page.evaluate(() => {
|
||||
const snap = window['app']?.canvas?.linkConnector?.state?.snapLinksPos
|
||||
return Array.isArray(snap) ? [snap[0], snap[1]] : null
|
||||
})
|
||||
expect(after).toEqual(before)
|
||||
})
|
||||
)
|
||||
|
||||
test('Context menu -> Search pre-filters by link type and connects after selection', async ({
|
||||
comfyPage,
|
||||
@@ -897,7 +897,7 @@ test.describe('Vue Node Link Interaction', () => {
|
||||
0,
|
||||
false
|
||||
)
|
||||
const dropPos = { x: outputCenter.x + 200, y: outputCenter.y - 120 }
|
||||
const dropPos = { x: outputCenter.x + 200, y: outputCenter.y - 100 }
|
||||
|
||||
await comfyMouse.move(outputCenter)
|
||||
await comfyPage.page.keyboard.down('Shift')
|
||||
|
||||
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../../../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Vue Node Resizing', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test('should resize node without position drift after selecting', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Get a Vue node fixture
|
||||
const node = await comfyPage.vueNodes.getFixtureByTitle('Load Checkpoint')
|
||||
const initialBox = await node.boundingBox()
|
||||
if (!initialBox) throw new Error('Node bounding box not found')
|
||||
|
||||
// Select the node first (this was causing the bug)
|
||||
await node.header.click()
|
||||
await comfyPage.page.waitForTimeout(100) // Brief pause after selection
|
||||
|
||||
// Get position after selection
|
||||
const selectedBox = await node.boundingBox()
|
||||
if (!selectedBox)
|
||||
throw new Error('Node bounding box not found after select')
|
||||
|
||||
// Verify position unchanged after selection
|
||||
expect(selectedBox.x).toBeCloseTo(initialBox.x, 1)
|
||||
expect(selectedBox.y).toBeCloseTo(initialBox.y, 1)
|
||||
|
||||
// Now resize from bottom-right corner
|
||||
const resizeStartX = selectedBox.x + selectedBox.width - 5
|
||||
const resizeStartY = selectedBox.y + selectedBox.height - 5
|
||||
|
||||
await comfyPage.page.mouse.move(resizeStartX, resizeStartY)
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.page.mouse.move(resizeStartX + 50, resizeStartY + 30)
|
||||
await comfyPage.page.mouse.up()
|
||||
|
||||
// Get final position and size
|
||||
const finalBox = await node.boundingBox()
|
||||
if (!finalBox) throw new Error('Node bounding box not found after resize')
|
||||
|
||||
// Position should NOT have changed (the bug was position drift)
|
||||
expect(finalBox.x).toBeCloseTo(initialBox.x, 1)
|
||||
expect(finalBox.y).toBeCloseTo(initialBox.y, 1)
|
||||
|
||||
// Size should have increased
|
||||
expect(finalBox.width).toBeGreaterThan(initialBox.width)
|
||||
expect(finalBox.height).toBeGreaterThan(initialBox.height)
|
||||
})
|
||||
})
|
||||
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 151 KiB |
|
Before Width: | Height: | Size: 143 KiB After Width: | Height: | Size: 143 KiB |
@@ -1,49 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Vue Nodes - LOD', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.setup()
|
||||
await comfyPage.loadWorkflow('default')
|
||||
await comfyPage.setSetting('LiteGraph.Canvas.MinFontSizeForLOD', 8)
|
||||
})
|
||||
|
||||
test('should toggle LOD based on zoom threshold', async ({ comfyPage }) => {
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const initialNodeCount = await comfyPage.vueNodes.getNodeCount()
|
||||
expect(initialNodeCount).toBeGreaterThan(0)
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('vue-nodes-default.png')
|
||||
|
||||
const vueNodesContainer = comfyPage.vueNodes.nodes
|
||||
const textboxesInNodes = vueNodesContainer.getByRole('textbox')
|
||||
const comboboxesInNodes = vueNodesContainer.getByRole('combobox')
|
||||
|
||||
await expect(textboxesInNodes.first()).toBeVisible()
|
||||
await expect(comboboxesInNodes.first()).toBeVisible()
|
||||
|
||||
await comfyPage.zoom(120, 10)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('vue-nodes-lod-active.png')
|
||||
|
||||
await expect(textboxesInNodes.first()).toBeHidden()
|
||||
await expect(comboboxesInNodes.first()).toBeHidden()
|
||||
|
||||
await comfyPage.zoom(-120, 10)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'vue-nodes-lod-inactive.png'
|
||||
)
|
||||
await expect(textboxesInNodes.first()).toBeVisible()
|
||||
await expect(comboboxesInNodes.first()).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 81 KiB |
@@ -88,12 +88,14 @@ export function comfyAPIPlugin(isDev: boolean): Plugin {
|
||||
|
||||
if (result.exports.length > 0) {
|
||||
const projectRoot = process.cwd()
|
||||
const relativePath = path.relative(path.join(projectRoot, 'src'), id)
|
||||
const relativePath = path
|
||||
.relative(path.join(projectRoot, 'src'), id)
|
||||
.replace(/\\/g, '/')
|
||||
const shimFileName = relativePath.replace(/\.ts$/, '.js')
|
||||
|
||||
let shimContent = `// Shim for ${relativePath}\n`
|
||||
|
||||
const fileKey = relativePath.replace(/\.ts$/, '').replace(/\\/g, '/')
|
||||
const fileKey = relativePath.replace(/\.ts$/, '')
|
||||
const warningMessage = getWarningMessage(fileKey, shimFileName)
|
||||
|
||||
if (warningMessage) {
|
||||
|
||||
@@ -27,7 +27,7 @@ const config: KnipConfig = {
|
||||
project: ['src/**/*.{js,ts}']
|
||||
}
|
||||
},
|
||||
ignoreBinaries: ['python3'],
|
||||
ignoreBinaries: ['python3', 'gh'],
|
||||
ignoreDependencies: [
|
||||
// Weird importmap things
|
||||
'@iconify/json',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.33.9",
|
||||
"version": "1.34.5",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -194,7 +194,7 @@
|
||||
--node-component-executing: var(--color-blue-500);
|
||||
--node-component-header: var(--fg-color);
|
||||
--node-component-header-icon: var(--color-ash-800);
|
||||
--node-component-header-surface: var(--color-white);
|
||||
--node-component-header-surface: var(--color-smoke-400);
|
||||
--node-component-outline: var(--color-black);
|
||||
--node-component-ring: rgb(from var(--color-smoke-500) r g b / 50%);
|
||||
--node-component-slot-dot-outline-opacity-mult: 1;
|
||||
@@ -1190,24 +1190,19 @@ dialog::backdrop {
|
||||
.litegraph.litecontextmenu,
|
||||
.litegraph.litecontextmenu.dark {
|
||||
z-index: 9999 !important;
|
||||
background-color: var(--comfy-menu-bg) !important;
|
||||
background-color: var(--comfy-menu-bg);
|
||||
}
|
||||
|
||||
.litegraph.litecontextmenu
|
||||
.litemenu-entry:hover:not(.disabled):not(.separator) {
|
||||
background-color: var(--comfy-menu-hover-bg, var(--border-color)) !important;
|
||||
color: var(--fg-color);
|
||||
}
|
||||
|
||||
.litegraph.litecontextmenu .litemenu-entry.submenu,
|
||||
.litegraph.litecontextmenu.dark .litemenu-entry.submenu {
|
||||
background-color: var(--comfy-menu-bg) !important;
|
||||
color: var(--input-text);
|
||||
background-color: var(--comfy-menu-bg);
|
||||
color: var(--fg-color);
|
||||
}
|
||||
|
||||
.litegraph.litecontextmenu input {
|
||||
background-color: var(--comfy-input-bg) !important;
|
||||
color: var(--input-text) !important;
|
||||
background-color: var(--comfy-input-bg);
|
||||
color: var(--input-text);
|
||||
}
|
||||
|
||||
.comfy-context-menu-filter {
|
||||
@@ -1248,14 +1243,14 @@ dialog::backdrop {
|
||||
|
||||
.litegraph.litesearchbox {
|
||||
z-index: 9999 !important;
|
||||
background-color: var(--comfy-menu-bg) !important;
|
||||
background-color: var(--comfy-menu-bg);
|
||||
overflow: hidden;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.litegraph.litesearchbox input,
|
||||
.litegraph.litesearchbox select {
|
||||
background-color: var(--comfy-input-bg) !important;
|
||||
background-color: var(--comfy-input-bg);
|
||||
color: var(--input-text);
|
||||
}
|
||||
|
||||
@@ -1320,66 +1315,6 @@ 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;
|
||||
}
|
||||
|
||||
/* START LOD specific styles */
|
||||
/* LOD styles - Custom CSS avoids 100+ Tailwind selectors that would slow style recalculation when .isLOD toggles */
|
||||
|
||||
.isLOD .lg-node {
|
||||
box-shadow: none;
|
||||
filter: none;
|
||||
backdrop-filter: none;
|
||||
text-shadow: none;
|
||||
mask-image: none;
|
||||
clip-path: none;
|
||||
background-image: none;
|
||||
text-rendering: optimizeSpeed;
|
||||
border-radius: 0;
|
||||
contain: layout style;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.isLOD .lg-node-header {
|
||||
border-radius: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.isLOD .lg-node-widgets {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.lod-toggle {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.isLOD .lod-toggle {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.lod-fallback {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.isLOD .lod-fallback {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.isLOD .image-preview img {
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.isLOD .slot-dot {
|
||||
border-radius: 0;
|
||||
}
|
||||
/* END LOD specific styles */
|
||||
|
||||
/* ===================== Mask Editor Styles ===================== */
|
||||
/* To be migrated to Tailwind later */
|
||||
#maskEditor_brush {
|
||||
|
||||
2713
packages/registry-types/src/comfyRegistryTypes.ts
generated
9
public/assets/images/civitai.svg
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
254
scripts/cicd/resolve-comfyui-release.ts
Executable file
@@ -0,0 +1,254 @@
|
||||
#!/usr/bin/env tsx
|
||||
import { execSync } from 'child_process'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
interface ReleaseInfo {
|
||||
current_version: string
|
||||
target_minor: number
|
||||
target_version: string
|
||||
target_branch: string
|
||||
needs_release: boolean
|
||||
latest_patch_tag: string | null
|
||||
branch_head_sha: string | null
|
||||
tag_commit_sha: string | null
|
||||
diff_url: string
|
||||
release_pr_url: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a command and return stdout
|
||||
*/
|
||||
function exec(command: string, cwd?: string): string {
|
||||
try {
|
||||
return execSync(command, {
|
||||
cwd,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
}).trim()
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
const cwdInfo = cwd ? ` in directory: ${cwd}` : ''
|
||||
console.error(
|
||||
`Command failed: ${command}${cwdInfo}\nError: ${errorMessage}`
|
||||
)
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse version from requirements.txt
|
||||
* Handles formats: comfyui-frontend-package==1.2.3, comfyui-frontend-package>=1.2.3, etc.
|
||||
*/
|
||||
function parseRequirementsVersion(requirementsPath: string): string | null {
|
||||
if (!fs.existsSync(requirementsPath)) {
|
||||
console.error(`Requirements file not found: ${requirementsPath}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(requirementsPath, 'utf-8')
|
||||
const match = content.match(
|
||||
/comfyui-frontend-package\s*(?:==|>=|<=|~=|>|<)\s*([0-9]+\.[0-9]+\.[0-9]+)/
|
||||
)
|
||||
|
||||
if (!match) {
|
||||
console.error(
|
||||
'Could not find comfyui-frontend-package version in requirements.txt'
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
return match[1]
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate semantic version string
|
||||
*/
|
||||
function isValidSemver(version: string): boolean {
|
||||
if (!version || typeof version !== 'string') {
|
||||
return false
|
||||
}
|
||||
|
||||
const parts = version.split('.')
|
||||
if (parts.length !== 3) {
|
||||
return false
|
||||
}
|
||||
|
||||
return parts.every((part) => {
|
||||
const num = Number(part)
|
||||
return Number.isFinite(num) && num >= 0 && String(num) === part
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest patch tag for a given minor version
|
||||
*/
|
||||
function getLatestPatchTag(repoPath: string, minor: number): string | null {
|
||||
// Fetch all tags
|
||||
exec('git fetch --tags', repoPath)
|
||||
|
||||
// Use git's native version sorting to get the latest tag
|
||||
const latestTag = exec(
|
||||
`git tag -l 'v1.${minor}.*' --sort=-version:refname | head -n 1`,
|
||||
repoPath
|
||||
)
|
||||
|
||||
if (!latestTag) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Validate the tag is a valid semver (vX.Y.Z format)
|
||||
const validTagRegex = /^v\d+\.\d+\.\d+$/
|
||||
if (!validTagRegex.test(latestTag)) {
|
||||
console.error(
|
||||
`Latest tag for minor version ${minor} is not valid semver: ${latestTag}`
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
return latestTag
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the ComfyUI release information
|
||||
*/
|
||||
function resolveRelease(
|
||||
comfyuiRepoPath: string,
|
||||
frontendRepoPath: string
|
||||
): ReleaseInfo | null {
|
||||
// Parse current version from ComfyUI requirements.txt
|
||||
const requirementsPath = path.join(comfyuiRepoPath, 'requirements.txt')
|
||||
const currentVersion = parseRequirementsVersion(requirementsPath)
|
||||
|
||||
if (!currentVersion) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Validate version format
|
||||
if (!isValidSemver(currentVersion)) {
|
||||
console.error(
|
||||
`Invalid semantic version format: ${currentVersion}. Expected format: X.Y.Z`
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
const [major, currentMinor, patch] = currentVersion.split('.').map(Number)
|
||||
|
||||
// Calculate target minor version (next minor)
|
||||
const targetMinor = currentMinor + 1
|
||||
const targetBranch = `core/1.${targetMinor}`
|
||||
|
||||
// Check if target branch exists in frontend repo
|
||||
exec('git fetch origin', frontendRepoPath)
|
||||
const branchExists = exec(
|
||||
`git rev-parse --verify origin/${targetBranch}`,
|
||||
frontendRepoPath
|
||||
)
|
||||
|
||||
if (!branchExists) {
|
||||
console.error(
|
||||
`Target branch ${targetBranch} does not exist in frontend repo`
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
// Get latest patch tag for target minor
|
||||
const latestPatchTag = getLatestPatchTag(frontendRepoPath, targetMinor)
|
||||
|
||||
let needsRelease = false
|
||||
let branchHeadSha: string | null = null
|
||||
let tagCommitSha: string | null = null
|
||||
let targetVersion = currentVersion
|
||||
|
||||
if (latestPatchTag) {
|
||||
// Get commit SHA for the tag
|
||||
tagCommitSha = exec(`git rev-list -n 1 ${latestPatchTag}`, frontendRepoPath)
|
||||
|
||||
// Get commit SHA for branch head
|
||||
branchHeadSha = exec(
|
||||
`git rev-parse origin/${targetBranch}`,
|
||||
frontendRepoPath
|
||||
)
|
||||
|
||||
// Check if there are commits between tag and branch head
|
||||
const commitsBetween = exec(
|
||||
`git rev-list ${latestPatchTag}..origin/${targetBranch} --count`,
|
||||
frontendRepoPath
|
||||
)
|
||||
|
||||
const commitCount = parseInt(commitsBetween, 10)
|
||||
needsRelease = !isNaN(commitCount) && commitCount > 0
|
||||
|
||||
// Parse existing patch number and increment if needed
|
||||
const tagVersion = latestPatchTag.replace('v', '')
|
||||
|
||||
// Validate tag version format
|
||||
if (!isValidSemver(tagVersion)) {
|
||||
console.error(
|
||||
`Invalid tag version format: ${tagVersion}. Expected format: X.Y.Z`
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
const [, , existingPatch] = tagVersion.split('.').map(Number)
|
||||
|
||||
// Validate existingPatch is a valid number
|
||||
if (!Number.isFinite(existingPatch) || existingPatch < 0) {
|
||||
console.error(`Invalid patch number in tag: ${existingPatch}`)
|
||||
return null
|
||||
}
|
||||
|
||||
if (needsRelease) {
|
||||
targetVersion = `1.${targetMinor}.${existingPatch + 1}`
|
||||
} else {
|
||||
targetVersion = tagVersion
|
||||
}
|
||||
} else {
|
||||
// No tags exist for this minor version, need to create v1.{targetMinor}.0
|
||||
needsRelease = true
|
||||
targetVersion = `1.${targetMinor}.0`
|
||||
branchHeadSha = exec(
|
||||
`git rev-parse origin/${targetBranch}`,
|
||||
frontendRepoPath
|
||||
)
|
||||
}
|
||||
|
||||
const diffUrl = `https://github.com/Comfy-Org/ComfyUI_frontend/compare/v${currentVersion}...v${targetVersion}`
|
||||
|
||||
return {
|
||||
current_version: currentVersion,
|
||||
target_minor: targetMinor,
|
||||
target_version: targetVersion,
|
||||
target_branch: targetBranch,
|
||||
needs_release: needsRelease,
|
||||
latest_patch_tag: latestPatchTag,
|
||||
branch_head_sha: branchHeadSha,
|
||||
tag_commit_sha: tagCommitSha,
|
||||
diff_url: diffUrl,
|
||||
release_pr_url: null // Will be populated by workflow if release is triggered
|
||||
}
|
||||
}
|
||||
|
||||
// Main execution
|
||||
const comfyuiRepoPath = process.argv[2]
|
||||
const frontendRepoPath = process.argv[3] || process.cwd()
|
||||
|
||||
if (!comfyuiRepoPath) {
|
||||
console.error(
|
||||
'Usage: resolve-comfyui-release.ts <comfyui-repo-path> [frontend-repo-path]'
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const releaseInfo = resolveRelease(comfyuiRepoPath, frontendRepoPath)
|
||||
|
||||
if (!releaseInfo) {
|
||||
console.error('Failed to resolve release information')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Output as JSON for GitHub Actions
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(JSON.stringify(releaseInfo, null, 2))
|
||||
|
||||
export { resolveRelease }
|
||||
@@ -1,5 +1,8 @@
|
||||
import * as fs from 'fs'
|
||||
|
||||
// Import Vite define shim to make __DISTRIBUTION__ and other define variables available
|
||||
import './vite-define-shim'
|
||||
|
||||
import { DESKTOP_DIALOGS } from '../apps/desktop-ui/src/constants/desktopDialogs'
|
||||
import { comfyPageFixture as test } from '../browser_tests/fixtures/ComfyPage'
|
||||
import {
|
||||
|
||||
46
scripts/vite-define-shim.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Shim for Vite define variables to make them available during Playwright test execution
|
||||
* This file should be imported before any code that uses Vite define variables
|
||||
*/
|
||||
|
||||
// Define global constants that Vite would normally replace at build time
|
||||
declare global {
|
||||
const __COMFYUI_FRONTEND_VERSION__: string
|
||||
const __SENTRY_ENABLED__: boolean
|
||||
const __SENTRY_DSN__: string
|
||||
const __ALGOLIA_APP_ID__: string
|
||||
const __ALGOLIA_API_KEY__: string
|
||||
const __USE_PROD_CONFIG__: boolean
|
||||
const __DISTRIBUTION__: 'desktop' | 'localhost' | 'cloud'
|
||||
}
|
||||
|
||||
type GlobalWithDefines = typeof globalThis & {
|
||||
__COMFYUI_FRONTEND_VERSION__: string
|
||||
__SENTRY_ENABLED__: boolean
|
||||
__SENTRY_DSN__: string
|
||||
__ALGOLIA_APP_ID__: string
|
||||
__ALGOLIA_API_KEY__: string
|
||||
__USE_PROD_CONFIG__: boolean
|
||||
__DISTRIBUTION__: 'desktop' | 'localhost' | 'cloud'
|
||||
window?: Record<string, unknown>
|
||||
}
|
||||
|
||||
const globalWithDefines = globalThis as GlobalWithDefines
|
||||
|
||||
// Set default values for Playwright test environment
|
||||
globalWithDefines.__COMFYUI_FRONTEND_VERSION__ =
|
||||
process.env.npm_package_version || '1.0.0'
|
||||
globalWithDefines.__SENTRY_ENABLED__ = false
|
||||
globalWithDefines.__SENTRY_DSN__ = ''
|
||||
globalWithDefines.__ALGOLIA_APP_ID__ = ''
|
||||
globalWithDefines.__ALGOLIA_API_KEY__ = ''
|
||||
globalWithDefines.__USE_PROD_CONFIG__ = false
|
||||
globalWithDefines.__DISTRIBUTION__ = 'localhost'
|
||||
|
||||
// Provide a minimal window shim for Node environment
|
||||
// This is needed for code that checks window existence during imports
|
||||
if (typeof window === 'undefined') {
|
||||
globalWithDefines.window = {}
|
||||
}
|
||||
|
||||
export {}
|
||||
@@ -14,10 +14,10 @@
|
||||
</div>
|
||||
|
||||
<Splitter
|
||||
key="main-splitter-stable"
|
||||
:key="splitterRefreshKey"
|
||||
class="splitter-overlay flex-1 overflow-hidden"
|
||||
:pt:gutter="sidebarPanelVisible ? '' : 'hidden'"
|
||||
:state-key="sidebarStateKey || 'main-splitter'"
|
||||
:pt:gutter="getSplitterGutterClasses"
|
||||
:state-key="sidebarStateKey"
|
||||
state-storage="local"
|
||||
>
|
||||
<SplitterPanel
|
||||
@@ -80,6 +80,16 @@
|
||||
name="side-bar-panel"
|
||||
/>
|
||||
</SplitterPanel>
|
||||
|
||||
<!-- Right Side Panel - independent of sidebar -->
|
||||
<SplitterPanel
|
||||
v-if="rightSidePanelVisible"
|
||||
class="right-side-panel pointer-events-auto"
|
||||
:min-size="15"
|
||||
:size="20"
|
||||
>
|
||||
<slot name="right-side-panel" />
|
||||
</SplitterPanel>
|
||||
</Splitter>
|
||||
</div>
|
||||
</div>
|
||||
@@ -92,9 +102,11 @@ import { computed } from 'vue'
|
||||
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const sidebarLocation = computed<'left' | 'right'>(() =>
|
||||
settingStore.get('Comfy.Sidebar.Location')
|
||||
)
|
||||
@@ -109,6 +121,7 @@ const sidebarPanelVisible = computed(
|
||||
const bottomPanelVisible = computed(
|
||||
() => useBottomPanelStore().bottomPanelVisible
|
||||
)
|
||||
const rightSidePanelVisible = computed(() => rightSidePanelStore.isOpen)
|
||||
const activeSidebarTabId = computed(
|
||||
() => useSidebarTabStore().activeSidebarTabId
|
||||
)
|
||||
@@ -120,6 +133,21 @@ const sidebarStateKey = computed(() => {
|
||||
// When no tab is active, use a default key to maintain state
|
||||
return activeSidebarTabId.value ?? 'default-sidebar'
|
||||
})
|
||||
|
||||
/**
|
||||
* Force refresh the splitter when right panel visibility changes to recalculate the width
|
||||
*/
|
||||
const splitterRefreshKey = computed(() => {
|
||||
return rightSidePanelVisible.value
|
||||
? 'main-splitter-with-right-panel'
|
||||
: 'main-splitter'
|
||||
})
|
||||
|
||||
// Gutter visibility should be controlled by CSS targeting specific gutters
|
||||
const getSplitterGutterClasses = computed(() => {
|
||||
// Empty string - let individual gutter styles handle visibility
|
||||
return ''
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -135,10 +163,20 @@ const sidebarStateKey = computed(() => {
|
||||
background-color: var(--p-primary-color);
|
||||
}
|
||||
|
||||
/* Hide sidebar gutter when sidebar is not visible */
|
||||
:deep(.side-bar-panel[style*='display: none'] + .p-splitter-gutter),
|
||||
:deep(.p-splitter-gutter + .side-bar-panel[style*='display: none']) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.side-bar-panel {
|
||||
background-color: var(--bg-color);
|
||||
}
|
||||
|
||||
.right-side-panel {
|
||||
background-color: var(--bg-color);
|
||||
}
|
||||
|
||||
.bottom-panel {
|
||||
background-color: var(--comfy-menu-bg);
|
||||
border: 1px solid var(--p-panel-border-color);
|
||||
|
||||
@@ -44,6 +44,20 @@
|
||||
</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 transition-colors duration-200 ease-in-out hover:bg-secondary-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
|
||||
:aria-pressed="isRightSidePanelOpen"
|
||||
:aria-label="t('rightSidePanel.togglePanel')"
|
||||
@click="toggleRightSidePanel"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--panel-right] block size-4 text-muted-foreground"
|
||||
/>
|
||||
</IconButton>
|
||||
</div>
|
||||
<QueueProgressOverlay
|
||||
v-model:expanded="isQueueOverlayExpanded"
|
||||
@@ -68,10 +82,12 @@ import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
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'
|
||||
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
const isDesktop = isElectron()
|
||||
const { t } = useI18n()
|
||||
@@ -88,6 +104,16 @@ const queueHistoryButtonBackgroundClass = computed(() =>
|
||||
: 'bg-secondary-background'
|
||||
)
|
||||
|
||||
// Right side panel toggle
|
||||
const isRightSidePanelOpen = computed(() => rightSidePanelStore.isOpen)
|
||||
const rightSidePanelTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('rightSidePanel.togglePanel'))
|
||||
)
|
||||
|
||||
const toggleRightSidePanel = () => {
|
||||
rightSidePanelStore.togglePanel()
|
||||
}
|
||||
|
||||
// Maintain support for legacy topbar elements attached by custom scripts
|
||||
const legacyCommandsContainerRef = ref<HTMLElement>()
|
||||
onMounted(() => {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
</div>
|
||||
|
||||
<Panel
|
||||
class="pointer-events-auto z-1010"
|
||||
class="pointer-events-auto"
|
||||
:style="style"
|
||||
:class="panelClass"
|
||||
:pt="{
|
||||
@@ -66,12 +66,7 @@ const storedPosition = useLocalStorage('Comfy.MenuPosition.Floating', {
|
||||
x: 0,
|
||||
y: 0
|
||||
})
|
||||
const {
|
||||
x,
|
||||
y,
|
||||
style: style,
|
||||
isDragging
|
||||
} = useDraggable(panelRef, {
|
||||
const { x, y, style, isDragging } = useDraggable(panelRef, {
|
||||
initialValue: { x: 0, y: 0 },
|
||||
handle: dragHandleRef,
|
||||
containerElement: document.body,
|
||||
@@ -267,7 +262,7 @@ const actionbarClass = computed(() =>
|
||||
)
|
||||
const panelClass = computed(() =>
|
||||
cn(
|
||||
'actionbar pointer-events-auto z1000',
|
||||
'actionbar pointer-events-auto z-1300',
|
||||
isDragging.value && 'select-none pointer-events-none',
|
||||
isDocked.value
|
||||
? 'p-0 static mr-2 border-none bg-transparent'
|
||||
|
||||
@@ -44,17 +44,22 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useQueueSettingsStore } from '@/stores/queueStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
||||
import { graphHasMissingNodes } from '@/workbench/extensions/manager/utils/graphHasMissingNodes'
|
||||
|
||||
import BatchCountEdit from '../BatchCountEdit.vue'
|
||||
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const { mode: queueMode, batchCount } = storeToRefs(useQueueSettingsStore())
|
||||
|
||||
const { hasMissingNodes } = useMissingNodes()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const hasMissingNodes = computed(() =>
|
||||
graphHasMissingNodes(app.graph, nodeDefStore.nodeDefsByName)
|
||||
)
|
||||
|
||||
const { t } = useI18n()
|
||||
const queueModeMenuItemLookup = computed(() => {
|
||||
|
||||
@@ -64,11 +64,13 @@ import {
|
||||
ComfyWorkflow,
|
||||
useWorkflowStore
|
||||
} from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
||||
import { appendJsonExt } from '@/utils/formatUtil'
|
||||
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
||||
import { graphHasMissingNodes } from '@/workbench/extensions/manager/utils/graphHasMissingNodes'
|
||||
|
||||
interface Props {
|
||||
item: MenuItem
|
||||
@@ -79,7 +81,10 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
isActive: false
|
||||
})
|
||||
|
||||
const { hasMissingNodes } = useMissingNodes()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const hasMissingNodes = computed(() =>
|
||||
graphHasMissingNodes(app.graph, nodeDefStore.nodeDefsByName)
|
||||
)
|
||||
|
||||
const { t } = useI18n()
|
||||
const menu = ref<InstanceType<typeof Menu> & MenuState>()
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
<template>
|
||||
<div :class="iconGroupClasses">
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex justify-center items-center shrink-0 outline-hidden border-none p-0 rounded-lg bg-secondary-background shadow-sm transition-all duration-200 cursor-pointer'
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const iconGroupClasses = cn(
|
||||
'flex justify-center items-center shrink-0',
|
||||
'outline-hidden border-none p-0 rounded-lg',
|
||||
'bg-secondary-background shadow-sm',
|
||||
'transition-all duration-200',
|
||||
'cursor-pointer'
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="relative inline-flex items-center">
|
||||
<IconButton :size="size" :type="type" @click="toggle">
|
||||
<IconButton :size="size" :type="type" @click="popover?.toggle">
|
||||
<i v-if="!isVertical" class="icon-[lucide--ellipsis] text-sm" />
|
||||
<i v-else class="icon-[lucide--more-vertical] text-sm" />
|
||||
</IconButton>
|
||||
@@ -25,8 +25,18 @@
|
||||
)
|
||||
}
|
||||
}"
|
||||
@show="$emit('menuOpened')"
|
||||
@hide="$emit('menuClosed')"
|
||||
@show="
|
||||
() => {
|
||||
isOpen = true
|
||||
$emit('menuOpened')
|
||||
}
|
||||
"
|
||||
@hide="
|
||||
() => {
|
||||
isOpen = false
|
||||
$emit('menuClosed')
|
||||
}
|
||||
"
|
||||
>
|
||||
<div class="flex min-w-40 flex-col gap-2 p-2">
|
||||
<slot :close="hide" />
|
||||
@@ -48,8 +58,6 @@ interface MoreButtonProps extends BaseButtonProps {
|
||||
isVertical?: boolean
|
||||
}
|
||||
|
||||
const popover = ref<InstanceType<typeof Popover>>()
|
||||
|
||||
const {
|
||||
size = 'md',
|
||||
type = 'secondary',
|
||||
@@ -61,15 +69,15 @@ defineEmits<{
|
||||
menuClosed: []
|
||||
}>()
|
||||
|
||||
const toggle = (event: Event) => {
|
||||
popover.value?.toggle(event)
|
||||
}
|
||||
const isOpen = ref(false)
|
||||
const popover = ref<InstanceType<typeof Popover>>()
|
||||
|
||||
const hide = () => {
|
||||
function hide() {
|
||||
popover.value?.hide()
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
hide
|
||||
hide,
|
||||
isOpen
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
...inputAttrs
|
||||
}
|
||||
}"
|
||||
@keyup.enter="blurInputElement"
|
||||
@keyup.escape="cancelEditing"
|
||||
@keyup.enter.capture.stop="blurInputElement"
|
||||
@keyup.escape.stop="cancelEditing"
|
||||
@click.stop
|
||||
@pointerdown.stop.capture
|
||||
@pointermove.stop.capture
|
||||
@@ -38,7 +38,7 @@ const {
|
||||
} = defineProps<{
|
||||
modelValue: string
|
||||
isEditing?: boolean
|
||||
inputAttrs?: Record<string, any>
|
||||
inputAttrs?: Record<string, string>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'edit', 'cancel'])
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<component
|
||||
:is="item.headerComponent"
|
||||
v-if="item.headerComponent"
|
||||
v-bind="item.headerProps"
|
||||
:id="item.key"
|
||||
/>
|
||||
<h3 v-else :id="item.key">
|
||||
|
||||
19
src/components/dialog/confirm/ConfirmBody.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col px-4 py-2 text-sm text-muted-foreground border-t border-border-default"
|
||||
>
|
||||
<p v-if="promptTextReal">
|
||||
{{ promptTextReal }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed, toValue } from 'vue'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
|
||||
const { promptText } = defineProps<{
|
||||
promptText?: MaybeRefOrGetter<string>
|
||||
}>()
|
||||
|
||||
const promptTextReal = computed(() => toValue(promptText))
|
||||
</script>
|
||||
43
src/components/dialog/confirm/ConfirmFooter.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<section class="w-full flex gap-2 justify-end px-2 pb-2">
|
||||
<TextButton
|
||||
:label="cancelTextX"
|
||||
:disabled
|
||||
type="transparent"
|
||||
autofocus
|
||||
@click="$emit('cancel')"
|
||||
/>
|
||||
<TextButton
|
||||
:label="confirmTextX"
|
||||
:disabled
|
||||
type="transparent"
|
||||
:class="confirmClass"
|
||||
@click="$emit('confirm')"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed, toValue } from 'vue'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import TextButton from '@/components/button/TextButton.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { cancelText, confirmText, confirmClass, optionsDisabled } = defineProps<{
|
||||
cancelText?: string
|
||||
confirmText?: string
|
||||
confirmClass?: string
|
||||
optionsDisabled?: MaybeRefOrGetter<boolean>
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
cancel: []
|
||||
confirm: []
|
||||
}>()
|
||||
|
||||
const confirmTextX = computed(() => confirmText || t('g.confirm'))
|
||||
const cancelTextX = computed(() => cancelText || t('g.cancel'))
|
||||
const disabled = computed(() => toValue(optionsDisabled))
|
||||
</script>
|
||||
12
src/components/dialog/confirm/ConfirmHeader.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center gap-2 p-4 font-bold text-sm text-base-foreground font-inter"
|
||||
>
|
||||
<span v-if="title" class="flex-auto">{{ title }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
title?: string
|
||||
}>()
|
||||
</script>
|
||||
31
src/components/dialog/confirm/confirmDialog.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import ConfirmBody from '@/components/dialog/confirm/ConfirmBody.vue'
|
||||
import ConfirmFooter from '@/components/dialog/confirm/ConfirmFooter.vue'
|
||||
import ConfirmHeader from '@/components/dialog/confirm/ConfirmHeader.vue'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import type { ComponentAttrs } from 'vue-component-type-helpers'
|
||||
|
||||
interface ConfirmDialogOptions {
|
||||
headerProps?: ComponentAttrs<typeof ConfirmHeader>
|
||||
props?: ComponentAttrs<typeof ConfirmBody>
|
||||
footerProps?: ComponentAttrs<typeof ConfirmFooter>
|
||||
}
|
||||
|
||||
export function showConfirmDialog(options: ConfirmDialogOptions = {}) {
|
||||
const dialogStore = useDialogStore()
|
||||
const { headerProps, props, footerProps } = options
|
||||
return dialogStore.showDialog({
|
||||
headerComponent: ConfirmHeader,
|
||||
component: ConfirmBody,
|
||||
footerComponent: ConfirmFooter,
|
||||
headerProps,
|
||||
props,
|
||||
footerProps,
|
||||
dialogComponentProps: {
|
||||
pt: {
|
||||
header: 'py-0! px-0!',
|
||||
content: 'p-0!',
|
||||
footer: 'p-0!'
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -14,7 +14,9 @@
|
||||
v-if="isNativeWindow() && workflowTabsPosition !== 'Topbar'"
|
||||
class="app-drag fixed top-0 left-0 z-10 h-[var(--comfy-topbar-height)] w-full"
|
||||
/>
|
||||
<div class="flex h-full items-center">
|
||||
<div
|
||||
class="flex h-full items-center border-b border-interface-stroke bg-comfy-menu-bg shadow-interface"
|
||||
>
|
||||
<WorkflowTabs />
|
||||
<TopbarBadges />
|
||||
</div>
|
||||
@@ -36,6 +38,9 @@
|
||||
<template v-if="showUI" #bottom-panel>
|
||||
<BottomPanel />
|
||||
</template>
|
||||
<template v-if="showUI" #right-side-panel>
|
||||
<NodePropertiesPanel />
|
||||
</template>
|
||||
<template #graph-canvas-panel>
|
||||
<GraphCanvasMenu v-if="canvasMenuEnabled" class="pointer-events-auto" />
|
||||
<MiniMap
|
||||
@@ -55,7 +60,6 @@
|
||||
<TransformPane
|
||||
v-if="shouldRenderVueNodes && comfyApp.canvas && comfyAppReady"
|
||||
:canvas="comfyApp.canvas"
|
||||
@transform-update="handleTransformUpdate"
|
||||
@wheel.capture="canvasInteractions.forwardEventToCanvas"
|
||||
>
|
||||
<!-- Vue nodes rendered based on graph nodes -->
|
||||
@@ -73,6 +77,9 @@
|
||||
/>
|
||||
</TransformPane>
|
||||
|
||||
<!-- Selection rectangle overlay for Vue nodes mode -->
|
||||
<SelectionRectangle v-if="shouldRenderVueNodes && comfyAppReady" />
|
||||
|
||||
<NodeTooltip v-if="tooltipEnabled" />
|
||||
<NodeSearchboxPopover ref="nodeSearchboxPopoverRef" />
|
||||
|
||||
@@ -110,13 +117,13 @@ import NodeTooltip from '@/components/graph/NodeTooltip.vue'
|
||||
import SelectionToolbox from '@/components/graph/SelectionToolbox.vue'
|
||||
import TitleEditor from '@/components/graph/TitleEditor.vue'
|
||||
import NodeOptions from '@/components/graph/selectionToolbox/NodeOptions.vue'
|
||||
import NodePropertiesPanel from '@/components/rightSidePanel/RightSidePanel.vue'
|
||||
import NodeSearchboxPopover from '@/components/searchbox/NodeSearchBoxPopover.vue'
|
||||
import SideToolbar from '@/components/sidebar/SideToolbar.vue'
|
||||
import TopbarBadges from '@/components/topbar/TopbarBadges.vue'
|
||||
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useViewportCulling } from '@/composables/graph/useViewportCulling'
|
||||
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
|
||||
import { useNodeBadge } from '@/composables/node/useNodeBadge'
|
||||
import { useCanvasDrop } from '@/composables/useCanvasDrop'
|
||||
@@ -155,6 +162,7 @@ import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { isNativeWindow } from '@/utils/envUtil'
|
||||
|
||||
import TryVueNodeBanner from '../topbar/TryVueNodeBanner.vue'
|
||||
import SelectionRectangle from './SelectionRectangle.vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
ready: []
|
||||
@@ -200,7 +208,6 @@ const { shouldRenderVueNodes } = useVueFeatureFlags()
|
||||
|
||||
// Vue node system
|
||||
const vueNodeLifecycle = useVueNodeLifecycle()
|
||||
const { handleTransformUpdate } = useViewportCulling()
|
||||
|
||||
const handleVueNodeLifecycleReset = async () => {
|
||||
if (shouldRenderVueNodes.value) {
|
||||
|
||||
63
src/components/graph/SelectionRectangle.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="isVisible"
|
||||
class="pointer-events-none absolute border border-blue-400 bg-blue-500/20"
|
||||
:style="rectangleStyle"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRafFn } from '@vueuse/core'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const selectionRect = ref<{
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
} | null>(null)
|
||||
|
||||
useRafFn(() => {
|
||||
const canvas = canvasStore.canvas
|
||||
if (!canvas) {
|
||||
selectionRect.value = null
|
||||
return
|
||||
}
|
||||
|
||||
const { pointer, dragging_rectangle } = canvas
|
||||
|
||||
if (dragging_rectangle && pointer.eDown && pointer.eMove) {
|
||||
const x = pointer.eDown.safeOffsetX
|
||||
const y = pointer.eDown.safeOffsetY
|
||||
const w = pointer.eMove.safeOffsetX - x
|
||||
const h = pointer.eMove.safeOffsetY - y
|
||||
|
||||
selectionRect.value = { x, y, w, h }
|
||||
} else {
|
||||
selectionRect.value = null
|
||||
}
|
||||
})
|
||||
|
||||
const isVisible = computed(() => selectionRect.value !== null)
|
||||
|
||||
const rectangleStyle = computed(() => {
|
||||
const rect = selectionRect.value
|
||||
if (!rect) return {}
|
||||
|
||||
const left = rect.w >= 0 ? rect.x : rect.x + rect.w
|
||||
const top = rect.h >= 0 ? rect.y : rect.y + rect.h
|
||||
const width = Math.abs(rect.w)
|
||||
const height = Math.abs(rect.h)
|
||||
|
||||
return {
|
||||
left: `${left}px`,
|
||||
top: `${top}px`,
|
||||
width: `${width}px`,
|
||||
height: `${height}px`
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -7,11 +7,17 @@
|
||||
severity="secondary"
|
||||
text
|
||||
icon="icon-[lucide--settings-2]"
|
||||
@click="showSubgraphNodeDialog"
|
||||
@click="handleClick"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
|
||||
import { showSubgraphNodeDialog } from '@/core/graph/subgraph/useSubgraphNodeDialog'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
|
||||
const handleClick = () => {
|
||||
rightSidePanelStore.openPanel('subgraph')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -280,7 +280,8 @@ const menuItems = computed<MenuItem[]>(() => {
|
||||
label: t('helpCenter.docs'),
|
||||
action: () => {
|
||||
trackResourceClick('docs', true)
|
||||
openExternalLink(buildDocsUrl('/', { includeLocale: true }))
|
||||
const path = isCloud ? '/get_started/cloud' : '/'
|
||||
openExternalLink(buildDocsUrl(path, { includeLocale: true }))
|
||||
emit('close')
|
||||
}
|
||||
},
|
||||
|
||||
@@ -152,7 +152,7 @@ const {
|
||||
popoverMaxWidth?: string
|
||||
}>()
|
||||
|
||||
const selectedItem = defineModel<string | null>({ required: true })
|
||||
const selectedItem = defineModel<string | undefined>({ required: true })
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
class="widget-expands relative h-full w-full"
|
||||
class="relative size-full"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
@pointerdown.stop
|
||||
@@ -17,7 +17,7 @@
|
||||
:on-model-drop="isPreview ? undefined : handleModelDrop"
|
||||
:is-preview="isPreview"
|
||||
/>
|
||||
<div class="pointer-events-none absolute top-0 left-0 h-full w-full">
|
||||
<div class="pointer-events-none absolute top-0 left-0 size-full">
|
||||
<Load3DControls
|
||||
v-model:scene-config="sceneConfig"
|
||||
v-model:model-config="modelConfig"
|
||||
|
||||
@@ -6,10 +6,7 @@
|
||||
{{ t('maskEditor.brushSettings') }}
|
||||
</h3>
|
||||
|
||||
<button
|
||||
class="w-45 h-7.5 border-none bg-black/20 border border-[var(--border-color)] text-[var(--input-text)] font-sans text-[15px] pointer-events-auto transition-colors duration-100 hover:bg-[var(--p-overlaybadge-outline-color)] hover:border-none"
|
||||
@click="resetToDefault"
|
||||
>
|
||||
<button :class="textButtonClass" @click="resetToDefault">
|
||||
{{ t('maskEditor.resetToDefault') }}
|
||||
</button>
|
||||
|
||||
@@ -99,6 +96,9 @@ import SliderControl from './controls/SliderControl.vue'
|
||||
|
||||
const store = useMaskEditorStore()
|
||||
|
||||
const textButtonClass =
|
||||
'h-7.5 w-32 rounded-[10px] border border-[var(--p-form-field-border-color)] text-[var(--input-text)] font-sans pointer-events-auto transition-colors duration-100 bg-[var(--comfy-menu-bg)] hover:bg-secondary-background-hover'
|
||||
|
||||
const setBrushShape = (shape: BrushShape) => {
|
||||
store.brushSettings.type = shape
|
||||
}
|
||||
|
||||
230
src/components/node/NodeHelpContent.vue
Normal file
@@ -0,0 +1,230 @@
|
||||
<template>
|
||||
<div class="node-help-content mx-auto w-full">
|
||||
<ProgressSpinner
|
||||
v-if="isLoading"
|
||||
class="m-auto"
|
||||
:aria-label="$t('g.loading')"
|
||||
/>
|
||||
<!-- Markdown fetched successfully -->
|
||||
<div
|
||||
v-else-if="!error"
|
||||
class="markdown-content"
|
||||
v-html="renderedHelpHtml"
|
||||
/>
|
||||
<!-- Fallback: markdown not found or fetch error -->
|
||||
<div v-else class="fallback-content space-y-6 text-sm">
|
||||
<p v-if="node.description">
|
||||
<strong>{{ $t('g.description') }}:</strong> {{ node.description }}
|
||||
</p>
|
||||
|
||||
<div v-if="inputList.length">
|
||||
<p>
|
||||
<strong>{{ $t('nodeHelpPage.inputs') }}:</strong>
|
||||
</p>
|
||||
<!-- Using plain HTML table instead of DataTable for consistent styling with markdown content -->
|
||||
<table class="overflow-x-auto">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t('g.name') }}</th>
|
||||
<th>{{ $t('nodeHelpPage.type') }}</th>
|
||||
<th>{{ $t('g.description') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="input in inputList" :key="input.name">
|
||||
<td>
|
||||
<code>{{ input.name }}</code>
|
||||
</td>
|
||||
<td>{{ input.type }}</td>
|
||||
<td>{{ input.tooltip || '-' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="outputList.length">
|
||||
<p>
|
||||
<strong>{{ $t('nodeHelpPage.outputs') }}:</strong>
|
||||
</p>
|
||||
<table class="overflow-x-auto">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t('g.name') }}</th>
|
||||
<th>{{ $t('nodeHelpPage.type') }}</th>
|
||||
<th>{{ $t('g.description') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="output in outputList" :key="output.name">
|
||||
<td>
|
||||
<code>{{ output.name }}</code>
|
||||
</td>
|
||||
<td>{{ output.type }}</td>
|
||||
<td>{{ output.tooltip || '-' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
|
||||
|
||||
const { node } = defineProps<{ node: ComfyNodeDefImpl }>()
|
||||
|
||||
const nodeHelpStore = useNodeHelpStore()
|
||||
const { renderedHelpHtml, isLoading, error } = storeToRefs(nodeHelpStore)
|
||||
|
||||
const inputList = computed(() =>
|
||||
Object.values(node.inputs).map((spec) => ({
|
||||
name: spec.name,
|
||||
type: spec.type,
|
||||
tooltip: spec.tooltip || ''
|
||||
}))
|
||||
)
|
||||
|
||||
const outputList = computed(() =>
|
||||
node.outputs.map((spec) => ({
|
||||
name: spec.name,
|
||||
type: spec.type,
|
||||
tooltip: spec.tooltip || ''
|
||||
}))
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference './../../assets/css/style.css';
|
||||
|
||||
.node-help-content :deep(:is(img, video)) {
|
||||
@apply max-w-full h-auto block mb-4;
|
||||
}
|
||||
|
||||
.markdown-content,
|
||||
.fallback-content {
|
||||
@apply text-sm overflow-visible;
|
||||
}
|
||||
|
||||
.markdown-content :deep(h1),
|
||||
.fallback-content h1 {
|
||||
@apply text-[22px] font-bold mt-8 mb-4 first:mt-0;
|
||||
}
|
||||
|
||||
.markdown-content :deep(h2),
|
||||
.fallback-content h2 {
|
||||
@apply text-[18px] font-bold mt-8 mb-4 first:mt-0;
|
||||
}
|
||||
|
||||
.markdown-content :deep(h3),
|
||||
.fallback-content h3 {
|
||||
@apply text-[16px] font-bold mt-8 mb-4 first:mt-0;
|
||||
}
|
||||
|
||||
.markdown-content :deep(h4),
|
||||
.markdown-content :deep(h5),
|
||||
.markdown-content :deep(h6),
|
||||
.fallback-content h4,
|
||||
.fallback-content h5,
|
||||
.fallback-content h6 {
|
||||
@apply mt-8 mb-4 first:mt-0;
|
||||
}
|
||||
|
||||
.markdown-content :deep(td),
|
||||
.fallback-content td {
|
||||
color: var(--drag-text);
|
||||
}
|
||||
|
||||
.markdown-content :deep(a),
|
||||
.fallback-content a {
|
||||
color: var(--drag-text);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.markdown-content :deep(th),
|
||||
.fallback-content th {
|
||||
color: var(--fg-color);
|
||||
}
|
||||
|
||||
.markdown-content :deep(ul),
|
||||
.markdown-content :deep(ol),
|
||||
.fallback-content ul,
|
||||
.fallback-content ol {
|
||||
@apply pl-8 my-2;
|
||||
}
|
||||
|
||||
.markdown-content :deep(ul ul),
|
||||
.markdown-content :deep(ol ol),
|
||||
.markdown-content :deep(ul ol),
|
||||
.markdown-content :deep(ol ul),
|
||||
.fallback-content ul ul,
|
||||
.fallback-content ol ol,
|
||||
.fallback-content ul ol,
|
||||
.fallback-content ol ul {
|
||||
@apply pl-6 my-2;
|
||||
}
|
||||
|
||||
.markdown-content :deep(li),
|
||||
.fallback-content li {
|
||||
@apply my-2;
|
||||
}
|
||||
|
||||
.markdown-content :deep(*:first-child),
|
||||
.fallback-content > *:first-child {
|
||||
@apply mt-0;
|
||||
}
|
||||
|
||||
.markdown-content :deep(code),
|
||||
.fallback-content code {
|
||||
color: var(--code-text-color);
|
||||
background-color: var(--code-bg-color);
|
||||
@apply rounded px-1.5 py-0.5;
|
||||
}
|
||||
|
||||
.markdown-content :deep(table),
|
||||
.fallback-content table {
|
||||
@apply w-full border-collapse;
|
||||
}
|
||||
|
||||
.markdown-content :deep(th),
|
||||
.markdown-content :deep(td),
|
||||
.fallback-content th,
|
||||
.fallback-content td {
|
||||
@apply px-2 py-2;
|
||||
}
|
||||
|
||||
.markdown-content :deep(tr),
|
||||
.fallback-content tr {
|
||||
border-bottom: 1px solid var(--content-bg);
|
||||
}
|
||||
|
||||
.markdown-content :deep(tr:last-child),
|
||||
.fallback-content tr:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.markdown-content :deep(thead),
|
||||
.fallback-content thead {
|
||||
border-bottom: 1px solid var(--p-text-color);
|
||||
}
|
||||
|
||||
.markdown-content :deep(pre),
|
||||
.fallback-content pre {
|
||||
@apply rounded p-4 my-4 overflow-x-auto;
|
||||
background-color: var(--code-block-bg-color);
|
||||
|
||||
code {
|
||||
@apply bg-transparent p-0;
|
||||
color: var(--p-text-color);
|
||||
}
|
||||
}
|
||||
|
||||
.markdown-content :deep(table) {
|
||||
@apply overflow-x-auto;
|
||||
}
|
||||
</style>
|
||||
@@ -60,7 +60,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, withDefaults } from 'vue'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import QueueOverlayActive from '@/components/queue/QueueOverlayActive.vue'
|
||||
|
||||
172
src/components/rightSidePanel/RightSidePanel.vue
Normal file
@@ -0,0 +1,172 @@
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, watchEffect } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import IconButton from '@/components/button/IconButton.vue'
|
||||
import Tab from '@/components/tab/Tab.vue'
|
||||
import TabList from '@/components/tab/TabList.vue'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { isLGraphNode } from '@/utils/litegraphUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import TabInfo from './info/TabInfo.vue'
|
||||
import TabParameters from './parameters/TabParameters.vue'
|
||||
import TabSettings from './settings/TabSettings.vue'
|
||||
import SubgraphEditor from './subgraph/SubgraphEditor.vue'
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const { selectedItems } = storeToRefs(canvasStore)
|
||||
const { activeTab, isEditingSubgraph } = storeToRefs(rightSidePanelStore)
|
||||
|
||||
const hasSelection = computed(() => selectedItems.value.length > 0)
|
||||
|
||||
const selectedNodes = computed(() => {
|
||||
return selectedItems.value.filter(isLGraphNode) as LGraphNode[]
|
||||
})
|
||||
|
||||
const isSubgraphNode = computed(() => {
|
||||
return selectedNode.value instanceof SubgraphNode
|
||||
})
|
||||
|
||||
const isSingleNodeSelected = computed(() => selectedNodes.value.length === 1)
|
||||
|
||||
const selectedNode = computed(() => {
|
||||
return isSingleNodeSelected.value ? selectedNodes.value[0] : null
|
||||
})
|
||||
|
||||
const selectionCount = computed(() => selectedItems.value.length)
|
||||
|
||||
const panelTitle = computed(() => {
|
||||
if (!hasSelection.value) return t('rightSidePanel.properties')
|
||||
if (isSingleNodeSelected.value && selectedNode.value) {
|
||||
return selectedNode.value.title || selectedNode.value.type || 'Node'
|
||||
}
|
||||
return t('rightSidePanel.multipleSelection', { count: selectionCount.value })
|
||||
})
|
||||
|
||||
function closePanel() {
|
||||
rightSidePanelStore.closePanel()
|
||||
}
|
||||
|
||||
const tabs = computed<{ label: () => string; value: string }[]>(() => {
|
||||
const list = [
|
||||
{
|
||||
label: () => t('rightSidePanel.parameters'),
|
||||
value: 'parameters'
|
||||
},
|
||||
{
|
||||
label: () => t('rightSidePanel.settings'),
|
||||
value: 'settings'
|
||||
}
|
||||
]
|
||||
if (
|
||||
!hasSelection.value ||
|
||||
(isSingleNodeSelected.value && !isSubgraphNode.value)
|
||||
) {
|
||||
list.push({
|
||||
label: () => t('rightSidePanel.info'),
|
||||
value: 'info'
|
||||
})
|
||||
}
|
||||
return list
|
||||
})
|
||||
|
||||
// Use global state for activeTab and ensure it's valid
|
||||
watchEffect(() => {
|
||||
if (!tabs.value.some((tab) => tab.value === activeTab.value)) {
|
||||
activeTab.value = tabs.value[0].value as 'parameters' | 'settings' | 'info'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full w-full flex-col bg-interface-panel-surface">
|
||||
<!-- Panel Header -->
|
||||
<div class="border-b border-interface-stroke pt-1">
|
||||
<div class="flex items-center justify-between pl-4 pr-3">
|
||||
<h3 class="my-3.5 text-sm font-semibold line-clamp-2">
|
||||
{{ panelTitle }}
|
||||
</h3>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<IconButton
|
||||
v-if="isSubgraphNode"
|
||||
type="transparent"
|
||||
size="sm"
|
||||
class="bg-secondary-background hover:bg-secondary-background-hover"
|
||||
:class="
|
||||
cn(
|
||||
'bg-secondary-background hover:bg-secondary-background-hover',
|
||||
isEditingSubgraph
|
||||
? 'bg-secondary-background-selected'
|
||||
: 'bg-secondary-background'
|
||||
)
|
||||
"
|
||||
@click="isEditingSubgraph = !isEditingSubgraph"
|
||||
>
|
||||
<i class="icon-[lucide--settings-2]" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
type="transparent"
|
||||
size="sm"
|
||||
class="bg-secondary-background hover:bg-secondary-background-hover"
|
||||
:aria-pressed="rightSidePanelStore.isOpen"
|
||||
:aria-label="t('rightSidePanel.togglePanel')"
|
||||
@click="closePanel"
|
||||
>
|
||||
<i class="icon-[lucide--panel-right]" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="hasSelection && !(isSubgraphNode && isEditingSubgraph)"
|
||||
class="px-4 pb-2 pt-1"
|
||||
>
|
||||
<TabList v-model="activeTab">
|
||||
<Tab
|
||||
v-for="tab in tabs"
|
||||
:key="tab.value"
|
||||
class="text-sm py-1 px-2"
|
||||
:value="tab.value"
|
||||
>
|
||||
{{ tab.label() }}
|
||||
</Tab>
|
||||
</TabList>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Panel Content -->
|
||||
<div class="scrollbar-thin flex-1 overflow-y-auto">
|
||||
<SubgraphEditor
|
||||
v-if="isSubgraphNode && isEditingSubgraph"
|
||||
:node="selectedNode"
|
||||
/>
|
||||
<div
|
||||
v-else-if="!hasSelection"
|
||||
class="flex h-full items-center justify-center text-center"
|
||||
>
|
||||
<div class="px-4 text-sm text-base-foreground-muted">
|
||||
{{ $t('rightSidePanel.noSelection') }}
|
||||
</div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<TabParameters
|
||||
v-if="activeTab === 'parameters'"
|
||||
:nodes="selectedNodes"
|
||||
/>
|
||||
<TabInfo v-else-if="activeTab === 'info'" :nodes="selectedNodes" />
|
||||
<TabSettings
|
||||
v-else-if="activeTab === 'settings'"
|
||||
:nodes="selectedNodes"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
37
src/components/rightSidePanel/info/TabInfo.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, watch } from 'vue'
|
||||
|
||||
import NodeHelpContent from '@/components/node/NodeHelpContent.vue'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
|
||||
|
||||
const props = defineProps<{
|
||||
nodes: LGraphNode[]
|
||||
}>()
|
||||
const node = computed(() => props.nodes[0])
|
||||
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const nodeHelpStore = useNodeHelpStore()
|
||||
|
||||
const nodeInfo = computed(() => {
|
||||
return nodeDefStore.fromLGraphNode(node.value)
|
||||
})
|
||||
|
||||
// Open node help when the selected node changes
|
||||
watch(
|
||||
nodeInfo,
|
||||
(info) => {
|
||||
if (info) {
|
||||
nodeHelpStore.openHelp(info)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="nodeInfo" class="rounded-lg bg-interface-surface p-3">
|
||||
<NodeHelpContent :node="nodeInfo" />
|
||||
</div>
|
||||
</template>
|
||||
51
src/components/rightSidePanel/layout/RightPanelSection.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<script lang="ts" setup>
|
||||
import { watch } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const props = defineProps<{
|
||||
label?: string
|
||||
defaultCollapse?: boolean
|
||||
}>()
|
||||
const isCollapse = defineModel<boolean>('collapse', { default: false })
|
||||
|
||||
if (props.defaultCollapse) {
|
||||
isCollapse.value = true
|
||||
}
|
||||
watch(
|
||||
() => props.defaultCollapse,
|
||||
(value) => (isCollapse.value = value)
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<div
|
||||
class="sticky top-0 z-10 flex items-center justify-between backdrop-blur-xl min-h-12"
|
||||
>
|
||||
<button
|
||||
class="group min-h-12 bg-transparent border-0 outline-0 ring-0 w-full text-left flex items-center justify-between pl-4 pr-3 cursor-pointer"
|
||||
@click="isCollapse = !isCollapse"
|
||||
>
|
||||
<span class="text-sm font-semibold line-clamp-2">
|
||||
<slot name="label">
|
||||
{{ props.label ?? '' }}
|
||||
</slot>
|
||||
</span>
|
||||
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
'icon-[lucide--chevron-down] size-5 min-w-5 transition-all',
|
||||
isCollapse && 'rotate-90'
|
||||
)
|
||||
"
|
||||
class="relative top-px text-xs leading-none text-node-component-header-icon group-hover:text-base-foreground"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="!isCollapse" class="pb-4">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
79
src/components/rightSidePanel/layout/SidePanelSearch.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
import { refDebounced } from '@vueuse/core'
|
||||
import { ref, toRef, watch } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
searcher?: (
|
||||
query: string,
|
||||
onCleanup: (cleanupFn: () => void) => void
|
||||
) => Promise<void>
|
||||
updateKey?: (() => unknown) | unknown
|
||||
}>(),
|
||||
{
|
||||
searcher: async () => {}
|
||||
}
|
||||
)
|
||||
|
||||
const searchQuery = defineModel<string>({ default: '' })
|
||||
|
||||
const isQuerying = ref(false)
|
||||
const debouncedSearchQuery = refDebounced(searchQuery, 700, {
|
||||
maxWait: 700
|
||||
})
|
||||
watch(searchQuery, (value) => {
|
||||
isQuerying.value = value !== debouncedSearchQuery.value
|
||||
})
|
||||
|
||||
const updateKey =
|
||||
typeof props.updateKey === 'function'
|
||||
? props.updateKey
|
||||
: toRef(props, 'updateKey')
|
||||
|
||||
watch(
|
||||
[debouncedSearchQuery, updateKey],
|
||||
(_, __, onCleanup) => {
|
||||
let isCleanup = false
|
||||
let cleanupFn: undefined | (() => void)
|
||||
onCleanup(() => {
|
||||
isCleanup = true
|
||||
cleanupFn?.()
|
||||
})
|
||||
|
||||
void props
|
||||
.searcher(debouncedSearchQuery.value, (cb) => (cleanupFn = cb))
|
||||
.finally(() => {
|
||||
if (!isCleanup) isQuerying.value = false
|
||||
})
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<label
|
||||
:class="
|
||||
cn(
|
||||
'h-8 bg-zinc-500/20 rounded-lg outline outline-offset-[-1px] outline-node-component-border transition-all duration-150',
|
||||
'flex-1 flex px-2 items-center text-base leading-none cursor-text',
|
||||
searchQuery?.trim() !== '' ? 'text-base-foreground' : '',
|
||||
'hover:outline-component-node-widget-background-highlighted/80',
|
||||
'focus-within:outline-component-node-widget-background-highlighted/80'
|
||||
)
|
||||
"
|
||||
>
|
||||
<i
|
||||
v-if="isQuerying"
|
||||
class="mr-2 icon-[lucide--loader-circle] size-4 animate-spin"
|
||||
/>
|
||||
<i v-else class="mr-2 icon-[lucide--search] size-4" />
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
class="bg-transparent border-0 outline-0 ring-0 text-left"
|
||||
:placeholder="$t('g.search')"
|
||||
/>
|
||||
</label>
|
||||
</template>
|
||||
70
src/components/rightSidePanel/parameters/SectionWidgets.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import { provide } from 'vue'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import WidgetLegacy from '@/renderer/extensions/vueNodes/widgets/components/WidgetLegacy.vue'
|
||||
import { getComponent } from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
|
||||
|
||||
import RightPanelSection from '../layout/RightPanelSection.vue'
|
||||
|
||||
defineProps<{
|
||||
label?: string
|
||||
widgets: { widget: IBaseWidget; node: LGraphNode }[]
|
||||
}>()
|
||||
|
||||
provide('hideLayoutField', true)
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
function getWidgetComponent(widget: IBaseWidget) {
|
||||
const component = getComponent(widget.type, widget.name)
|
||||
return component || WidgetLegacy
|
||||
}
|
||||
|
||||
function onWidgetValueChange(
|
||||
widget: IBaseWidget,
|
||||
value: string | number | boolean | object
|
||||
) {
|
||||
widget.value = value
|
||||
widget.callback?.(value)
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RightPanelSection>
|
||||
<template #label>
|
||||
<slot name="label">
|
||||
{{ label ?? $t('rightSidePanel.inputs') }}
|
||||
</slot>
|
||||
</template>
|
||||
|
||||
<div class="space-y-4 rounded-lg bg-interface-surface px-4">
|
||||
<div
|
||||
v-for="({ widget, node }, index) in widgets"
|
||||
:key="`widget-${index}-${widget.name}`"
|
||||
class="widget-item gap-1.5 col-span-full grid grid-cols-subgrid"
|
||||
>
|
||||
<div class="min-h-8">
|
||||
<p v-if="widget.name" class="text-sm leading-8 p-0 m-0 line-clamp-1">
|
||||
{{ widget.label || widget.name }}
|
||||
</p>
|
||||
</div>
|
||||
<component
|
||||
:is="getWidgetComponent(widget)"
|
||||
:widget="widget"
|
||||
:model-value="widget.value"
|
||||
:node-id="String(node.id)"
|
||||
:node-type="node.type"
|
||||
class="col-span-1"
|
||||
@update:model-value="
|
||||
(value: string | number | boolean | object) =>
|
||||
onWidgetValueChange(widget, value)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</RightPanelSection>
|
||||
</template>
|
||||
89
src/components/rightSidePanel/parameters/TabParameters.vue
Normal file
@@ -0,0 +1,89 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, shallowRef } from 'vue'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import SidePanelSearch from '../layout/SidePanelSearch.vue'
|
||||
import SectionWidgets from './SectionWidgets.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
nodes: LGraphNode[]
|
||||
}>()
|
||||
|
||||
const widgetsSectionDataList = computed(() => {
|
||||
const list: {
|
||||
widgets: { node: LGraphNode; widget: IBaseWidget }[]
|
||||
node: LGraphNode
|
||||
}[] = []
|
||||
for (const node of props.nodes) {
|
||||
const shownWidgets: IBaseWidget[] = []
|
||||
for (const widget of node.widgets ?? []) {
|
||||
if (widget.options?.canvasOnly || widget.options?.hidden) continue
|
||||
shownWidgets.push(widget)
|
||||
}
|
||||
list.push({
|
||||
widgets: shownWidgets?.map((widget) => ({ node, widget })) ?? [],
|
||||
node
|
||||
})
|
||||
}
|
||||
return list
|
||||
})
|
||||
|
||||
const searchedWidgetsSectionDataList = shallowRef<
|
||||
{
|
||||
widgets: { node: LGraphNode; widget: IBaseWidget }[]
|
||||
node: LGraphNode
|
||||
}[]
|
||||
>([])
|
||||
|
||||
/**
|
||||
* Searches widgets in all selected nodes and returns search results.
|
||||
* Filters by name, localized label, type, and user-input value.
|
||||
* Performs basic tokenization of the query string.
|
||||
*/
|
||||
async function searcher(query: string) {
|
||||
if (query.trim() === '') {
|
||||
searchedWidgetsSectionDataList.value = widgetsSectionDataList.value
|
||||
return
|
||||
}
|
||||
const words = query.trim().toLowerCase().split(' ')
|
||||
searchedWidgetsSectionDataList.value = widgetsSectionDataList.value
|
||||
.map((item) => {
|
||||
return {
|
||||
...item,
|
||||
widgets: item.widgets.filter(({ widget }) => {
|
||||
const label = widget.label?.toLowerCase()
|
||||
const name = widget.name.toLowerCase()
|
||||
const type = widget.type.toLowerCase()
|
||||
const value = widget.value?.toString().toLowerCase()
|
||||
return words.every(
|
||||
(word) =>
|
||||
name.includes(word) ||
|
||||
label?.includes(word) ||
|
||||
type?.includes(word) ||
|
||||
value?.includes(word)
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
.filter((item) => item.widgets.length > 0)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4 flex gap-2 border-b border-interface-stroke">
|
||||
<SidePanelSearch :searcher :update-key="widgetsSectionDataList" />
|
||||
</div>
|
||||
<SectionWidgets
|
||||
v-for="section in searchedWidgetsSectionDataList"
|
||||
:key="section.node.id"
|
||||
:label="widgetsSectionDataList.length > 1 ? section.node.title : undefined"
|
||||
:widgets="section.widgets"
|
||||
:default-collapse="
|
||||
widgetsSectionDataList.length > 1 &&
|
||||
widgetsSectionDataList === searchedWidgetsSectionDataList
|
||||
"
|
||||
class="border-b border-interface-stroke"
|
||||
/>
|
||||
</template>
|
||||
230
src/components/rightSidePanel/settings/TabSettings.vue
Normal file
@@ -0,0 +1,230 @@
|
||||
<template>
|
||||
<div class="space-y-4 rounded-lg bg-interface-surface p-3">
|
||||
<!-- Node State -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-sm text-text-secondary">
|
||||
{{ t('rightSidePanel.nodeState') }}
|
||||
</span>
|
||||
<FormSelectButton
|
||||
v-model="nodeState"
|
||||
class="w-full"
|
||||
:options="[
|
||||
{
|
||||
label: t('rightSidePanel.normal'),
|
||||
value: LGraphEventMode.ALWAYS
|
||||
},
|
||||
{
|
||||
label: t('rightSidePanel.bypass'),
|
||||
value: LGraphEventMode.BYPASS
|
||||
},
|
||||
{
|
||||
label: t('rightSidePanel.mute'),
|
||||
value: LGraphEventMode.NEVER
|
||||
}
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Color Picker -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-sm text-text-secondary">
|
||||
{{ t('rightSidePanel.color') }}
|
||||
</span>
|
||||
<div
|
||||
class="bg-component-node-widget-background text-component-node-foreground border-none rounded-lg p-1 grid grid-cols-5 gap-1 justify-items-center"
|
||||
>
|
||||
<button
|
||||
v-for="option of colorOptions"
|
||||
:key="option.name"
|
||||
:class="
|
||||
cn(
|
||||
'size-8 rounded-lg bg-transparent border-0 outline-0 ring-0 text-left flex justify-center items-center cursor-pointer',
|
||||
{
|
||||
'bg-interface-menu-component-surface-selected':
|
||||
option.name === nodeColor,
|
||||
'hover:bg-interface-menu-component-surface-selected':
|
||||
option.name !== nodeColor
|
||||
}
|
||||
)
|
||||
"
|
||||
@click="nodeColor = option.name"
|
||||
>
|
||||
<div
|
||||
v-tooltip.top="option.localizedName()"
|
||||
:class="cn('size-4 rounded-full ring-2 ring-gray-500/10')"
|
||||
:style="{
|
||||
backgroundColor: isLightTheme
|
||||
? option.value.light
|
||||
: option.value.dark,
|
||||
'--tw-ring-color':
|
||||
option.name === nodeColor
|
||||
? isLightTheme
|
||||
? option.value.ringLight
|
||||
: option.value.ringDark
|
||||
: undefined
|
||||
}"
|
||||
:data-testid="option.name"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pinned Toggle -->
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-text-secondary">
|
||||
{{ t('rightSidePanel.pinned') }}
|
||||
</span>
|
||||
<ToggleSwitch v-model="isPinned" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ToggleSwitch from 'primevue/toggleswitch'
|
||||
import { computed, shallowRef, triggerRef, watchEffect } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { LGraphCanvas, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { ColorOption, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import FormSelectButton from '@/renderer/extensions/vueNodes/widgets/components/form/FormSelectButton.vue'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { adjustColor } from '@/utils/colorUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const props = defineProps<{
|
||||
node?: LGraphNode
|
||||
nodes?: LGraphNode[]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
const isLightTheme = computed(
|
||||
() => colorPaletteStore.completedActivePalette.light_theme
|
||||
)
|
||||
|
||||
const targetNodes = shallowRef<LGraphNode[]>([])
|
||||
watchEffect(() => {
|
||||
if (props.node) {
|
||||
targetNodes.value = [props.node]
|
||||
} else {
|
||||
targetNodes.value = props.nodes || []
|
||||
}
|
||||
})
|
||||
|
||||
const nodeState = computed({
|
||||
get() {
|
||||
let mode: LGraphNode['mode'] | null = null
|
||||
|
||||
// For multiple nodes, if all nodes have the same mode, return that mode, otherwise return null
|
||||
if (targetNodes.value.length > 1) {
|
||||
mode = targetNodes.value[0].mode
|
||||
if (!targetNodes.value.every((node) => node.mode === mode)) {
|
||||
mode = null
|
||||
}
|
||||
} else {
|
||||
mode = targetNodes.value[0].mode
|
||||
}
|
||||
|
||||
return mode
|
||||
},
|
||||
set(value: LGraphNode['mode']) {
|
||||
targetNodes.value.forEach((node) => {
|
||||
node.mode = value
|
||||
})
|
||||
triggerRef(targetNodes)
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
}
|
||||
})
|
||||
|
||||
// Pinned state
|
||||
const isPinned = computed<boolean>({
|
||||
get() {
|
||||
return targetNodes.value.some((node) => node.pinned)
|
||||
},
|
||||
set(value) {
|
||||
targetNodes.value.forEach((node) => node.pin(value))
|
||||
triggerRef(targetNodes)
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
}
|
||||
})
|
||||
|
||||
type NodeColorOption = {
|
||||
name: string
|
||||
localizedName: () => string
|
||||
value: {
|
||||
dark: string
|
||||
light: string
|
||||
ringDark: string
|
||||
ringLight: string
|
||||
}
|
||||
}
|
||||
|
||||
function getColorValue(color: string): NodeColorOption['value'] {
|
||||
return {
|
||||
dark: adjustColor(color, { lightness: 0.3 }),
|
||||
light: adjustColor(color, { lightness: 0.4 }),
|
||||
ringDark: adjustColor(color, { lightness: 0.5 }),
|
||||
ringLight: adjustColor(color, { lightness: 0.1 })
|
||||
}
|
||||
}
|
||||
|
||||
const NO_COLOR_OPTION: NodeColorOption = {
|
||||
name: 'noColor',
|
||||
localizedName: () => t('color.noColor'),
|
||||
value: getColorValue(LiteGraph.NODE_DEFAULT_BGCOLOR)
|
||||
}
|
||||
|
||||
const nodeColorEntries = Object.entries(LGraphCanvas.node_colors)
|
||||
|
||||
const colorOptions: NodeColorOption[] = [
|
||||
NO_COLOR_OPTION,
|
||||
...nodeColorEntries.map(([name, color]) => ({
|
||||
name,
|
||||
localizedName: () => t(`color.${name}`),
|
||||
value: getColorValue(color.bgcolor)
|
||||
}))
|
||||
]
|
||||
|
||||
const nodeColor = computed<NodeColorOption['name'] | null>({
|
||||
get() {
|
||||
if (targetNodes.value.length === 0) return null
|
||||
const theColorOptions = targetNodes.value.map((item) =>
|
||||
item.getColorOption()
|
||||
)
|
||||
|
||||
let colorOption: ColorOption | null | false = theColorOptions[0]
|
||||
if (!theColorOptions.every((option) => option === colorOption)) {
|
||||
colorOption = false
|
||||
}
|
||||
|
||||
if (colorOption === false) return null
|
||||
if (colorOption == null || (!colorOption.bgcolor && !colorOption.color))
|
||||
return NO_COLOR_OPTION.name
|
||||
return (
|
||||
nodeColorEntries.find(
|
||||
([_, color]) =>
|
||||
color.bgcolor === colorOption.bgcolor &&
|
||||
color.color === colorOption.color
|
||||
)?.[0] ?? null
|
||||
)
|
||||
},
|
||||
set(colorName) {
|
||||
if (colorName === null) return
|
||||
|
||||
const canvasColorOption =
|
||||
colorName === NO_COLOR_OPTION.name
|
||||
? null
|
||||
: LGraphCanvas.node_colors[colorName]
|
||||
|
||||
for (const item of targetNodes.value) {
|
||||
item.setColorOption(canvasColorOption)
|
||||
}
|
||||
triggerRef(targetNodes)
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { refDebounced, watchDebounced } from '@vueuse/core'
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import Button from 'primevue/button'
|
||||
import {
|
||||
computed,
|
||||
customRef,
|
||||
@@ -9,8 +10,6 @@ import {
|
||||
triggerRef
|
||||
} from 'vue'
|
||||
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import SubgraphNodeWidget from '@/core/graph/subgraph/SubgraphNodeWidget.vue'
|
||||
import {
|
||||
demoteWidget,
|
||||
isRecommendedWidget,
|
||||
@@ -29,14 +28,15 @@ import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { DraggableList } from '@/scripts/ui/draggableList'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
import SidePanelSearch from '../layout/SidePanelSearch.vue'
|
||||
import SubgraphNodeWidget from './SubgraphNodeWidget.vue'
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const draggableList = ref<DraggableList | undefined>(undefined)
|
||||
const draggableItems = ref()
|
||||
const searchQuery = ref<string>('')
|
||||
const debouncedQuery = refDebounced(searchQuery, 200)
|
||||
const proxyWidgets = customRef<ProxyWidgetsProperty>((track, trigger) => ({
|
||||
get() {
|
||||
track()
|
||||
@@ -56,10 +56,13 @@ const proxyWidgets = customRef<ProxyWidgetsProperty>((track, trigger) => ({
|
||||
}
|
||||
}))
|
||||
|
||||
async function searcher(query: string) {
|
||||
searchQuery.value = query
|
||||
}
|
||||
|
||||
const activeNode = computed(() => {
|
||||
const node = canvasStore.selectedItems[0]
|
||||
if (node instanceof SubgraphNode) return node
|
||||
useDialogStore().closeDialog()
|
||||
return undefined
|
||||
})
|
||||
|
||||
@@ -114,7 +117,7 @@ const candidateWidgets = computed<WidgetItem[]>(() => {
|
||||
)
|
||||
})
|
||||
const filteredCandidates = computed<WidgetItem[]>(() => {
|
||||
const query = debouncedQuery.value.toLowerCase()
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
if (!query) return candidateWidgets.value
|
||||
return candidateWidgets.value.filter(
|
||||
([n, w]: WidgetItem) =>
|
||||
@@ -125,12 +128,12 @@ const filteredCandidates = computed<WidgetItem[]>(() => {
|
||||
|
||||
const recommendedWidgets = computed(() => {
|
||||
const node = activeNode.value
|
||||
if (!node) return [] //Not reachable
|
||||
if (!node) return []
|
||||
return filteredCandidates.value.filter(isRecommendedWidget)
|
||||
})
|
||||
|
||||
const filteredActive = computed<WidgetItem[]>(() => {
|
||||
const query = debouncedQuery.value.toLowerCase()
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
if (!query) return activeWidgets.value
|
||||
return activeWidgets.value.filter(
|
||||
([n, w]: WidgetItem) =>
|
||||
@@ -160,7 +163,7 @@ function promote([node, widget]: WidgetItem) {
|
||||
}
|
||||
function showAll() {
|
||||
const node = activeNode.value
|
||||
if (!node) return //Not reachable
|
||||
if (!node) return
|
||||
const widgets = proxyWidgets.value
|
||||
const toAdd: ProxyWidgetsProperty =
|
||||
filteredCandidates.value.map(widgetItemToProperty)
|
||||
@@ -169,7 +172,7 @@ function showAll() {
|
||||
}
|
||||
function hideAll() {
|
||||
const node = activeNode.value
|
||||
if (!node) return //Not reachable
|
||||
if (!node) return
|
||||
proxyWidgets.value = proxyWidgets.value.filter(
|
||||
(propertyItem) =>
|
||||
!filteredActive.value.some(matchesWidgetItem(propertyItem)) ||
|
||||
@@ -178,25 +181,21 @@ function hideAll() {
|
||||
}
|
||||
function showRecommended() {
|
||||
const node = activeNode.value
|
||||
if (!node) return //Not reachable
|
||||
if (!node) return
|
||||
const widgets = proxyWidgets.value
|
||||
const toAdd: ProxyWidgetsProperty =
|
||||
recommendedWidgets.value.map(widgetItemToProperty)
|
||||
//TODO: Add sort step here
|
||||
//Input should always be before output by default
|
||||
widgets.push(...toAdd)
|
||||
proxyWidgets.value = widgets
|
||||
}
|
||||
|
||||
function setDraggableState() {
|
||||
draggableList.value?.dispose()
|
||||
if (debouncedQuery.value || !draggableItems.value?.children?.length) return
|
||||
if (searchQuery.value || !draggableItems.value?.children?.length) return
|
||||
draggableList.value = new DraggableList(
|
||||
draggableItems.value,
|
||||
'.draggable-item'
|
||||
)
|
||||
//Original implementation plays really poorly with vue,
|
||||
//It has been modified to not add/remove elements
|
||||
draggableList.value.applyNewItemsOrder = function () {
|
||||
const reorderedItems = []
|
||||
|
||||
@@ -242,70 +241,87 @@ onBeforeUnmount(() => {
|
||||
draggableList.value?.dispose()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SearchBox
|
||||
v-model:model-value="searchQuery"
|
||||
class="p-2"
|
||||
:placeholder="$t('g.search') + '...'"
|
||||
/>
|
||||
<div
|
||||
v-if="filteredActive.length"
|
||||
class="border-b-1 border-node-component-border pt-1 pb-4"
|
||||
>
|
||||
<div class="flex justify-between px-4 py-0">
|
||||
<div class="text-[9px] font-semibold text-slate-100 uppercase">
|
||||
{{ $t('subgraphStore.shown') }}
|
||||
<div v-if="activeNode" class="subgraph-edit-section flex h-full flex-col">
|
||||
<div class="p-4 flex gap-2">
|
||||
<SidePanelSearch :searcher />
|
||||
</div>
|
||||
|
||||
<div class="flex-1">
|
||||
<div
|
||||
v-if="filteredActive.length"
|
||||
class="flex flex-col border-t border-interface-stroke"
|
||||
>
|
||||
<div
|
||||
class="sticky top-0 z-10 flex items-center justify-between backdrop-blur-xl min-h-12 px-4"
|
||||
>
|
||||
<div class="text-sm font-semibold uppercase line-clamp-1">
|
||||
{{ $t('subgraphStore.shown') }}
|
||||
</div>
|
||||
<a
|
||||
class="cursor-pointer text-right text-xs font-normal text-text-secondary hover:text-azure-600 whitespace-nowrap"
|
||||
@click.stop="hideAll"
|
||||
>
|
||||
{{ $t('subgraphStore.hideAll') }}</a
|
||||
>
|
||||
</div>
|
||||
<div ref="draggableItems" class="pb-2 px-2 space-y-0.5 mt-0.5">
|
||||
<SubgraphNodeWidget
|
||||
v-for="[node, widget] in filteredActive"
|
||||
:key="toKey([node, widget])"
|
||||
class="bg-interface-panel-surface"
|
||||
:node-title="node.title"
|
||||
:widget-name="widget.name"
|
||||
:is-shown="true"
|
||||
:is-draggable="!searchQuery"
|
||||
:is-physical="node.id === -1"
|
||||
@toggle-visibility="demote([node, widget])"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
class="cursor-pointer text-right text-[11px] font-normal text-azure-600"
|
||||
@click.stop="hideAll"
|
||||
|
||||
<div
|
||||
v-if="filteredCandidates.length"
|
||||
class="flex flex-col border-t border-interface-stroke"
|
||||
>
|
||||
{{ $t('subgraphStore.hideAll') }}</a
|
||||
>
|
||||
</div>
|
||||
<div ref="draggableItems">
|
||||
<SubgraphNodeWidget
|
||||
v-for="[node, widget] in filteredActive"
|
||||
:key="toKey([node, widget])"
|
||||
:node-title="node.title"
|
||||
:widget-name="widget.name"
|
||||
:is-shown="true"
|
||||
:is-draggable="!debouncedQuery"
|
||||
:is-physical="node.id === -1"
|
||||
@toggle-visibility="demote([node, widget])"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="filteredCandidates.length" class="pt-1 pb-4">
|
||||
<div class="flex justify-between px-4 py-0">
|
||||
<div class="text-[9px] font-semibold text-slate-100 uppercase">
|
||||
{{ $t('subgraphStore.hidden') }}
|
||||
<div
|
||||
class="sticky top-0 z-10 flex items-center justify-between backdrop-blur-xl min-h-12 px-4"
|
||||
>
|
||||
<div class="text-sm font-semibold uppercase line-clamp-1">
|
||||
{{ $t('subgraphStore.hidden') }}
|
||||
</div>
|
||||
<a
|
||||
class="cursor-pointer text-right text-xs font-normal text-text-secondary hover:text-azure-600 whitespace-nowrap"
|
||||
@click.stop="showAll"
|
||||
>
|
||||
{{ $t('subgraphStore.showAll') }}</a
|
||||
>
|
||||
</div>
|
||||
<div class="pb-2 px-2 space-y-0.5 mt-0.5">
|
||||
<SubgraphNodeWidget
|
||||
v-for="[node, widget] in filteredCandidates"
|
||||
:key="toKey([node, widget])"
|
||||
class="bg-interface-panel-surface"
|
||||
:node-title="node.title"
|
||||
:widget-name="widget.name"
|
||||
@toggle-visibility="promote([node, widget])"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
class="cursor-pointer text-right text-[11px] font-normal text-azure-600"
|
||||
@click.stop="showAll"
|
||||
>
|
||||
{{ $t('subgraphStore.showAll') }}</a
|
||||
|
||||
<div
|
||||
v-if="recommendedWidgets.length"
|
||||
class="flex justify-center border-t border-interface-stroke py-4"
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
class="rounded border-none px-3 py-0.5"
|
||||
@click.stop="showRecommended"
|
||||
>
|
||||
{{ $t('subgraphStore.showRecommended') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<SubgraphNodeWidget
|
||||
v-for="[node, widget] in filteredCandidates"
|
||||
:key="toKey([node, widget])"
|
||||
:node-title="node.title"
|
||||
:widget-name="widget.name"
|
||||
@toggle-visibility="promote([node, widget])"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="recommendedWidgets.length"
|
||||
class="flex justify-center border-t-1 border-node-component-border py-4"
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
class="rounded border-none px-3 py-0.5"
|
||||
@click.stop="showRecommended"
|
||||
>
|
||||
{{ $t('subgraphStore.showRecommended') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -2,6 +2,7 @@
|
||||
import Button from 'primevue/button'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import type { ClassValue } from '@/utils/tailwindUtil'
|
||||
|
||||
const props = defineProps<{
|
||||
nodeTitle: string
|
||||
@@ -9,19 +10,12 @@ const props = defineProps<{
|
||||
isShown?: boolean
|
||||
isDraggable?: boolean
|
||||
isPhysical?: boolean
|
||||
class?: ClassValue
|
||||
}>()
|
||||
defineEmits<{
|
||||
(e: 'toggleVisibility'): void
|
||||
}>()
|
||||
|
||||
function classes() {
|
||||
return cn(
|
||||
'flex py-1 pr-4 pl-0 break-all rounded items-center gap-1',
|
||||
'bg-node-component-surface',
|
||||
props.isDraggable &&
|
||||
'draggable-item drag-handle cursor-grab [&.is-draggable]:cursor-grabbing'
|
||||
)
|
||||
}
|
||||
function getIcon() {
|
||||
return props.isPhysical
|
||||
? 'icon-[lucide--link]'
|
||||
@@ -30,19 +24,24 @@ function getIcon() {
|
||||
: 'icon-[lucide--eye-off]'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="classes()">
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'size-4 pointer-events-none',
|
||||
isDraggable ? 'icon-[lucide--grip-vertical]' : ''
|
||||
)
|
||||
"
|
||||
/>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex py-1 px-2 break-all rounded items-center gap-1',
|
||||
'bg-node-component-surface',
|
||||
props.isDraggable &&
|
||||
'draggable-item drag-handle cursor-grab [&.is-draggable]:cursor-grabbing hover:ring-1 ring-accent-background',
|
||||
props.class
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="pointer-events-none flex-1">
|
||||
<div class="text-[10px] text-slate-100">{{ nodeTitle }}</div>
|
||||
<div class="text-xs">{{ widgetName }}</div>
|
||||
<div class="text-xs text-text-secondary line-clamp-1">
|
||||
{{ nodeTitle }}
|
||||
</div>
|
||||
<div class="text-sm line-clamp-1 leading-8">{{ widgetName }}</div>
|
||||
</div>
|
||||
<Button
|
||||
size="small"
|
||||
@@ -52,5 +51,9 @@ function getIcon() {
|
||||
severity="secondary"
|
||||
@click.stop="$emit('toggleVisibility')"
|
||||
/>
|
||||
<div
|
||||
v-if="isDraggable"
|
||||
class="size-4 pointer-events-none icon-[lucide--grip-vertical]"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -73,6 +73,7 @@
|
||||
@click.stop="handleNodes2ToggleClick"
|
||||
>
|
||||
<span class="p-menubar-item-label text-nowrap">{{ item.label }}</span>
|
||||
<Tag severity="info" class="ml-2 text-xs">{{ $t('g.beta') }}</Tag>
|
||||
<ToggleSwitch
|
||||
v-model="nodes2Enabled"
|
||||
class="ml-4"
|
||||
@@ -101,6 +102,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import Tag from 'primevue/tag'
|
||||
import TieredMenu from 'primevue/tieredmenu'
|
||||
import type { TieredMenuMethods, TieredMenuState } from 'primevue/tieredmenu'
|
||||
import ToggleSwitch from 'primevue/toggleswitch'
|
||||
|
||||
@@ -85,13 +85,12 @@
|
||||
:show-output-count="shouldShowOutputCount(item)"
|
||||
:output-count="getOutputCount(item)"
|
||||
:show-delete-button="shouldShowDeleteButton"
|
||||
:open-popover-id="openPopoverId"
|
||||
:open-context-menu-id="openContextMenuId"
|
||||
@click="handleAssetSelect(item)"
|
||||
@zoom="handleZoomClick(item)"
|
||||
@output-count-click="enterFolderView(item)"
|
||||
@asset-deleted="refreshAssets"
|
||||
@popover-opened="openPopoverId = item.id"
|
||||
@popover-closed="openPopoverId = null"
|
||||
@context-menu-opened="openContextMenuId = item.id"
|
||||
/>
|
||||
</template>
|
||||
</VirtualGrid>
|
||||
@@ -113,7 +112,7 @@
|
||||
count: totalOutputCount
|
||||
})
|
||||
"
|
||||
type="transparent"
|
||||
type="secondary"
|
||||
:class="isCompact ? 'text-left' : ''"
|
||||
@click="handleDeselectAll"
|
||||
/>
|
||||
@@ -202,8 +201,8 @@ const folderPromptId = ref<string | null>(null)
|
||||
const folderExecutionTime = ref<number | undefined>(undefined)
|
||||
const isInFolderView = computed(() => folderPromptId.value !== null)
|
||||
|
||||
// Track which asset's popover is open (for single-instance popover management)
|
||||
const openPopoverId = ref<string | null>(null)
|
||||
// Track which asset's context menu is open (for single-instance context menu management)
|
||||
const openContextMenuId = ref<string | null>(null)
|
||||
|
||||
// Determine if delete button should be shown
|
||||
// Hide delete button when in input tab and not in cloud (OSS mode - files are from local folders)
|
||||
|
||||
@@ -12,234 +12,21 @@
|
||||
/>
|
||||
<span class="ml-2 font-semibold">{{ node.display_name }}</span>
|
||||
</div>
|
||||
<div class="node-help-content mx-auto w-full grow p-4">
|
||||
<ProgressSpinner
|
||||
v-if="isLoading"
|
||||
class="m-auto"
|
||||
:aria-label="$t('g.loading')"
|
||||
/>
|
||||
<!-- Markdown fetched successfully -->
|
||||
<div
|
||||
v-else-if="!error"
|
||||
class="markdown-content"
|
||||
v-html="renderedHelpHtml"
|
||||
/>
|
||||
<!-- Fallback: markdown not found or fetch error -->
|
||||
<div v-else class="fallback-content space-y-6 text-sm">
|
||||
<p v-if="node.description">
|
||||
<strong>{{ $t('g.description') }}:</strong> {{ node.description }}
|
||||
</p>
|
||||
|
||||
<div v-if="inputList.length">
|
||||
<p>
|
||||
<strong>{{ $t('nodeHelpPage.inputs') }}:</strong>
|
||||
</p>
|
||||
<!-- Using plain HTML table instead of DataTable for consistent styling with markdown content -->
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t('g.name') }}</th>
|
||||
<th>{{ $t('nodeHelpPage.type') }}</th>
|
||||
<th>{{ $t('g.description') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="input in inputList" :key="input.name">
|
||||
<td>
|
||||
<code>{{ input.name }}</code>
|
||||
</td>
|
||||
<td>{{ input.type }}</td>
|
||||
<td>{{ input.tooltip || '-' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="outputList.length">
|
||||
<p>
|
||||
<strong>{{ $t('nodeHelpPage.outputs') }}:</strong>
|
||||
</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t('g.name') }}</th>
|
||||
<th>{{ $t('nodeHelpPage.type') }}</th>
|
||||
<th>{{ $t('g.description') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="output in outputList" :key="output.name">
|
||||
<td>
|
||||
<code>{{ output.name }}</code>
|
||||
</td>
|
||||
<td>{{ output.type }}</td>
|
||||
<td>{{ output.tooltip || '-' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grow p-4">
|
||||
<NodeHelpContent :node="node" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import Button from 'primevue/button'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import NodeHelpContent from '@/components/node/NodeHelpContent.vue'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
|
||||
|
||||
const { node } = defineProps<{ node: ComfyNodeDefImpl }>()
|
||||
|
||||
const nodeHelpStore = useNodeHelpStore()
|
||||
const { renderedHelpHtml, isLoading, error } = storeToRefs(nodeHelpStore)
|
||||
|
||||
defineEmits<{
|
||||
(e: 'close'): void
|
||||
}>()
|
||||
|
||||
const inputList = computed(() =>
|
||||
Object.values(node.inputs).map((spec) => ({
|
||||
name: spec.name,
|
||||
type: spec.type,
|
||||
tooltip: spec.tooltip || ''
|
||||
}))
|
||||
)
|
||||
|
||||
const outputList = computed(() =>
|
||||
node.outputs.map((spec) => ({
|
||||
name: spec.name,
|
||||
type: spec.type,
|
||||
tooltip: spec.tooltip || ''
|
||||
}))
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference './../../../../assets/css/style.css';
|
||||
|
||||
.node-help-content :deep(:is(img, video)) {
|
||||
@apply max-w-full h-auto block mb-4;
|
||||
}
|
||||
|
||||
.markdown-content,
|
||||
.fallback-content {
|
||||
@apply text-sm;
|
||||
}
|
||||
|
||||
.markdown-content :deep(h1),
|
||||
.fallback-content h1 {
|
||||
@apply text-[22px] font-bold mt-8 mb-4 first:mt-0;
|
||||
}
|
||||
|
||||
.markdown-content :deep(h2),
|
||||
.fallback-content h2 {
|
||||
@apply text-[18px] font-bold mt-8 mb-4 first:mt-0;
|
||||
}
|
||||
|
||||
.markdown-content :deep(h3),
|
||||
.fallback-content h3 {
|
||||
@apply text-[16px] font-bold mt-8 mb-4 first:mt-0;
|
||||
}
|
||||
|
||||
.markdown-content :deep(h4),
|
||||
.markdown-content :deep(h5),
|
||||
.markdown-content :deep(h6),
|
||||
.fallback-content h4,
|
||||
.fallback-content h5,
|
||||
.fallback-content h6 {
|
||||
@apply mt-8 mb-4 first:mt-0;
|
||||
}
|
||||
|
||||
.markdown-content :deep(td),
|
||||
.fallback-content td {
|
||||
color: var(--drag-text);
|
||||
}
|
||||
|
||||
.markdown-content :deep(a),
|
||||
.fallback-content a {
|
||||
color: var(--drag-text);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.markdown-content :deep(th),
|
||||
.fallback-content th {
|
||||
color: var(--fg-color);
|
||||
}
|
||||
|
||||
.markdown-content :deep(ul),
|
||||
.markdown-content :deep(ol),
|
||||
.fallback-content ul,
|
||||
.fallback-content ol {
|
||||
@apply pl-8 my-2;
|
||||
}
|
||||
|
||||
.markdown-content :deep(ul ul),
|
||||
.markdown-content :deep(ol ol),
|
||||
.markdown-content :deep(ul ol),
|
||||
.markdown-content :deep(ol ul),
|
||||
.fallback-content ul ul,
|
||||
.fallback-content ol ol,
|
||||
.fallback-content ul ol,
|
||||
.fallback-content ol ul {
|
||||
@apply pl-6 my-2;
|
||||
}
|
||||
|
||||
.markdown-content :deep(li),
|
||||
.fallback-content li {
|
||||
@apply my-2;
|
||||
}
|
||||
|
||||
.markdown-content :deep(*:first-child),
|
||||
.fallback-content > *:first-child {
|
||||
@apply mt-0;
|
||||
}
|
||||
|
||||
.markdown-content :deep(code),
|
||||
.fallback-content code {
|
||||
color: var(--code-text-color);
|
||||
background-color: var(--code-bg-color);
|
||||
@apply rounded px-1.5 py-0.5;
|
||||
}
|
||||
|
||||
.markdown-content :deep(table),
|
||||
.fallback-content table {
|
||||
@apply w-full border-collapse;
|
||||
}
|
||||
|
||||
.markdown-content :deep(th),
|
||||
.markdown-content :deep(td),
|
||||
.fallback-content th,
|
||||
.fallback-content td {
|
||||
@apply px-2 py-2;
|
||||
}
|
||||
|
||||
.markdown-content :deep(tr),
|
||||
.fallback-content tr {
|
||||
border-bottom: 1px solid var(--content-bg);
|
||||
}
|
||||
|
||||
.markdown-content :deep(tr:last-child),
|
||||
.fallback-content tr:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.markdown-content :deep(thead),
|
||||
.fallback-content thead {
|
||||
border-bottom: 1px solid var(--p-text-color);
|
||||
}
|
||||
|
||||
.markdown-content :deep(pre),
|
||||
.fallback-content pre {
|
||||
@apply rounded p-4 my-4 overflow-x-auto;
|
||||
background-color: var(--code-block-bg-color);
|
||||
|
||||
code {
|
||||
@apply bg-transparent p-0;
|
||||
color: var(--p-text-color);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
v-for="(button, index) in actionBarButtonStore.buttons"
|
||||
:key="index"
|
||||
v-tooltip.bottom="button.tooltip"
|
||||
:label="button.label"
|
||||
:label="isMobile ? undefined : button.label"
|
||||
:aria-label="button.tooltip || button.label"
|
||||
:class="button.class"
|
||||
text
|
||||
@@ -21,9 +21,13 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
|
||||
import Button from 'primevue/button'
|
||||
|
||||
import { useActionBarButtonStore } from '@/stores/actionBarButtonStore'
|
||||
|
||||
const actionBarButtonStore = useActionBarButtonStore()
|
||||
|
||||
const breakpoints = useBreakpoints(breakpointsTailwind)
|
||||
const isMobile = breakpoints.smaller('sm')
|
||||
</script>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
v-if="showVueNodesBanner"
|
||||
class="pointer-events-auto relative w-full h-10 bg-gradient-to-r from-blue-600 to-blue-700 flex items-center justify-center px-4"
|
||||
>
|
||||
<div class="flex items-center text-sm">
|
||||
<div class="flex items-center text-sm text-white">
|
||||
<i class="icon-[lucide--rocket]"></i>
|
||||
<span class="pl-2">{{ $t('vueNodesBanner.title') }}</span>
|
||||
<span class="pl-1.5 hidden md:inline">{{
|
||||
@@ -17,7 +17,7 @@
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
class="cursor-pointer bg-transparent border-0 outline-0 grid place-items-center absolute right-4"
|
||||
class="cursor-pointer bg-transparent border-0 outline-0 grid place-items-center absolute right-4 text-white"
|
||||
unstyled
|
||||
@click="handleDismiss"
|
||||
>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="workflow-tabs-container flex h-full max-w-full flex-auto flex-row overflow-hidden border-b border-[var(--interface-stroke)] shadow-interface"
|
||||
class="workflow-tabs-container flex h-full max-w-full flex-auto flex-row overflow-hidden"
|
||||
:class="{ 'workflow-tabs-container-desktop': isDesktop }"
|
||||
>
|
||||
<Button
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useElementBounding, useRafFn } from '@vueuse/core'
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import { computed, onUnmounted, ref, watch, watchEffect } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
|
||||
@@ -157,6 +157,14 @@ export function useSelectionToolboxPosition(
|
||||
// Sync with canvas transform
|
||||
const { resume: startSync, pause: stopSync } = useRafFn(updateTransform)
|
||||
|
||||
watchEffect(() => {
|
||||
if (visible.value) {
|
||||
startSync()
|
||||
} else {
|
||||
stopSync()
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for selection changes
|
||||
watch(
|
||||
() => canvasStore.getCanvas().state.selectionChanged,
|
||||
@@ -173,11 +181,6 @@ export function useSelectionToolboxPosition(
|
||||
}
|
||||
updateSelectionBounds()
|
||||
canvasStore.getCanvas().state.selectionChanged = false
|
||||
if (visible.value) {
|
||||
startSync()
|
||||
} else {
|
||||
stopSync()
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
|
||||
@@ -10,7 +10,10 @@ import type {
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot
|
||||
} from '@/lib/litegraph/src/interfaces'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type {
|
||||
IBaseWidget,
|
||||
IWidgetOptions
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import type { NodeId } from '@/renderer/core/layout/types'
|
||||
@@ -39,7 +42,7 @@ export interface SafeWidgetData {
|
||||
type: string
|
||||
value: WidgetValue
|
||||
label?: string
|
||||
options?: Record<string, unknown>
|
||||
options?: IWidgetOptions<unknown>
|
||||
callback?: ((value: unknown) => void) | undefined
|
||||
spec?: InputSpec
|
||||
slotMetadata?: WidgetSlotMetadata
|
||||
@@ -107,7 +110,7 @@ export function safeWidgetMapper(
|
||||
type: widget.type,
|
||||
value: value,
|
||||
label: widget.label,
|
||||
options: widget.options ? { ...widget.options } : undefined,
|
||||
options: widget.options,
|
||||
callback: widget.callback,
|
||||
spec,
|
||||
slotMetadata: slotInfo,
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
/**
|
||||
* Vue Nodes Viewport Culling
|
||||
*
|
||||
* Principles:
|
||||
* 1. Query DOM directly using data attributes (no cache to maintain)
|
||||
* 2. Set display none on element to avoid cascade resolution overhead
|
||||
* 3. Only run when transform changes (event driven)
|
||||
*/
|
||||
import { createSharedComposable, useThrottleFn } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
type Bounds = [left: number, right: number, top: number, bottom: number]
|
||||
|
||||
function getNodeBounds(node: LGraphNode): Bounds {
|
||||
const [nodeLeft, nodeTop] = node.pos
|
||||
const nodeRight = nodeLeft + node.size[0]
|
||||
const nodeBottom = nodeTop + node.size[1]
|
||||
return [nodeLeft, nodeRight, nodeTop, nodeBottom]
|
||||
}
|
||||
|
||||
function viewportEdges(
|
||||
canvas: ReturnType<typeof useCanvasStore>['canvas']
|
||||
): Bounds | undefined {
|
||||
if (!canvas) {
|
||||
return
|
||||
}
|
||||
const ds = canvas.ds
|
||||
const viewport_width = canvas.canvas.width
|
||||
const viewport_height = canvas.canvas.height
|
||||
const margin = 500 * ds.scale
|
||||
|
||||
const [xOffset, yOffset] = ds.offset
|
||||
|
||||
const leftEdge = -margin / ds.scale - xOffset
|
||||
const rightEdge = (viewport_width + margin) / ds.scale - xOffset
|
||||
const topEdge = -margin / ds.scale - yOffset
|
||||
const bottomEdge = (viewport_height + margin) / ds.scale - yOffset
|
||||
return [leftEdge, rightEdge, topEdge, bottomEdge]
|
||||
}
|
||||
|
||||
function boundsIntersect(boxA: Bounds, boxB: Bounds): boolean {
|
||||
const [aLeft, aRight, aTop, aBottom] = boxA
|
||||
const [bLeft, bRight, bTop, bBottom] = boxB
|
||||
|
||||
const leftOf = aRight < bLeft
|
||||
const rightOf = aLeft > bRight
|
||||
const above = aBottom < bTop
|
||||
const below = aTop > bBottom
|
||||
return !(leftOf || rightOf || above || below)
|
||||
}
|
||||
|
||||
function useViewportCullingIndividual() {
|
||||
const canvasStore = useCanvasStore()
|
||||
const { nodeManager } = useVueNodeLifecycle()
|
||||
|
||||
const viewport = computed(() => viewportEdges(canvasStore.canvas))
|
||||
|
||||
function inViewport(node: LGraphNode | undefined): boolean {
|
||||
if (!viewport.value || !node) {
|
||||
return true
|
||||
}
|
||||
const nodeBounds = getNodeBounds(node)
|
||||
return boundsIntersect(nodeBounds, viewport.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update visibility of all nodes based on viewport
|
||||
* Queries DOM directly - no cache maintenance needed
|
||||
*/
|
||||
function updateVisibility() {
|
||||
if (!nodeManager.value || !app.canvas) return // load bearing app.canvas check for workflows being loaded.
|
||||
|
||||
const nodeElements = document.querySelectorAll('[data-node-id]')
|
||||
for (const element of nodeElements) {
|
||||
const nodeId = element.getAttribute('data-node-id')
|
||||
if (!nodeId) continue
|
||||
|
||||
const node = nodeManager.value.getNode(nodeId)
|
||||
if (!node) continue
|
||||
|
||||
const displayValue = inViewport(node) ? '' : 'none'
|
||||
if (
|
||||
element instanceof HTMLElement &&
|
||||
element.style.display !== displayValue
|
||||
) {
|
||||
element.style.display = displayValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleTransformUpdate = useThrottleFn(() => updateVisibility, 100, true)
|
||||
|
||||
return { handleTransformUpdate }
|
||||
}
|
||||
|
||||
export const useViewportCulling = createSharedComposable(
|
||||
useViewportCullingIndividual
|
||||
)
|
||||
@@ -3,7 +3,6 @@ import { shallowRef, watch } from 'vue'
|
||||
|
||||
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
|
||||
import type { GraphNodeManager } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useRenderModeSetting } from '@/composables/settings/useRenderModeSetting'
|
||||
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
|
||||
import { useVueNodesMigrationDismissed } from '@/composables/useVueNodesMigrationDismissed'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
@@ -11,6 +10,7 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { useLayoutSync } from '@/renderer/core/layout/sync/useLayoutSync'
|
||||
import { removeNodeTitleHeight } from '@/renderer/core/layout/utils/nodeSizeUtil'
|
||||
import { ensureCorrectLayoutScale } from '@/renderer/extensions/vueNodes/layout/ensureCorrectLayoutScale'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
@@ -26,11 +26,6 @@ function useVueNodeLifecycleIndividual() {
|
||||
|
||||
let hasShownMigrationToast = false
|
||||
|
||||
useRenderModeSetting(
|
||||
{ setting: 'LiteGraph.Canvas.MinFontSizeForLOD', vue: 0, litegraph: 8 },
|
||||
shouldRenderVueNodes
|
||||
)
|
||||
|
||||
const initializeNodeManager = () => {
|
||||
// Use canvas graph if available (handles subgraph contexts), fallback to app graph
|
||||
const activeGraph = comfyApp.canvas?.graph
|
||||
@@ -44,7 +39,10 @@ function useVueNodeLifecycleIndividual() {
|
||||
const nodes = activeGraph._nodes.map((node: LGraphNode) => ({
|
||||
id: node.id.toString(),
|
||||
pos: [node.pos[0], node.pos[1]] as [number, number],
|
||||
size: [node.size[0], node.size[1]] as [number, number]
|
||||
size: [node.size[0], removeNodeTitleHeight(node.size[1])] as [
|
||||
number,
|
||||
number
|
||||
]
|
||||
}))
|
||||
layoutStore.initializeFromLiteGraph(nodes)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import tgpu from 'typegpu'
|
||||
import { tgpu } from 'typegpu'
|
||||
import * as d from 'typegpu/data'
|
||||
import { BrushUniforms } from './gpuSchema'
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { Brush, Point } from '@/extensions/core/maskeditor/types'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
import { useCoordinateTransform } from './useCoordinateTransform'
|
||||
import { resampleSegment } from './splineUtils'
|
||||
import TGPU from 'typegpu'
|
||||
import { tgpu } from 'typegpu'
|
||||
import { GPUBrushRenderer } from './gpu/GPUBrushRenderer'
|
||||
import { StrokeProcessor } from './StrokeProcessor'
|
||||
import { getEffectiveBrushSize, getEffectiveHardness } from './brushUtils'
|
||||
@@ -276,7 +276,7 @@ export function useBrushDrawing(initialSettings?: {
|
||||
}
|
||||
|
||||
try {
|
||||
const root = await TGPU.init()
|
||||
const root = await tgpu.init()
|
||||
store.tgpuRoot = root
|
||||
device = root.device
|
||||
console.warn('✅ TypeGPU initialized! Root:', root)
|
||||
|
||||
@@ -49,6 +49,21 @@ const calculateRunwayDurationPrice = (node: LGraphNode): string => {
|
||||
return `$${cost}/Run`
|
||||
}
|
||||
|
||||
const makeOmniProDurationCalculator =
|
||||
(pricePerSecond: number): PricingFunction =>
|
||||
(node: LGraphNode): string => {
|
||||
const durationWidget = node.widgets?.find(
|
||||
(w) => w.name === 'duration'
|
||||
) as IComboWidget
|
||||
if (!durationWidget) return `$${pricePerSecond.toFixed(3)}/second`
|
||||
|
||||
const seconds = parseFloat(String(durationWidget.value))
|
||||
if (!Number.isFinite(seconds)) return `$${pricePerSecond.toFixed(3)}/second`
|
||||
|
||||
const cost = pricePerSecond * seconds
|
||||
return `$${cost.toFixed(2)}/Run`
|
||||
}
|
||||
|
||||
const pixversePricingCalculator = (node: LGraphNode): string => {
|
||||
const durationWidget = node.widgets?.find(
|
||||
(w) => w.name === 'duration_seconds'
|
||||
@@ -131,6 +146,11 @@ const byteDanceVideoPricingCalculator = (node: LGraphNode): string => {
|
||||
'720p': [0.51, 0.56],
|
||||
'1080p': [1.18, 1.22]
|
||||
},
|
||||
'seedance-1-0-pro-fast': {
|
||||
'480p': [0.09, 0.1],
|
||||
'720p': [0.21, 0.23],
|
||||
'1080p': [0.47, 0.49]
|
||||
},
|
||||
'seedance-1-0-lite': {
|
||||
'480p': [0.17, 0.18],
|
||||
'720p': [0.37, 0.41],
|
||||
@@ -138,11 +158,13 @@ const byteDanceVideoPricingCalculator = (node: LGraphNode): string => {
|
||||
}
|
||||
}
|
||||
|
||||
const modelKey = model.includes('seedance-1-0-pro')
|
||||
? 'seedance-1-0-pro'
|
||||
: model.includes('seedance-1-0-lite')
|
||||
? 'seedance-1-0-lite'
|
||||
: ''
|
||||
const modelKey = model.includes('seedance-1-0-pro-fast')
|
||||
? 'seedance-1-0-pro-fast'
|
||||
: model.includes('seedance-1-0-pro')
|
||||
? 'seedance-1-0-pro'
|
||||
: model.includes('seedance-1-0-lite')
|
||||
? 'seedance-1-0-lite'
|
||||
: ''
|
||||
|
||||
const resKey = resolution.includes('1080')
|
||||
? '1080p'
|
||||
@@ -623,7 +645,12 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
const modeValue = String(modeWidget.value)
|
||||
|
||||
// Same pricing matrix as KlingTextToVideoNode
|
||||
if (modeValue.includes('v2-1')) {
|
||||
if (modeValue.includes('v2-5-turbo')) {
|
||||
if (modeValue.includes('10')) {
|
||||
return '$0.70/Run'
|
||||
}
|
||||
return '$0.35/Run' // 5s default
|
||||
} else if (modeValue.includes('v2-1')) {
|
||||
if (modeValue.includes('10s')) {
|
||||
return '$0.98/Run' // pro, 10s
|
||||
}
|
||||
@@ -699,6 +726,21 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
KlingVirtualTryOnNode: {
|
||||
displayPrice: '$0.07/Run'
|
||||
},
|
||||
KlingOmniProTextToVideoNode: {
|
||||
displayPrice: makeOmniProDurationCalculator(0.112)
|
||||
},
|
||||
KlingOmniProFirstLastFrameNode: {
|
||||
displayPrice: makeOmniProDurationCalculator(0.112)
|
||||
},
|
||||
KlingOmniProImageToVideoNode: {
|
||||
displayPrice: makeOmniProDurationCalculator(0.112)
|
||||
},
|
||||
KlingOmniProVideoToVideoNode: {
|
||||
displayPrice: makeOmniProDurationCalculator(0.168)
|
||||
},
|
||||
KlingOmniProEditVideoNode: {
|
||||
displayPrice: '$0.168/second'
|
||||
},
|
||||
LumaImageToVideoNode: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
// Same pricing as LumaVideoNode per CSV
|
||||
@@ -1873,6 +1915,10 @@ export const useNodePricing = () => {
|
||||
KlingDualCharacterVideoEffectNode: ['mode', 'model_name', 'duration'],
|
||||
KlingSingleImageVideoEffectNode: ['effect_scene'],
|
||||
KlingStartEndFrameNode: ['mode', 'model_name', 'duration'],
|
||||
KlingOmniProTextToVideoNode: ['duration'],
|
||||
KlingOmniProFirstLastFrameNode: ['duration'],
|
||||
KlingOmniProImageToVideoNode: ['duration'],
|
||||
KlingOmniProVideoToVideoNode: ['duration'],
|
||||
MinimaxHailuoVideoNode: ['resolution', 'duration'],
|
||||
OpenAIDalle3: ['size', 'quality'],
|
||||
OpenAIDalle2: ['size', 'n'],
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
|
||||
import { st } from '@/i18n'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
@@ -263,7 +264,8 @@ export function useJobList() {
|
||||
totalPercent: isActive ? totalPercent.value : undefined,
|
||||
currentNodePercent: isActive ? currentNodePercent.value : undefined,
|
||||
currentNodeName: isActive ? currentNodeName.value : undefined,
|
||||
showAddedHint
|
||||
showAddedHint,
|
||||
isCloud
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import type { ComputedRef } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import type { Settings } from '@/schemas/apiSchema'
|
||||
|
||||
interface RenderModeSettingConfig<TSettingKey extends keyof Settings> {
|
||||
setting: TSettingKey
|
||||
vue: Settings[TSettingKey]
|
||||
litegraph: Settings[TSettingKey]
|
||||
}
|
||||
|
||||
export function useRenderModeSetting<TSettingKey extends keyof Settings>(
|
||||
config: RenderModeSettingConfig<TSettingKey>,
|
||||
isVueMode: ComputedRef<boolean>
|
||||
) {
|
||||
const settingStore = useSettingStore()
|
||||
const vueValue = ref(config.vue)
|
||||
const litegraphValue = ref(config.litegraph)
|
||||
const lastWasVue = ref<boolean | null>(null)
|
||||
|
||||
const load = async (vue: boolean) => {
|
||||
if (lastWasVue.value === vue) return
|
||||
|
||||
if (lastWasVue.value !== null) {
|
||||
const currentValue = settingStore.get(config.setting)
|
||||
if (lastWasVue.value) {
|
||||
vueValue.value = currentValue
|
||||
} else {
|
||||
litegraphValue.value = currentValue
|
||||
}
|
||||
}
|
||||
|
||||
await settingStore.set(
|
||||
config.setting,
|
||||
vue ? vueValue.value : litegraphValue.value
|
||||
)
|
||||
lastWasVue.value = vue
|
||||
}
|
||||
|
||||
watch(isVueMode, load, { immediate: true })
|
||||
}
|
||||
@@ -23,10 +23,16 @@ export const useCopy = () => {
|
||||
const canvas = canvasStore.canvas
|
||||
if (canvas?.selectedItems) {
|
||||
const serializedData = canvas.copyToClipboard()
|
||||
// Use TextEncoder to handle Unicode characters properly
|
||||
const base64Data = btoa(
|
||||
String.fromCharCode(
|
||||
...Array.from(new TextEncoder().encode(serializedData))
|
||||
)
|
||||
)
|
||||
// clearData doesn't remove images from clipboard
|
||||
e.clipboardData?.setData(
|
||||
'text/html',
|
||||
clipboardHTMLWrapper.join(btoa(serializedData))
|
||||
clipboardHTMLWrapper.join(base64Data)
|
||||
)
|
||||
e.preventDefault()
|
||||
e.stopImmediatePropagation()
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
DEFAULT_LIGHT_COLOR_PALETTE
|
||||
} from '@/constants/coreColorPalettes'
|
||||
import { tryToggleWidgetPromotion } from '@/core/graph/subgraph/proxyWidgetUtils'
|
||||
import { showSubgraphNodeDialog } from '@/core/graph/subgraph/useSubgraphNodeDialog'
|
||||
import { t } from '@/i18n'
|
||||
import {
|
||||
LGraphEventMode,
|
||||
@@ -48,6 +47,7 @@ import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
||||
import { useSubgraphStore } from '@/stores/subgraphStore'
|
||||
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import {
|
||||
@@ -1025,7 +1025,9 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
label: 'Edit Subgraph Widgets',
|
||||
icon: 'icon-[lucide--settings-2]',
|
||||
versionAdded: '1.28.5',
|
||||
function: showSubgraphNodeDialog
|
||||
function: () => {
|
||||
useRightSidePanelStore().openPanel('subgraph')
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Graph.ToggleWidgetPromotion',
|
||||
|
||||
@@ -9,7 +9,8 @@ export enum ServerFeatureFlag {
|
||||
SUPPORTS_PREVIEW_METADATA = 'supports_preview_metadata',
|
||||
MAX_UPLOAD_SIZE = 'max_upload_size',
|
||||
MANAGER_SUPPORTS_V4 = 'extension.manager.supports_v4',
|
||||
MODEL_UPLOAD_BUTTON_ENABLED = 'model_upload_button_enabled'
|
||||
MODEL_UPLOAD_BUTTON_ENABLED = 'model_upload_button_enabled',
|
||||
ASSET_UPDATE_OPTIONS_ENABLED = 'asset_update_options_enabled'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -31,6 +32,12 @@ export function useFeatureFlags() {
|
||||
ServerFeatureFlag.MODEL_UPLOAD_BUTTON_ENABLED,
|
||||
false
|
||||
)
|
||||
},
|
||||
get assetUpdateOptionsEnabled() {
|
||||
return api.getServerFeature(
|
||||
ServerFeatureFlag.ASSET_UPDATE_OPTIONS_ENABLED,
|
||||
false
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -14,9 +14,11 @@ function pasteClipboardItems(data: DataTransfer): boolean {
|
||||
const match = rawData.match(/data-metadata="([A-Za-z0-9+/=]+)"/)?.[1]
|
||||
if (!match) return false
|
||||
try {
|
||||
useCanvasStore()
|
||||
.getCanvas()
|
||||
._deserializeItems(JSON.parse(atob(match)), {})
|
||||
// Decode UTF-8 safe base64
|
||||
const binaryString = atob(match)
|
||||
const bytes = Uint8Array.from(binaryString, (c) => c.charCodeAt(0))
|
||||
const decodedData = new TextDecoder().decode(bytes)
|
||||
useCanvasStore().getCanvas()._deserializeItems(JSON.parse(decodedData), {})
|
||||
return true
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export function getSlotColor(type?: string | number | null): string {
|
||||
if (!type) return '#AAA'
|
||||
const typeStr = String(type).toUpperCase()
|
||||
return `var(--color-datatype-${typeStr})`
|
||||
return `var(--color-datatype-${typeStr}, #AAA)`
|
||||
}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import SubgraphNode from '@/core/graph/subgraph/SubgraphNode.vue'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import type { DialogComponentProps } from '@/stores/dialogStore'
|
||||
|
||||
const key = 'global-subgraph-node-config'
|
||||
|
||||
export function showSubgraphNodeDialog() {
|
||||
const dialogStore = useDialogStore()
|
||||
const dialogComponentProps: DialogComponentProps = {
|
||||
modal: false,
|
||||
position: 'topright',
|
||||
pt: {
|
||||
root: {
|
||||
class: 'bg-node-component-surface mt-22'
|
||||
},
|
||||
header: {
|
||||
class: 'h-8 text-xs ml-3'
|
||||
}
|
||||
}
|
||||
}
|
||||
dialogStore.showDialog({
|
||||
title: 'Parameters',
|
||||
key,
|
||||
component: SubgraphNode,
|
||||
dialogComponentProps
|
||||
})
|
||||
}
|
||||
@@ -1,10 +1,50 @@
|
||||
import { without } from 'es-toolkit'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import type {
|
||||
ISlotType,
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot
|
||||
} from '@/lib/litegraph/src/interfaces'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LLink } from '@/lib/litegraph/src/LLink'
|
||||
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
|
||||
import type { ComboInputSpec, InputSpec } from '@/schemas/nodeDefSchema'
|
||||
import { zDynamicComboInputSpec } from '@/schemas/nodeDefSchema'
|
||||
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import {
|
||||
zAutogrowOptions,
|
||||
zDynamicComboInputSpec
|
||||
} from '@/schemas/nodeDefSchema'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { ComfyApp } from '@/scripts/app'
|
||||
import { isStrings } from '@/utils/typeGuardUtil'
|
||||
|
||||
const INLINE_INPUTS = false
|
||||
|
||||
type MatchTypeNode = LGraphNode &
|
||||
Pick<Required<LGraphNode>, 'comfyMatchType' | 'onConnectionsChange'>
|
||||
|
||||
function ensureWidgetForInput(node: LGraphNode, input: INodeInputSlot) {
|
||||
if (input.widget?.name) return
|
||||
node.widgets ??= []
|
||||
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()
|
||||
}
|
||||
})
|
||||
input.alwaysVisible = true
|
||||
input.widget = { name: input.name }
|
||||
}
|
||||
|
||||
function dynamicComboWidget(
|
||||
node: LGraphNode,
|
||||
@@ -32,11 +72,10 @@ function dynamicComboWidget(
|
||||
const updateWidgets = (value?: string) => {
|
||||
if (!node.widgets) throw new Error('Not Reachable')
|
||||
const newSpec = value ? options[value] : undefined
|
||||
//TODO: Calculate intersection for widgets that persist across options
|
||||
//This would potentially allow links to be retained
|
||||
const inputsToRemove: Record<string, INodeInputSlot> = {}
|
||||
for (const name of currentDynamicNames) {
|
||||
const inputIndex = node.inputs.findIndex((input) => input.name === name)
|
||||
if (inputIndex !== -1) node.removeInput(inputIndex)
|
||||
const input = node.inputs.find((input) => input.name === name)
|
||||
if (input) inputsToRemove[input.name] = input
|
||||
const widgetIndex = node.widgets.findIndex(
|
||||
(widget) => widget.name === name
|
||||
)
|
||||
@@ -45,13 +84,20 @@ function dynamicComboWidget(
|
||||
node.widgets.splice(widgetIndex, 1)
|
||||
}
|
||||
currentDynamicNames = []
|
||||
if (!newSpec) return
|
||||
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 insertionPoint = node.widgets.findIndex((w) => w === widget) + 1
|
||||
const startingLength = node.widgets.length
|
||||
const inputInsertionPoint =
|
||||
const initialInputIndex =
|
||||
node.inputs.findIndex((i) => i.name === widget.name) + 1
|
||||
const startingInputLength = node.inputs.length
|
||||
let startingInputLength = node.inputs.length
|
||||
if (insertionPoint === 0)
|
||||
throw new Error("Dynamic widget doesn't exist on node")
|
||||
const inputTypes: [Record<string, InputSpec> | undefined, boolean][] = [
|
||||
@@ -59,17 +105,37 @@ function dynamicComboWidget(
|
||||
[newSpec.optional, true]
|
||||
]
|
||||
for (const [inputType, isOptional] of inputTypes)
|
||||
for (const name in inputType ?? {}) {
|
||||
addNodeInput(
|
||||
node,
|
||||
transformInputSpecV1ToV2(inputType![name], {
|
||||
name,
|
||||
isOptional
|
||||
})
|
||||
)
|
||||
for (const key in inputType ?? {}) {
|
||||
const name = `${widget.name}.${key}`
|
||||
const specToAdd = transformInputSpecV1ToV2(inputType![key], {
|
||||
name,
|
||||
isOptional
|
||||
})
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
node.widgets.splice(insertionPoint, 0, ...addedWidgets)
|
||||
if (inputInsertionPoint === 0) {
|
||||
@@ -81,19 +147,23 @@ function dynamicComboWidget(
|
||||
throw new Error('Failed to find input socket for ' + widget.name)
|
||||
return
|
||||
}
|
||||
const addedInputs = node
|
||||
.spliceInputs(startingInputLength)
|
||||
.map((addedInput) => {
|
||||
const addedInputs = spliceInputs(node, startingInputLength).map(
|
||||
(addedInput) => {
|
||||
const existingInput = node.inputs.findIndex(
|
||||
(existingInput) => addedInput.name === existingInput.name
|
||||
)
|
||||
return existingInput === -1
|
||||
? addedInput
|
||||
: node.spliceInputs(existingInput, 1)[0]
|
||||
})
|
||||
: spliceInputs(node, existingInput, 1)[0]
|
||||
}
|
||||
)
|
||||
//assume existing inputs are in correct order
|
||||
node.spliceInputs(inputInsertionPoint, 0, ...addedInputs)
|
||||
spliceInputs(node, inputInsertionPoint, 0, ...addedInputs)
|
||||
node.size[1] = node.computeSize([...node.size])[1]
|
||||
if (!node.graph) return
|
||||
node._setConcreteSlots()
|
||||
node.arrange()
|
||||
app.canvas?.setDirty(true, true)
|
||||
}
|
||||
//A little hacky, but onConfigure won't work.
|
||||
//It fires too late and is overly disruptive
|
||||
@@ -112,3 +182,335 @@ function dynamicComboWidget(
|
||||
}
|
||||
|
||||
export const dynamicWidgets = { COMFY_DYNAMICCOMBO_V3: dynamicComboWidget }
|
||||
const dynamicInputs: Record<
|
||||
string,
|
||||
(node: LGraphNode, inputSpec: InputSpecV2) => void
|
||||
> = {
|
||||
COMFY_AUTOGROW_V3: applyAutogrow,
|
||||
COMFY_MATCHTYPE_V3: applyMatchType
|
||||
}
|
||||
|
||||
export function applyDynamicInputs(
|
||||
node: LGraphNode,
|
||||
inputSpec: InputSpecV2
|
||||
): boolean {
|
||||
if (!(inputSpec.type in dynamicInputs)) return false
|
||||
//TODO: move parsing/validation of inputSpec here?
|
||||
dynamicInputs[inputSpec.type](node, inputSpec)
|
||||
return true
|
||||
}
|
||||
function spliceInputs(
|
||||
node: LGraphNode,
|
||||
startIndex: number,
|
||||
deleteCount = -1,
|
||||
...toAdd: INodeInputSlot[]
|
||||
): INodeInputSlot[] {
|
||||
if (deleteCount < 0) return node.inputs.splice(startIndex)
|
||||
const ret = node.inputs.splice(startIndex, deleteCount, ...toAdd)
|
||||
node.inputs.slice(startIndex).forEach((input, index) => {
|
||||
const link = input.link && node.graph?.links?.get(input.link)
|
||||
if (link) link.target_slot = startIndex + index
|
||||
})
|
||||
return ret
|
||||
}
|
||||
|
||||
function changeOutputType(
|
||||
node: LGraphNode,
|
||||
output: INodeOutputSlot,
|
||||
combinedType: ISlotType
|
||||
) {
|
||||
if (output.type === combinedType) return
|
||||
output.type = combinedType
|
||||
|
||||
//check and potentially remove links
|
||||
if (!node.graph) return
|
||||
for (const link_id of output.links ?? []) {
|
||||
const link = node.graph.links[link_id]
|
||||
if (!link) continue
|
||||
const { input, inputNode, subgraphOutput } = link.resolve(node.graph)
|
||||
const inputType = (input ?? subgraphOutput)?.type
|
||||
if (!inputType) continue
|
||||
const keep = LiteGraph.isValidConnection(combinedType, inputType)
|
||||
if (!keep && subgraphOutput) subgraphOutput.disconnect()
|
||||
else if (!keep && inputNode) inputNode.disconnectInput(link.target_slot)
|
||||
if (input && inputNode?.onConnectionsChange)
|
||||
inputNode.onConnectionsChange(
|
||||
LiteGraph.INPUT,
|
||||
link.target_slot,
|
||||
keep,
|
||||
link,
|
||||
input
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function combineTypes(...types: ISlotType[]): ISlotType | undefined {
|
||||
if (!isStrings(types)) return undefined
|
||||
|
||||
const withoutWildcards = without(types, '*')
|
||||
if (withoutWildcards.length === 0) return '*'
|
||||
|
||||
const typeLists: string[][] = withoutWildcards.map((type) => type.split(','))
|
||||
|
||||
const combinedTypes = intersection(...typeLists)
|
||||
if (combinedTypes.length === 0) return undefined
|
||||
|
||||
return combinedTypes.join(',')
|
||||
}
|
||||
|
||||
function intersection(...sets: string[][]): string[] {
|
||||
const itemCounts: Record<string, number> = {}
|
||||
for (const set of sets)
|
||||
for (const item of new Set(set))
|
||||
itemCounts[item] = (itemCounts[item] ?? 0) + 1
|
||||
return Object.entries(itemCounts)
|
||||
.filter(([, count]) => count == sets.length)
|
||||
.map(([key]) => key)
|
||||
}
|
||||
|
||||
function withComfyMatchType(node: LGraphNode): asserts node is MatchTypeNode {
|
||||
if (node.comfyMatchType) return
|
||||
node.comfyMatchType = {}
|
||||
|
||||
const outputGroups = node.constructor.nodeData?.output_matchtypes
|
||||
node.onConnectionsChange = useChainCallback(
|
||||
node.onConnectionsChange,
|
||||
function (
|
||||
this: MatchTypeNode,
|
||||
contype: ISlotType,
|
||||
slot: number,
|
||||
iscon: boolean,
|
||||
linf: LLink | null | undefined
|
||||
) {
|
||||
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]
|
||||
if (!matchGroup) return
|
||||
if (iscon && linf) {
|
||||
const { output, subgraphInput } = linf.resolve(this.graph)
|
||||
//TODO: fix this bug globally. A link type (and therefore color)
|
||||
//should be the combinedType of origin and target type
|
||||
const connectingType = (output ?? subgraphInput)?.type
|
||||
if (connectingType) linf.type = connectingType
|
||||
}
|
||||
//NOTE: inputs contains input
|
||||
const groupInputs: INodeInputSlot[] = node.inputs.filter(
|
||||
(inp) => inp.name in matchGroup
|
||||
)
|
||||
const connectedTypes = groupInputs.map((inp) => {
|
||||
if (!inp.link) return '*'
|
||||
const link = this.graph!.links[inp.link]
|
||||
if (!link) return '*'
|
||||
const { output, subgraphInput } = link.resolve(this.graph!)
|
||||
return (output ?? subgraphInput)?.type ?? '*'
|
||||
})
|
||||
//An input slot can accept a connection that is
|
||||
// - Compatible with original type
|
||||
// - Compatible with all other input types
|
||||
//An output slot can output
|
||||
// - Only what every input can output
|
||||
groupInputs.forEach((input, idx) => {
|
||||
const otherConnected = [
|
||||
...connectedTypes.slice(0, idx),
|
||||
...connectedTypes.slice(idx + 1)
|
||||
]
|
||||
const combinedType = combineTypes(
|
||||
...otherConnected,
|
||||
matchGroup[input.name]
|
||||
)
|
||||
if (!combinedType) throw new Error('invalid connection')
|
||||
input.type = combinedType
|
||||
})
|
||||
const outputType = combineTypes(...connectedTypes)
|
||||
if (!outputType) throw new Error('invalid connection')
|
||||
this.outputs.forEach((output, idx) => {
|
||||
if (!(outputGroups?.[idx] == matchKey)) return
|
||||
changeOutputType(this, output, outputType)
|
||||
})
|
||||
app.canvas?.setDirty(true, true)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function applyMatchType(node: LGraphNode, inputSpec: InputSpecV2) {
|
||||
const { addNodeInput } = useLitegraphService()
|
||||
const name = inputSpec.name
|
||||
const { allowed_types, template_id } = (
|
||||
inputSpec as InputSpecV2 & {
|
||||
template: { allowed_types: string; template_id: string }
|
||||
}
|
||||
).template
|
||||
const typedSpec = { ...inputSpec, type: allowed_types }
|
||||
addNodeInput(node, typedSpec)
|
||||
withComfyMatchType(node)
|
||||
node.comfyMatchType[template_id] ??= {}
|
||||
node.comfyMatchType[template_id][name] = allowed_types
|
||||
|
||||
//TODO: instead apply on output add?
|
||||
//ensure outputs get updated
|
||||
const index = node.inputs.length - 1
|
||||
const input = node.inputs.at(-1)!
|
||||
requestAnimationFrame(() =>
|
||||
node.onConnectionsChange(LiteGraph.INPUT, index, false, undefined, input)
|
||||
)
|
||||
}
|
||||
|
||||
function applyAutogrow(node: LGraphNode, untypedInputSpec: InputSpecV2) {
|
||||
const { addNodeInput } = useLitegraphService()
|
||||
|
||||
const parseResult = zAutogrowOptions.safeParse(untypedInputSpec)
|
||||
if (!parseResult.success) throw new Error('invalid Autogrow spec')
|
||||
const inputSpec = parseResult.data
|
||||
|
||||
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 })
|
||||
)
|
||||
)
|
||||
|
||||
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)]
|
||||
}
|
||||
|
||||
//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)
|
||||
}
|
||||
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)
|
||||
) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
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])
|
||||
}
|
||||
|
||||
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,
|
||||
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) {
|
||||
if (swappingConnection || !linf) return
|
||||
inputConnected(index)
|
||||
} else {
|
||||
if (pendingConnection === index) {
|
||||
swappingConnection = true
|
||||
requestAnimationFrame(() => (swappingConnection = false))
|
||||
return
|
||||
}
|
||||
requestAnimationFrame(() => inputDisconnected(index))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||