Compare commits

..

1 Commits

Author SHA1 Message Date
bymyself
e8461252c1 improve type guard 2025-10-17 13:31:18 -07:00
74 changed files with 292 additions and 1532 deletions

View File

@@ -95,61 +95,41 @@ jobs:
echo "skip=true" >> $GITHUB_OUTPUT
echo "::warning::Backport PRs already exist for PR #${PR_NUMBER}, skipping to avoid duplicates"
- name: Collect backport targets
- name: Extract version labels
if: steps.check-existing.outputs.skip != 'true'
id: targets
id: versions
run: |
TARGETS=()
declare -A SEEN=()
# Extract version labels (e.g., "1.24", "1.22")
VERSIONS=""
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
# For manual triggers, get labels from the PR
LABELS=$(gh pr view ${{ inputs.pr_number }} --json labels | jq -r '.labels[].name')
else
# For automatic triggers, extract from PR event
LABELS='${{ toJSON(github.event.pull_request.labels) }}'
LABELS=$(echo "$LABELS" | jq -r '.[].name')
fi
add_target() {
local label="$1"
local target="$2"
if [ -z "$target" ]; then
return
for label in $LABELS; do
# Match version labels like "1.24" (major.minor only)
if [[ "$label" =~ ^[0-9]+\.[0-9]+$ ]]; then
# Validate the branch exists before adding to list
if git ls-remote --exit-code origin "core/${label}" >/dev/null 2>&1; then
VERSIONS="${VERSIONS}${label} "
else
echo "::warning::Label '${label}' found but branch 'core/${label}' does not exist"
fi
fi
done
target=$(echo "$target" | xargs)
if [ -z "$target" ] || [ -n "${SEEN[$target]}" ]; then
return
fi
if git ls-remote --exit-code origin "$target" >/dev/null 2>&1; then
TARGETS+=("$target")
SEEN["$target"]=1
else
echo "::warning::Label '${label}' references missing branch '${target}'"
fi
}
while IFS= read -r label; do
[ -z "$label" ] && continue
if [[ "$label" =~ ^branch:(.+)$ ]]; then
add_target "$label" "${BASH_REMATCH[1]}"
elif [[ "$label" =~ ^backport:(.+)$ ]]; then
add_target "$label" "${BASH_REMATCH[1]}"
elif [[ "$label" =~ ^[0-9]+\.[0-9]+$ ]]; then
add_target "$label" "core/${label}"
fi
done <<< "$LABELS"
if [ "${#TARGETS[@]}" -eq 0 ]; then
echo "::error::No backport targets found (use labels like '1.24' or 'branch:release/hotfix')"
if [ -z "$VERSIONS" ]; then
echo "::error::No version labels found (e.g., 1.24, 1.22)"
exit 1
fi
echo "targets=${TARGETS[*]}" >> $GITHUB_OUTPUT
echo "Found backport targets: ${TARGETS[*]}"
echo "versions=${VERSIONS}" >> $GITHUB_OUTPUT
echo "Found version labels: ${VERSIONS}"
- name: Backport commits
if: steps.check-existing.outputs.skip != 'true'
@@ -170,17 +150,16 @@ jobs:
MERGE_COMMIT="${{ github.event.pull_request.merge_commit_sha }}"
fi
for target in ${{ steps.targets.outputs.targets }}; do
TARGET_BRANCH="${target}"
SAFE_TARGET=$(echo "$TARGET_BRANCH" | tr '/' '-')
BACKPORT_BRANCH="backport-${PR_NUMBER}-to-${SAFE_TARGET}"
for version in ${{ steps.versions.outputs.versions }}; do
echo "::group::Backporting to core/${version}"
echo "::group::Backporting to ${TARGET_BRANCH}"
TARGET_BRANCH="core/${version}"
BACKPORT_BRANCH="backport-${PR_NUMBER}-to-${version}"
# Fetch target branch (fail if doesn't exist)
if ! git fetch origin "${TARGET_BRANCH}"; then
echo "::error::Target branch ${TARGET_BRANCH} does not exist"
FAILED="${FAILED}${TARGET_BRANCH}:branch-missing "
FAILED="${FAILED}${version}:branch-missing "
echo "::endgroup::"
continue
fi
@@ -191,7 +170,7 @@ jobs:
# Try cherry-pick
if git cherry-pick "${MERGE_COMMIT}"; then
git push origin "${BACKPORT_BRANCH}"
SUCCESS="${SUCCESS}${TARGET_BRANCH}:${BACKPORT_BRANCH} "
SUCCESS="${SUCCESS}${version}:${BACKPORT_BRANCH} "
echo "Successfully created backport branch: ${BACKPORT_BRANCH}"
# Return to main (keep the branch, we need it for PR)
git checkout main
@@ -201,7 +180,7 @@ jobs:
git cherry-pick --abort
echo "::error::Cherry-pick failed due to conflicts"
FAILED="${FAILED}${TARGET_BRANCH}:conflicts:${CONFLICTS} "
FAILED="${FAILED}${version}:conflicts:${CONFLICTS} "
# Clean up the failed branch
git checkout main
@@ -235,13 +214,13 @@ jobs:
fi
for backport in ${{ steps.backport.outputs.success }}; do
IFS=':' read -r target branch <<< "${backport}"
IFS=':' read -r version branch <<< "${backport}"
if PR_URL=$(gh pr create \
--base "${target}" \
--base "core/${version}" \
--head "${branch}" \
--title "[backport ${target}] ${PR_TITLE}" \
--body "Backport of #${PR_NUMBER} to \`${target}\`"$'\n\n'"Automatically created by backport workflow." \
--title "[backport ${version}] ${PR_TITLE}" \
--body "Backport of #${PR_NUMBER} to \`core/${version}\`"$'\n\n'"Automatically created by backport workflow." \
--label "backport" 2>&1); then
# Extract PR number from URL
@@ -251,9 +230,9 @@ jobs:
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Successfully backported to #${PR_NUM}"
fi
else
echo "::error::Failed to create PR for ${target}: ${PR_URL}"
echo "::error::Failed to create PR for ${version}: ${PR_URL}"
# Still try to comment on the original PR about the failure
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Backport branch created but PR creation failed for \`${target}\`. Please create the PR manually from branch \`${branch}\`"
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Backport branch created but PR creation failed for \`core/${version}\`. Please create the PR manually from branch \`${branch}\`"
fi
done
@@ -274,16 +253,16 @@ jobs:
fi
for failure in ${{ steps.backport.outputs.failed }}; do
IFS=':' read -r target reason conflicts <<< "${failure}"
IFS=':' read -r version reason conflicts <<< "${failure}"
if [ "${reason}" = "branch-missing" ]; then
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Backport failed: Branch \`${target}\` does not exist"
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Backport failed: Branch \`core/${version}\` does not exist"
elif [ "${reason}" = "conflicts" ]; then
# Convert comma-separated conflicts back to newlines for display
CONFLICTS_LIST=$(echo "${conflicts}" | tr ',' '\n' | sed 's/^/- /')
COMMENT_BODY="@${PR_AUTHOR} Backport to \`${target}\` failed: Merge conflicts detected."$'\n\n'"Please manually cherry-pick commit \`${MERGE_COMMIT}\` to the \`${target}\` branch."$'\n\n'"<details><summary>Conflicting files</summary>"$'\n\n'"${CONFLICTS_LIST}"$'\n\n'"</details>"
COMMENT_BODY="@${PR_AUTHOR} Backport to \`core/${version}\` failed: Merge conflicts detected."$'\n\n'"Please manually cherry-pick commit \`${MERGE_COMMIT}\` to the \`core/${version}\` branch."$'\n\n'"<details><summary>Conflicting files</summary>"$'\n\n'"${CONFLICTS_LIST}"$'\n\n'"</details>"
gh pr comment "${PR_NUMBER}" --body "${COMMENT_BODY}"
fi
done

View File

@@ -44,7 +44,6 @@ jobs:
contents: read
env:
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1'
ENABLE_MINIFY: 'true'
steps:
- name: Validate inputs
env:

View File

@@ -55,7 +55,6 @@ jobs:
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
ALGOLIA_APP_ID: ${{ secrets.ALGOLIA_APP_ID }}
ALGOLIA_API_KEY: ${{ secrets.ALGOLIA_API_KEY }}
ENABLE_MINIFY: 'true'
USE_PROD_CONFIG: 'true'
run: |
pnpm install --frozen-lockfile

View File

@@ -44,7 +44,6 @@ jobs:
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
ALGOLIA_APP_ID: ${{ secrets.ALGOLIA_APP_ID }}
ALGOLIA_API_KEY: ${{ secrets.ALGOLIA_API_KEY }}
ENABLE_MINIFY: 'true'
USE_PROD_CONFIG: 'true'
run: |
pnpm install --frozen-lockfile

View File

@@ -1,52 +0,0 @@
name: size data
on:
push:
branches:
- main
pull_request:
branches:
- main
permissions:
contents: read
jobs:
collect:
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Install pnpm
uses: pnpm/action-setup@v4.1.0
with:
version: 10
- name: Install Node.js
uses: actions/setup-node@v5
with:
node-version: '24.x'
cache: pnpm
- name: Install dependencies
run: pnpm install
- name: Build project
run: pnpm build
- name: Collect size data
run: node scripts/size-collect.js
- name: Save PR number & base branch
if: ${{ github.event_name == 'pull_request' }}
run: |
echo ${{ github.event.number }} > ./temp/size/number.txt
echo ${{ github.base_ref }} > ./temp/size/base.txt
- name: Upload size data
uses: actions/upload-artifact@v4
with:
name: size-data
path: temp/size

View File

@@ -1,104 +0,0 @@
name: size report
on:
workflow_run:
workflows: ['size data']
types:
- completed
workflow_dispatch:
inputs:
pr_number:
description: 'PR number to report on'
required: true
type: number
run_id:
description: 'Size data workflow run ID'
required: true
type: string
permissions:
contents: read
pull-requests: write
issues: write
jobs:
size-report:
runs-on: ubuntu-latest
if: >
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
(
(github.event_name == 'workflow_run' &&
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion == 'success') ||
github.event_name == 'workflow_dispatch'
)
steps:
- uses: actions/checkout@v5
- name: Install pnpm
uses: pnpm/action-setup@v4.1.0
with:
version: 10
- name: Install Node.js
uses: actions/setup-node@v5
with:
node-version: '24.x'
cache: pnpm
- name: Install dependencies
run: pnpm install
- name: Download size data
uses: dawidd6/action-download-artifact@v11
with:
name: size-data
run_id: ${{ github.event_name == 'workflow_dispatch' && inputs.run_id || github.event.workflow_run.id }}
path: temp/size
- name: Set PR number
id: pr-number
run: |
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
echo "content=${{ inputs.pr_number }}" >> $GITHUB_OUTPUT
else
echo "content=$(cat temp/size/number.txt)" >> $GITHUB_OUTPUT
fi
- name: Set base branch
id: pr-base
run: |
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
echo "content=main" >> $GITHUB_OUTPUT
else
echo "content=$(cat temp/size/base.txt)" >> $GITHUB_OUTPUT
fi
- name: Download previous size data
uses: dawidd6/action-download-artifact@v11
with:
branch: ${{ steps.pr-base.outputs.content }}
workflow: size-data.yml
event: push
name: size-data
path: temp/size-prev
if_no_artifact_found: warn
- name: Generate size report
run: node scripts/size-report.js > size-report.md
- name: Read size report
id: size-report
uses: juliangruber/read-file-action@v1
with:
path: ./size-report.md
- name: Create or update PR comment
uses: actions-cool/maintain-one-comment@v3
with:
token: ${{ secrets.GITHUB_TOKEN }}
number: ${{ steps.pr-number.outputs.content }}
body: |
${{ steps.size-report.outputs.content }}
<!-- COMFYUI_FRONTEND_SIZE -->
body-include: '<!-- COMFYUI_FRONTEND_SIZE -->'

View File

@@ -13,7 +13,6 @@ import { ComfyTemplates } from '../helpers/templates'
import { ComfyMouse } from './ComfyMouse'
import { VueNodeHelpers } from './VueNodeHelpers'
import { ComfyNodeSearchBox } from './components/ComfyNodeSearchBox'
import { Minimap } from './components/Minimap'
import { SettingDialog } from './components/SettingDialog'
import {
NodeLibrarySidebarTab,
@@ -34,7 +33,6 @@ class ComfyMenu {
private _workflowsTab: WorkflowsSidebarTab | null = null
private _queueTab: QueueSidebarTab | null = null
private _topbar: Topbar | null = null
private _minimap: Minimap | null = null
public readonly sideToolbar: Locator
public readonly themeToggleButton: Locator
@@ -72,11 +70,6 @@ class ComfyMenu {
return this._topbar
}
get minimap() {
this._minimap ??= new Minimap(this.page)
return this._minimap
}
async toggleTheme() {
await this.themeToggleButton.click()
await this.page.evaluate(() => {

View File

@@ -1,41 +0,0 @@
import type { Locator, Page } from '@playwright/test'
export class Minimap {
constructor(public readonly page: Page) {}
get mainContainer(): Locator {
return this.page.locator('.minimap-main-container')
}
get container(): Locator {
return this.page.locator('.litegraph-minimap')
}
get canvas(): Locator {
return this.container.locator('.minimap-canvas')
}
get viewport(): Locator {
return this.container.locator('.minimap-viewport')
}
get settingsButton(): Locator {
return this.container.getByRole('button').first()
}
get closeButton(): Locator {
return this.container.getByTestId('close-minmap-button')
}
async clickCanvas(options?: Parameters<Locator['click']>[0]): Promise<void> {
await this.canvas.click(options)
}
async clickSettingsButton(): Promise<void> {
await this.settingsButton.click()
}
async close(): Promise<void> {
await this.closeButton.click()
}
}

View File

@@ -2,11 +2,11 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Copy Paste', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Copy Paste', () => {
test('Can copy and paste node', async ({ comfyPage }) => {
await comfyPage.clickEmptyLatentNode()
await comfyPage.page.mouse.move(10, 10)
@@ -15,29 +15,6 @@ test.describe('Copy Paste', () => {
await expect(comfyPage.canvas).toHaveScreenshot('copied-node.png')
})
test('Can copy and paste node after clicking minimap', async ({
comfyPage
}) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setSetting('Comfy.Minimap.Visible', true)
const latentNodeTitle = 'Empty Latent Image'
const initialLatentNodeCt =
await comfyPage.getNodeRefsByTitle(latentNodeTitle)
await comfyPage.clickEmptyLatentNode()
await comfyPage.ctrlC()
// Click minimap to lose focus.
await comfyPage.menu.minimap.clickCanvas({ force: true })
// Paste node.
await comfyPage.ctrlV()
const expectedNodeCt = initialLatentNodeCt.length + 1
const latentNodeCt = await comfyPage.getNodeRefsByTitle(latentNodeTitle)
expect(latentNodeCt.length).toBe(expectedNodeCt)
})
test('Can copy and paste node with link', async ({ comfyPage }) => {
await comfyPage.clickTextEncodeNode1()
await comfyPage.page.mouse.move(10, 10)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 99 KiB

View File

@@ -14,70 +14,65 @@ test.describe('Minimap', () => {
})
test('Validate minimap is visible by default', async ({ comfyPage }) => {
const minimap = comfyPage.menu.minimap
const minimapContainer = comfyPage.page.locator('.litegraph-minimap')
await expect(minimap.container).toBeVisible()
await expect(minimap.canvas).toBeVisible()
await expect(minimap.viewport).toBeVisible()
await expect(minimap.container).toHaveCSS('position', 'relative')
await expect(minimapContainer).toBeVisible()
const minimapCanvas = minimapContainer.locator('.minimap-canvas')
await expect(minimapCanvas).toBeVisible()
const minimapViewport = minimapContainer.locator('.minimap-viewport')
await expect(minimapViewport).toBeVisible()
await expect(minimapContainer).toHaveCSS('position', 'relative')
// position and z-index validation moved to the parent container of the minimap
await expect(minimap.mainContainer).toHaveCSS('position', 'absolute')
await expect(minimap.mainContainer).toHaveCSS('z-index', '1000')
const minimapMainContainer = comfyPage.page.locator(
'.minimap-main-container'
)
await expect(minimapMainContainer).toHaveCSS('position', 'absolute')
await expect(minimapMainContainer).toHaveCSS('z-index', '1000')
})
test('Validate minimap toggle button state', async ({ comfyPage }) => {
const minimap = comfyPage.menu.minimap
const toggleButton = comfyPage.page.getByTestId('toggle-minimap-button')
await expect(toggleButton).toBeVisible()
await expect(minimap.container).toBeVisible()
const minimapContainer = comfyPage.page.locator('.litegraph-minimap')
await expect(minimapContainer).toBeVisible()
})
test('Validate minimap can be toggled off and on', async ({ comfyPage }) => {
const minimap = comfyPage.menu.minimap
// Open zoom controls dropdown first
const zoomControlsButton = comfyPage.page.getByTestId(
'zoom-controls-button'
)
await zoomControlsButton.click()
const minimapContainer = comfyPage.page.locator('.litegraph-minimap')
const toggleButton = comfyPage.page.getByTestId('toggle-minimap-button')
await expect(minimap.container).toBeVisible()
await expect(minimapContainer).toBeVisible()
await toggleButton.click()
await comfyPage.nextFrame()
await expect(minimap.container).not.toBeVisible()
await expect(minimapContainer).not.toBeVisible()
await toggleButton.click()
await comfyPage.nextFrame()
await expect(minimap.container).toBeVisible()
// Open zoom controls dropdown again to verify button text
await zoomControlsButton.click()
await comfyPage.nextFrame()
await expect(toggleButton).toContainText('Hide Minimap')
await expect(minimapContainer).toBeVisible()
})
test('Validate minimap keyboard shortcut Alt+M', async ({ comfyPage }) => {
const minimap = comfyPage.menu.minimap
const minimapContainer = comfyPage.page.locator('.litegraph-minimap')
await expect(minimap.container).toBeVisible()
await expect(minimapContainer).toBeVisible()
await comfyPage.page.keyboard.press('Alt+KeyM')
await comfyPage.nextFrame()
await expect(minimap.container).not.toBeVisible()
await expect(minimapContainer).not.toBeVisible()
await comfyPage.page.keyboard.press('Alt+KeyM')
await comfyPage.nextFrame()
await expect(minimap.container).toBeVisible()
await expect(minimapContainer).toBeVisible()
})
})

View File

@@ -1,28 +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('Record Audio Node', () => {
test('should add a record audio node and take a screenshot', async ({
comfyPage
}) => {
// Open the search box by double clicking on the canvas
await comfyPage.doubleClickCanvas()
await expect(comfyPage.searchBox.input).toHaveCount(1)
// Search for and add the RecordAudio node
await comfyPage.searchBox.fillAndSelectFirstNode('RecordAudio')
await comfyPage.nextFrame()
// Verify the RecordAudio node was added
const recordAudioNodes = await comfyPage.getNodeRefsByType('RecordAudio')
expect(recordAudioNodes.length).toBe(1)
// Take a screenshot of the canvas with the RecordAudio node
await expect(comfyPage.canvas).toHaveScreenshot('record_audio_node.png')
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 85 KiB

View File

@@ -12,26 +12,21 @@ test.describe('Vue Node Bypass', () => {
await comfyPage.vueNodes.waitForNodes()
})
test.fixme(
'should allow toggling bypass on a selected node with hotkey',
async ({ comfyPage }) => {
await comfyPage.setup()
await comfyPage.page.getByText('Load Checkpoint').click()
await comfyPage.page.keyboard.press(BYPASS_HOTKEY)
test('should allow toggling bypass on a selected node with hotkey', async ({
comfyPage
}) => {
await comfyPage.page.getByText('Load Checkpoint').click()
await comfyPage.page.keyboard.press(BYPASS_HOTKEY)
const checkpointNode =
comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
await expect(checkpointNode).toHaveClass(BYPASS_CLASS)
await comfyPage.page.mouse.click(400, 300)
await comfyPage.page.waitForTimeout(128)
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-node-bypassed-state.png'
)
const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
await expect(checkpointNode).toHaveClass(BYPASS_CLASS)
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-node-bypassed-state.png'
)
await comfyPage.page.keyboard.press(BYPASS_HOTKEY)
await expect(checkpointNode).not.toHaveClass(BYPASS_CLASS)
}
)
await comfyPage.page.keyboard.press(BYPASS_HOTKEY)
await expect(checkpointNode).not.toHaveClass(BYPASS_CLASS)
})
test('should allow toggling bypass on multiple selected nodes with hotkey', async ({
comfyPage

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 81 KiB

View File

@@ -254,17 +254,5 @@ export default defineConfig([
rules: {
'no-console': 'off'
}
},
{
files: ['scripts/**/*.js'],
languageOptions: {
globals: {
...globals.node
}
},
rules: {
'@typescript-eslint/no-floating-promises': 'off',
'no-console': 'off'
}
}
])

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.30.1",
"version": "1.30.0",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -13,8 +13,6 @@
"build:types": "nx build --config vite.types.config.mts && node scripts/prepare-types.js",
"build:analyze": "cross-env ANALYZE_BUNDLE=true pnpm build",
"build": "cross-env NODE_OPTIONS='--max-old-space-size=8192' pnpm typecheck && nx build",
"size:collect": "node scripts/size-collect.js",
"size:report": "node scripts/size-report.js",
"collect-i18n": "pnpm exec playwright test --config=playwright.i18n.config.ts",
"dev:desktop": "nx dev @comfyorg/desktop-ui",
"dev:electron": "nx serve --config vite.electron.config.mts",
@@ -88,12 +86,9 @@
"jsdom": "catalog:",
"knip": "catalog:",
"lint-staged": "catalog:",
"markdown-table": "catalog:",
"nx": "catalog:",
"picocolors": "catalog:",
"postcss-html": "catalog:",
"prettier": "catalog:",
"pretty-bytes": "catalog:",
"rollup-plugin-visualizer": "catalog:",
"storybook": "catalog:",
"stylelint": "catalog:",

View File

@@ -159,7 +159,6 @@
--backdrop: var(--color-white);
--button-hover-surface: var(--color-gray-200);
--button-active-surface: var(--color-gray-400);
--button-icon: var(--color-gray-600);
--dialog-surface: var(--color-neutral-200);
--interface-menu-component-surface-hovered: var(--color-gray-200);
--interface-menu-component-surface-selected: var(--color-gray-400);
@@ -210,7 +209,6 @@
--button-surface-contrast: var(--color-pure-white);
--button-hover-surface: var(--color-charcoal-600);
--button-active-surface: var(--color-charcoal-600);
--button-icon: var(--color-gray-800);
--dialog-surface: var(--color-neutral-700);
--interface-menu-component-surface-hovered: var(--color-charcoal-400);
--interface-menu-component-surface-selected: var(--color-charcoal-300);
@@ -252,9 +250,8 @@
@theme inline {
--color-backdrop: var(--backdrop);
--color-button-active-surface: var(--button-active-surface);
--color-button-hover-surface: var(--button-hover-surface);
--color-button-icon: var(--button-icon);
--color-button-active-surface: var(--button-active-surface);
--color-button-surface: var(--button-surface);
--color-button-surface-contrast: var(--button-surface-contrast);
--color-dialog-surface: var(--dialog-surface);

69
pnpm-lock.yaml generated
View File

@@ -183,15 +183,9 @@ catalogs:
lint-staged:
specifier: ^15.2.7
version: 15.2.7
markdown-table:
specifier: ^3.0.4
version: 3.0.4
nx:
specifier: 21.4.1
version: 21.4.1
picocolors:
specifier: ^1.1.1
version: 1.1.1
pinia:
specifier: ^2.1.7
version: 2.2.2
@@ -201,9 +195,6 @@ catalogs:
prettier:
specifier: ^3.6.2
version: 3.6.2
pretty-bytes:
specifier: ^7.1.0
version: 7.1.0
primeicons:
specifier: ^7.0.0
version: 7.0.0
@@ -263,7 +254,7 @@ catalogs:
version: 3.5.13
vue-component-type-helpers:
specifier: ^3.0.7
version: 3.1.1
version: 3.1.0
vue-eslint-parser:
specifier: ^10.2.0
version: 10.2.0
@@ -594,24 +585,15 @@ importers:
lint-staged:
specifier: 'catalog:'
version: 15.2.7
markdown-table:
specifier: 'catalog:'
version: 3.0.4
nx:
specifier: 'catalog:'
version: 21.4.1
picocolors:
specifier: 'catalog:'
version: 1.1.1
postcss-html:
specifier: 'catalog:'
version: 1.8.0
prettier:
specifier: 'catalog:'
version: 3.6.2
pretty-bytes:
specifier: 'catalog:'
version: 7.1.0
rollup-plugin-visualizer:
specifier: 'catalog:'
version: 6.0.4(rollup@4.22.4)
@@ -665,7 +647,7 @@ importers:
version: 3.2.4(@types/debug@4.1.12)(@types/node@20.14.10)(@vitest/ui@3.2.4)(happy-dom@15.11.0)(jsdom@26.1.0)(lightningcss@1.30.1)(terser@5.39.2)
vue-component-type-helpers:
specifier: 'catalog:'
version: 3.1.1
version: 3.1.0
vue-eslint-parser:
specifier: 'catalog:'
version: 10.2.0(eslint@9.35.0(jiti@2.4.2))
@@ -4910,6 +4892,9 @@ packages:
get-tsconfig@4.10.1:
resolution: {integrity: sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==}
get-tsconfig@4.7.5:
resolution: {integrity: sha512-ZCuZCnlqNzjb4QprAzXKdpp/gh6KTxSJuw3IBsPnV/7fV4NxC9ckB+vPTt8w7fJA0TaSD7c55BR47JD6MEDyDw==}
glob-parent@5.1.2:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'}
@@ -6014,6 +5999,11 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
nanoid@3.3.8:
resolution: {integrity: sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
nanoid@5.1.5:
resolution: {integrity: sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==}
engines: {node: ^18 || >=20}
@@ -6361,6 +6351,10 @@ packages:
postcss-value-parser@4.2.0:
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
postcss@8.5.1:
resolution: {integrity: sha512-6oz2beyjc5VMn/KV1pPw8fliQkhBXrVn1Z3TVyqZxU8kZpzEKhBdmCFqI6ZbmGtamQvQGuU1sgPTk8ZrXDD7jQ==}
engines: {node: ^10 || ^12 || >=14}
postcss@8.5.6:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
engines: {node: ^10 || ^12 || >=14}
@@ -6378,10 +6372,6 @@ packages:
engines: {node: '>=14'}
hasBin: true
pretty-bytes@7.1.0:
resolution: {integrity: sha512-nODzvTiYVRGRqAOvE84Vk5JDPyyxsVk0/fbA/bq7RqlnhksGpset09XTxbpvLTIjoaF7K8Z8DG8yHtKGTPSYRw==}
engines: {node: '>=20'}
pretty-format@27.5.1:
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
@@ -7483,6 +7473,9 @@ packages:
vue-component-type-helpers@2.2.12:
resolution: {integrity: sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==}
vue-component-type-helpers@3.1.0:
resolution: {integrity: sha512-cC1pYNRZkSS1iCvdlaMbbg2sjDwxX098FucEjtz9Yig73zYjWzQsnMe5M9H8dRNv55hAIDGUI29hF2BEUA4FMQ==}
vue-component-type-helpers@3.1.1:
resolution: {integrity: sha512-B0kHv7qX6E7+kdc5nsaqjdGZ1KwNKSUQDWGy7XkTYT7wFsOpkEyaJ1Vq79TjwrrtuLRgizrTV7PPuC4rRQo+vw==}
@@ -12592,6 +12585,10 @@ snapshots:
dependencies:
resolve-pkg-maps: 1.0.0
get-tsconfig@4.7.5:
dependencies:
resolve-pkg-maps: 1.0.0
glob-parent@5.1.2:
dependencies:
is-glob: 4.0.3
@@ -13886,6 +13883,8 @@ snapshots:
nanoid@3.3.11: {}
nanoid@3.3.8: {}
nanoid@5.1.5: {}
napi-postinstall@0.3.3: {}
@@ -14265,14 +14264,14 @@ snapshots:
dependencies:
htmlparser2: 8.0.2
js-tokens: 9.0.1
postcss: 8.5.6
postcss-safe-parser: 6.0.0(postcss@8.5.6)
postcss: 8.5.1
postcss-safe-parser: 6.0.0(postcss@8.5.1)
postcss-resolve-nested-selector@0.1.6: {}
postcss-safe-parser@6.0.0(postcss@8.5.6):
postcss-safe-parser@6.0.0(postcss@8.5.1):
dependencies:
postcss: 8.5.6
postcss: 8.5.1
postcss-safe-parser@7.0.1(postcss@8.5.6):
dependencies:
@@ -14290,6 +14289,12 @@ snapshots:
postcss-value-parser@4.2.0: {}
postcss@8.5.1:
dependencies:
nanoid: 3.3.8
picocolors: 1.1.1
source-map-js: 1.2.1
postcss@8.5.6:
dependencies:
nanoid: 3.3.11
@@ -14304,8 +14309,6 @@ snapshots:
prettier@3.6.2: {}
pretty-bytes@7.1.0: {}
pretty-format@27.5.1:
dependencies:
ansi-regex: 5.0.1
@@ -15318,7 +15321,7 @@ snapshots:
tsx@4.19.4:
dependencies:
esbuild: 0.25.5
get-tsconfig: 4.10.1
get-tsconfig: 4.7.5
optionalDependencies:
fsevents: 2.3.3
@@ -15670,7 +15673,7 @@ snapshots:
vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2):
dependencies:
esbuild: 0.21.5
postcss: 8.5.6
postcss: 8.5.1
rollup: 4.22.4
optionalDependencies:
'@types/node': 20.14.10
@@ -15735,6 +15738,8 @@ snapshots:
vue-component-type-helpers@2.2.12: {}
vue-component-type-helpers@3.1.0: {}
vue-component-type-helpers@3.1.1: {}
vue-demi@0.14.10(vue@3.5.13(typescript@5.9.2)):

View File

@@ -62,13 +62,10 @@ catalog:
jsdom: ^26.1.0
knip: ^5.62.0
lint-staged: ^15.2.7
markdown-table: ^3.0.4
nx: 21.4.1
picocolors: ^1.1.1
pinia: ^2.1.7
postcss-html: ^1.8.0
prettier: ^3.6.2
pretty-bytes: ^7.1.0
primeicons: ^7.0.0
primevue: ^4.2.5
rollup-plugin-visualizer: ^6.0.4

View File

@@ -1,93 +0,0 @@
// @ts-check
/**
* Bundle categorization configuration
*
* This file defines how bundles are categorized in size reports.
* Categories help identify which parts of the application are growing.
*/
/**
* @typedef {Object} BundleCategory
* @property {string} name - Display name of the category
* @property {string} description - Description of what this category includes
* @property {RegExp[]} patterns - Regex patterns to match bundle files
* @property {number} order - Sort order for display (lower = first)
*/
/** @type {BundleCategory[]} */
export const BUNDLE_CATEGORIES = [
{
name: 'App Entry Points',
description: 'Main application bundles',
patterns: [/^index-.*\.js$/],
order: 1
},
{
name: 'Core Views',
description: 'Major application views and screens',
patterns: [/GraphView-.*\.js$/, /UserSelectView-.*\.js$/],
order: 2
},
{
name: 'UI Panels',
description: 'Settings and configuration panels',
patterns: [/.*Panel-.*\.js$/],
order: 3
},
{
name: 'UI Components',
description: 'Reusable UI components',
patterns: [/Avatar-.*\.js$/, /Badge-.*\.js$/],
order: 4
},
{
name: 'Services',
description: 'Business logic and services',
patterns: [/.*Service-.*\.js$/, /.*Store-.*\.js$/],
order: 5
},
{
name: 'Utilities',
description: 'Helper functions and utilities',
patterns: [/.*[Uu]til.*\.js$/],
order: 6
},
{
name: 'Other',
description: 'Uncategorized bundles',
patterns: [/.*/], // Catch-all pattern
order: 99
}
]
/**
* Categorize a bundle file based on its name
*
* @param {string} fileName - The bundle file name (e.g., "assets/GraphView-BnV6iF9h.js")
* @returns {string} - The category name
*/
export function categorizeBundle(fileName) {
// Extract just the file name without path
const baseName = fileName.split('/').pop() || fileName
// Find the first matching category
for (const category of BUNDLE_CATEGORIES) {
for (const pattern of category.patterns) {
if (pattern.test(baseName)) {
return category.name
}
}
}
return 'Other'
}
/**
* Get category metadata by name
*
* @param {string} categoryName - The category name
* @returns {BundleCategory | undefined} - The category metadata
*/
export function getCategoryMetadata(categoryName) {
return BUNDLE_CATEGORIES.find((cat) => cat.name === categoryName)
}

View File

@@ -1,90 +0,0 @@
// @ts-check
import { existsSync } from 'node:fs'
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { brotliCompressSync, gzipSync } from 'node:zlib'
import pico from 'picocolors'
import prettyBytes from 'pretty-bytes'
import { categorizeBundle } from './bundle-categories.js'
const distDir = path.resolve('dist')
const sizeDir = path.resolve('temp/size')
/**
* @typedef {Object} SizeResult
* @property {string} file
* @property {string} category
* @property {number} size
* @property {number} gzip
* @property {number} brotli
*/
run()
/**
* Main function to collect bundle size data
*/
async function run() {
if (!existsSync(distDir)) {
console.error(pico.red('Error: dist directory does not exist'))
console.error(pico.yellow('Please run "pnpm build" first'))
process.exit(1)
}
console.log(pico.blue('\nCollecting bundle size data...\n'))
// Collect main bundle files from dist/assets
const assetsDir = path.join(distDir, 'assets')
const bundles = []
if (existsSync(assetsDir)) {
const files = await readdir(assetsDir)
const jsFiles = files.filter(
(file) => file.endsWith('.js') && !file.includes('legacy')
)
for (const file of jsFiles) {
const filePath = path.join(assetsDir, file)
const content = await readFile(filePath, 'utf-8')
const size = Buffer.byteLength(content)
const gzip = gzipSync(content).length
const brotli = brotliCompressSync(content).length
const fileName = `assets/${file}`
const category = categorizeBundle(fileName)
bundles.push({
file: fileName,
category,
size,
gzip,
brotli
})
console.log(
`${pico.green(file)} ${pico.dim(`[${category}]`)} - ` +
`Size: ${prettyBytes(size)} / ` +
`Gzip: ${prettyBytes(gzip)} / ` +
`Brotli: ${prettyBytes(brotli)}`
)
}
}
// Create temp/size directory
await mkdir(sizeDir, { recursive: true })
// Write individual bundle files
for (const bundle of bundles) {
const fileName = bundle.file.replace(/[/\\]/g, '_').replace('.js', '.json')
await writeFile(
path.join(sizeDir, fileName),
JSON.stringify(bundle, null, 2),
'utf-8'
)
}
console.log(
pico.green(`\n✓ Collected size data for ${bundles.length} bundles\n`)
)
console.log(pico.blue(`Data saved to: ${sizeDir}\n`))
}

View File

@@ -1,162 +0,0 @@
// @ts-check
import { markdownTable } from 'markdown-table'
import { existsSync } from 'node:fs'
import { readdir } from 'node:fs/promises'
import path from 'node:path'
import prettyBytes from 'pretty-bytes'
import { getCategoryMetadata } from './bundle-categories.js'
/**
* @typedef {Object} SizeResult
* @property {number} size
* @property {number} gzip
* @property {number} brotli
*/
/**
* @typedef {SizeResult & { file: string, category?: string }} BundleResult
*/
const currDir = path.resolve('temp/size')
const prevDir = path.resolve('temp/size-prev')
let output = '## Bundle Size Report\n\n'
const sizeHeaders = ['Size', 'Gzip', 'Brotli']
run()
/**
* Main function to generate the size report
*/
async function run() {
if (!existsSync(currDir)) {
console.error('Error: temp/size directory does not exist')
console.error('Please run "pnpm size:collect" first')
process.exit(1)
}
await renderFiles()
process.stdout.write(output)
}
/**
* Renders file sizes and diffs between current and previous versions
*/
async function renderFiles() {
/**
* @param {string[]} files
* @returns {string[]}
*/
const filterFiles = (files) => files.filter((file) => file.endsWith('.json'))
const curr = filterFiles(await readdir(currDir))
const prev = existsSync(prevDir) ? filterFiles(await readdir(prevDir)) : []
const fileList = new Set([...curr, ...prev])
// Group bundles by category
/** @type {Map<string, Array<{fileName: string, curr: BundleResult | undefined, prev: BundleResult | undefined}>>} */
const bundlesByCategory = new Map()
for (const file of fileList) {
const currPath = path.resolve(currDir, file)
const prevPath = path.resolve(prevDir, file)
const curr = await importJSON(currPath)
const prev = await importJSON(prevPath)
const fileName = curr?.file || prev?.file || ''
const category = curr?.category || prev?.category || 'Other'
if (!bundlesByCategory.has(category)) {
bundlesByCategory.set(category, [])
}
// @ts-expect-error - get is valid
bundlesByCategory.get(category).push({ fileName, curr, prev })
}
// Sort categories by their order
const sortedCategories = Array.from(bundlesByCategory.keys()).sort((a, b) => {
const metaA = getCategoryMetadata(a)
const metaB = getCategoryMetadata(b)
return (metaA?.order ?? 99) - (metaB?.order ?? 99)
})
let totalSize = 0
let totalCount = 0
// Render each category
for (const category of sortedCategories) {
const bundles = bundlesByCategory.get(category) || []
if (bundles.length === 0) continue
const categoryMeta = getCategoryMetadata(category)
output += `### ${category}\n\n`
if (categoryMeta?.description) {
output += `_${categoryMeta.description}_\n\n`
}
const rows = []
let categorySize = 0
for (const { fileName, curr, prev } of bundles) {
if (!curr) {
// File was deleted
rows.push([`~~${fileName}~~`])
} else {
rows.push([
fileName,
`${prettyBytes(curr.size)}${getDiff(curr.size, prev?.size)}`,
`${prettyBytes(curr.gzip)}${getDiff(curr.gzip, prev?.gzip)}`,
`${prettyBytes(curr.brotli)}${getDiff(curr.brotli, prev?.brotli)}`
])
categorySize += curr.size
totalSize += curr.size
totalCount++
}
}
// Sort rows by file name within category
rows.sort((a, b) => {
const fileA = a[0].replace(/~~/g, '')
const fileB = b[0].replace(/~~/g, '')
return fileA.localeCompare(fileB)
})
output += markdownTable([['File', ...sizeHeaders], ...rows])
output += `\n\n**Category Total:** ${prettyBytes(categorySize)}\n\n`
}
// Add overall summary
if (totalCount > 0) {
output += '---\n\n'
output += `**Overall Total Size:** ${prettyBytes(totalSize)}\n`
output += `**Total Bundle Count:** ${totalCount}\n`
}
}
/**
* Imports JSON data from a specified path
*
* @template T
* @param {string} filePath - Path to the JSON file
* @returns {Promise<T | undefined>} The JSON content or undefined if the file does not exist
*/
async function importJSON(filePath) {
if (!existsSync(filePath)) return undefined
return (await import(filePath, { with: { type: 'json' } })).default
}
/**
* Calculates the difference between the current and previous sizes
*
* @param {number} curr - The current size
* @param {number} [prev] - The previous size
* @returns {string} The difference in pretty format
*/
function getDiff(curr, prev) {
if (prev === undefined) return ''
const diff = curr - prev
if (diff === 0) return ''
const sign = diff > 0 ? '+' : ''
return ` (**${sign}${prettyBytes(diff)}**)`
}

View File

@@ -10,8 +10,7 @@
></div>
<ButtonGroup
id="graph-canvas-controls"
class="absolute right-0 bottom-0 z-[1200] flex-row gap-1 border-[1px] border-node-border bg-interface-panel-surface p-2"
class="absolute right-2 bottom-2 z-[1200] flex-row gap-1 border-[1px] border-node-border bg-interface-panel-surface p-2"
:style="stringifiedMinimapStyles.buttonGroupStyles"
@wheel="canvasInteractions.handleWheel"
>

View File

@@ -1,6 +1,5 @@
<template>
<div
ref="containerRef"
class="workflow-tabs-container flex h-full max-w-full flex-auto flex-row overflow-hidden"
:class="{ 'workflow-tabs-container-desktop': isDesktop }"
>
@@ -14,6 +13,7 @@
@mousedown="whileMouseDown($event, () => scroll(-1))"
/>
<ScrollPanel
ref="scrollPanelRef"
class="no-drag overflow-hidden"
:pt:content="{
class: 'p-0 w-full flex',
@@ -74,7 +74,6 @@ import ContextMenu from 'primevue/contextmenu'
import ScrollPanel from 'primevue/scrollpanel'
import SelectButton from 'primevue/selectbutton'
import { computed, nextTick, onUpdated, ref, watch } from 'vue'
import type { WatchStopHandle } from 'vue'
import { useI18n } from 'vue-i18n'
import WorkflowTab from '@/components/topbar/WorkflowTab.vue'
@@ -109,7 +108,7 @@ const workflowService = useWorkflowService()
const rightClickedTab = ref<WorkflowOption | undefined>()
const menu = ref()
const containerRef = ref<HTMLElement | null>(null)
const scrollPanelRef = ref()
const showOverflowArrows = ref(false)
const leftArrowEnabled = ref(false)
const rightArrowEnabled = ref(false)
@@ -222,34 +221,26 @@ const handleWheel = (event: WheelEvent) => {
})
}
const scrollContent = computed(
() =>
(containerRef.value?.querySelector(
'.p-scrollpanel-content'
) as HTMLElement | null) ?? null
)
const scroll = (direction: number) => {
const el = scrollContent.value
if (!el) return
el.scrollBy({ left: direction * 20 })
const scrollElement = scrollPanelRef.value.$el.querySelector(
'.p-scrollpanel-content'
) as HTMLElement
scrollElement.scrollBy({ left: direction * 20 })
}
const ensureActiveTabVisible = async (
options: { waitForDom?: boolean } = {}
) => {
const ensureActiveTabVisible = async () => {
if (!selectedWorkflow.value) return
if (options.waitForDom !== false) {
await nextTick()
}
await nextTick()
const containerElement = containerRef.value
if (!containerElement) return
const scrollPanelElement = scrollPanelRef.value?.$el as
| HTMLElement
| undefined
if (!scrollPanelElement) return
const activeTabElement = containerElement.querySelector(
const activeTabElement = scrollPanelElement.querySelector(
'.p-togglebutton-checked'
)
) as HTMLElement | null
if (!activeTabElement) return
activeTabElement.scrollIntoView({ block: 'nearest', inline: 'nearest' })
@@ -264,56 +255,38 @@ watch(
{ immediate: true }
)
let overflowObserver: ReturnType<typeof useOverflowObserver> | null = null
let stopArrivedWatch: WatchStopHandle | null = null
let stopOverflowWatch: WatchStopHandle | null = null
watch(
scrollContent,
(el, _prev, onCleanup) => {
stopArrivedWatch?.()
stopOverflowWatch?.()
overflowObserver?.dispose()
if (!el) return
const scrollState = useScroll(el)
stopArrivedWatch = watch(
[
() => scrollState.arrivedState.left,
() => scrollState.arrivedState.right
],
([atLeft, atRight]) => {
leftArrowEnabled.value = !atLeft
rightArrowEnabled.value = !atRight
},
{ immediate: true }
)
overflowObserver = useOverflowObserver(el)
stopOverflowWatch = watch(
overflowObserver.isOverflowing,
(isOverflow) => {
showOverflowArrows.value = isOverflow
if (!isOverflow) return
void nextTick(() => {
// Force a new check after arrows are updated
scrollState.measure()
void ensureActiveTabVisible({ waitForDom: false })
})
},
{ immediate: true }
)
onCleanup(() => {
stopArrivedWatch?.()
stopOverflowWatch?.()
overflowObserver?.dispose()
})
},
{ immediate: true }
const scrollContent = computed(
() =>
scrollPanelRef.value?.$el.querySelector(
'.p-scrollpanel-content'
) as HTMLElement
)
let overflowObserver: ReturnType<typeof useOverflowObserver> | null = null
let overflowWatch: ReturnType<typeof watch> | null = null
watch(scrollContent, (value) => {
const scrollState = useScroll(value)
watch(scrollState.arrivedState, () => {
leftArrowEnabled.value = !scrollState.arrivedState.left
rightArrowEnabled.value = !scrollState.arrivedState.right
})
overflowObserver?.dispose()
overflowWatch?.stop()
overflowObserver = useOverflowObserver(value)
overflowWatch = watch(
overflowObserver.isOverflowing,
(value) => {
showOverflowArrows.value = value
void nextTick(() => {
// Force a new check after arrows are updated
scrollState.measure()
void ensureActiveTabVisible()
})
},
{ immediate: true }
)
})
onUpdated(() => {
if (!overflowObserver?.disposed.value) {

View File

@@ -1,12 +1,9 @@
import { FirebaseError } from 'firebase/app'
import { AuthErrorCodes } from 'firebase/auth'
import { ref } from 'vue'
import { useErrorHandling } from '@/composables/useErrorHandling'
import type { ErrorRecoveryStrategy } from '@/composables/useErrorHandling'
import { t } from '@/i18n'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useDialogService } from '@/services/dialogService'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { usdToMicros } from '@/utils/formatUtil'
@@ -125,47 +122,6 @@ export const useFirebaseAuthActions = () => {
reportError
)
/**
* Recovery strategy for Firebase auth/requires-recent-login errors.
* Prompts user to reauthenticate and retries the operation after successful login.
*/
const createReauthenticationRecovery = <
TArgs extends unknown[],
TReturn
>(): ErrorRecoveryStrategy<TArgs, TReturn> => {
const dialogService = useDialogService()
return {
shouldHandle: (error: unknown) =>
error instanceof FirebaseError &&
error.code === AuthErrorCodes.CREDENTIAL_TOO_OLD_LOGIN_AGAIN,
recover: async (
_error: unknown,
retry: (...args: TArgs) => Promise<TReturn> | TReturn,
args: TArgs
) => {
const confirmed = await dialogService.confirm({
title: t('auth.reauthRequired.title'),
message: t('auth.reauthRequired.message'),
type: 'default'
})
if (!confirmed) {
return
}
await authStore.logout()
const signedIn = await dialogService.showSignInDialog()
if (signedIn) {
await retry(...args)
}
}
}
}
const updatePassword = wrapWithErrorHandlingAsync(
async (newPassword: string) => {
await authStore.updatePassword(newPassword)
@@ -176,25 +132,18 @@ export const useFirebaseAuthActions = () => {
life: 5000
})
},
reportError,
undefined,
[createReauthenticationRecovery<[string], void>()]
reportError
)
const deleteAccount = wrapWithErrorHandlingAsync(
async () => {
await authStore.deleteAccount()
toastStore.add({
severity: 'success',
summary: t('auth.deleteAccount.success'),
detail: t('auth.deleteAccount.successDetail'),
life: 5000
})
},
reportError,
undefined,
[createReauthenticationRecovery<[], void>()]
)
const deleteAccount = wrapWithErrorHandlingAsync(async () => {
await authStore.deleteAccount()
toastStore.add({
severity: 'success',
summary: t('auth.deleteAccount.success'),
detail: t('auth.deleteAccount.successDetail'),
life: 5000
})
}, reportError)
return {
logout,

View File

@@ -1,7 +1,6 @@
import { useEventListener } from '@vueuse/core'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { isEventTargetInGraph } from '@/workbench/eventHelpers'
/**
* Adds a handler on copy that serializes selected nodes to JSON
@@ -21,14 +20,14 @@ export const useCopy = () => {
// Default system copy
return
}
// Check if target is graph canvas or within graph UI (minimap, controls, etc.)
if (!isEventTargetInGraph(e.target)) {
return
}
const isTargetInGraph =
e.target.classList.contains('litegraph') ||
e.target.classList.contains('graph-canvas-container') ||
e.target.id === 'graph-canvas'
// copy nodes and clear clipboard
const canvas = canvasStore.canvas
if (canvas?.selectedItems) {
if (isTargetInGraph && canvas?.selectedItems) {
canvas.copyToClipboard()
// clearData doesn't remove images from clipboard
e.clipboardData?.setData('text', ' ')

View File

@@ -5,11 +5,7 @@ import {
DEFAULT_DARK_COLOR_PALETTE,
DEFAULT_LIGHT_COLOR_PALETTE
} from '@/constants/coreColorPalettes'
import {
promoteRecommendedWidgets,
tryToggleWidgetPromotion
} from '@/core/graph/subgraph/proxyWidgetUtils'
import { showSubgraphNodeDialog } from '@/core/graph/subgraph/useSubgraphNodeDialog'
import { promoteRecommendedWidgets } from '@/core/graph/subgraph/proxyWidgetUtils'
import { t } from '@/i18n'
import {
LGraphEventMode,
@@ -892,7 +888,7 @@ export function useCoreCommands(): ComfyCommand[] {
},
{
id: 'Comfy.Graph.ConvertToSubgraph',
icon: 'icon-[lucide--shrink]',
icon: 'pi pi-sitemap',
label: 'Convert Selection to Subgraph',
versionAdded: '1.20.1',
category: 'essentials' as const,
@@ -920,9 +916,10 @@ export function useCoreCommands(): ComfyCommand[] {
},
{
id: 'Comfy.Graph.UnpackSubgraph',
icon: 'icon-[lucide--expand]',
icon: 'pi pi-sitemap',
label: 'Unpack the selected Subgraph',
versionAdded: '1.26.3',
versionAdded: '1.20.1',
category: 'essentials' as const,
function: () => {
const canvas = canvasStore.getCanvas()
const graph = canvas.subgraph ?? canvas.graph
@@ -934,20 +931,6 @@ export function useCoreCommands(): ComfyCommand[] {
graph.unpackSubgraph(subgraphNode)
}
},
{
id: 'Comfy.Graph.EditSubgraphWidgets',
label: 'Edit Subgraph Widgets',
icon: 'icon-[lucide--settings-2]',
versionAdded: '1.28.5',
function: showSubgraphNodeDialog
},
{
id: 'Comfy.Graph.ToggleWidgetPromotion',
icon: 'icon-[lucide--arrow-left-right]',
label: 'Toggle promotion of hovered widget',
versionAdded: '1.30.1',
function: tryToggleWidgetPromotion
},
{
id: 'Comfy.OpenManagerDialog',
icon: 'mdi mdi-puzzle-outline',

View File

@@ -1,54 +1,6 @@
import { t } from '@/i18n'
import { useToastStore } from '@/platform/updates/common/toastStore'
/**
* Strategy for recovering from specific error conditions.
* Allows operations to be retried after resolving the error condition.
*
* @template TArgs - The argument types of the operation to be retried
* @template TReturn - The return type of the operation
*
* @example
* ```typescript
* const networkRecovery: ErrorRecoveryStrategy = {
* shouldHandle: (error) => error instanceof NetworkError,
* recover: async (error, retry) => {
* await waitForNetwork()
* await retry()
* }
* }
* ```
*/
export interface ErrorRecoveryStrategy<
TArgs extends unknown[] = unknown[],
TReturn = unknown
> {
/**
* Determines if this strategy should handle the given error.
* @param error - The error to check
* @returns true if this strategy can handle the error
*/
shouldHandle: (error: unknown) => boolean
/**
* Attempts to recover from the error and retry the operation.
* This function is responsible for:
* 1. Resolving the error condition (e.g., reauthentication, network reconnect)
* 2. Calling retry() to re-execute the original operation
* 3. Handling the retry result (success or failure)
*
* @param error - The error that occurred
* @param retry - Function to retry the original operation
* @param args - Original arguments passed to the operation
* @returns Promise that resolves when recovery completes (whether successful or not)
*/
recover: (
error: unknown,
retry: (...args: TArgs) => Promise<TReturn> | TReturn,
args: TArgs
) => Promise<void>
}
export function useErrorHandling() {
const toast = useToastStore()
const toastErrorHandler = (error: unknown) => {
@@ -61,9 +13,9 @@ export function useErrorHandling() {
}
const wrapWithErrorHandling =
<TArgs extends unknown[], TReturn>(
<TArgs extends any[], TReturn>(
action: (...args: TArgs) => TReturn,
errorHandler?: (error: unknown) => void,
errorHandler?: (error: any) => void,
finallyHandler?: () => void
) =>
(...args: TArgs): TReturn | undefined => {
@@ -77,27 +29,15 @@ export function useErrorHandling() {
}
const wrapWithErrorHandlingAsync =
<TArgs extends unknown[], TReturn>(
<TArgs extends any[], TReturn>(
action: (...args: TArgs) => Promise<TReturn> | TReturn,
errorHandler?: (error: unknown) => void,
finallyHandler?: () => void,
recoveryStrategies: ErrorRecoveryStrategy<TArgs, TReturn>[] = []
errorHandler?: (error: any) => void,
finallyHandler?: () => void
) =>
async (...args: TArgs): Promise<TReturn | undefined> => {
try {
return await action(...args)
} catch (e) {
for (const strategy of recoveryStrategies) {
if (strategy.shouldHandle(e)) {
try {
await strategy.recover(e, action, args)
return
} catch (recoveryError) {
console.error('Recovery strategy failed:', recoveryError)
}
}
}
;(errorHandler ?? toastErrorHandler)(e)
} finally {
finallyHandler?.()

View File

@@ -7,7 +7,6 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isAudioNode, isImageNode, isVideoNode } from '@/utils/litegraphUtil'
import { isEventTargetInGraph } from '@/workbench/eventHelpers'
/**
* Adds a handler on paste that extracts and loads images or workflows from pasted JSON data
@@ -39,10 +38,14 @@ export const usePaste = () => {
}
useEventListener(document, 'paste', async (e) => {
// Check if target is graph canvas or within graph UI (minimap, controls, etc.)
if (!isEventTargetInGraph(e.target)) {
return
}
const isTargetInGraph =
e.target instanceof Element &&
(e.target.classList.contains('litegraph') ||
e.target.classList.contains('graph-canvas-container') ||
e.target.id === 'graph-canvas')
// If the target is not in the graph, we don't want to handle the paste event
if (!isTargetInGraph) return
// ctrl+shift+v is used to paste nodes with connections
// this is handled by litegraph

View File

@@ -1,14 +1,11 @@
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
import type { ProxyWidgetsProperty } from '@/core/schemas/proxyWidget'
import { t } from '@/i18n'
import type {
IContextMenuValue,
LGraphNode
} from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets.ts'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLitegraphService } from '@/services/litegraphService'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
@@ -66,15 +63,7 @@ function getParentNodes(): SubgraphNode[] {
//or by adding a new event for parent listeners to collect from
const { navigationStack } = useSubgraphNavigationStore()
const subgraph = navigationStack.at(-1)
if (!subgraph) {
useToastStore().add({
severity: 'error',
summary: t('g.error'),
detail: t('subgraphStore.promoteOutsideSubgraph'),
life: 2000
})
return []
}
if (!subgraph) throw new Error("Can't promote widget when not in subgraph")
const parentGraph = navigationStack.at(-2) ?? subgraph.rootGraph
return parentGraph.nodes.filter(
(node): node is SubgraphNode =>
@@ -107,21 +96,6 @@ export function addWidgetPromotionOptions(
})
}
}
export function tryToggleWidgetPromotion() {
const canvas = useCanvasStore().getCanvas()
const [x, y] = canvas.graph_mouse
const node = canvas.graph?.getNodeOnPos(x, y, canvas.visible_nodes)
if (!node) return
const widget = node.getWidgetOnPos(x, y, true)
const parents = getParentNodes()
if (!parents.length || !widget) return
const promotableParents = parents.filter(
(s) => !getProxyWidgets(s).some(matchesPropertyItem([node, widget]))
)
if (promotableParents.length > 0)
promoteWidget(node, widget, promotableParents)
else demoteWidget(node, widget, parents)
}
const recommendedNodes = [
'CLIPTextEncode',
'LoadImage',

View File

@@ -1,12 +1,16 @@
import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useExtensionService } from '@/services/extensionService'
useExtensionService().registerExtension({
name: 'Comfy.CloudBadge',
topbarBadges: [
{
label: t('g.beta'),
text: 'Comfy Cloud'
}
]
// Only show badge when running in cloud environment
topbarBadges: isCloud
? [
{
label: t('g.beta'),
text: 'Comfy Cloud'
}
]
: undefined
})

View File

@@ -253,7 +253,7 @@ app.registerExtension({
audio.setAttribute('name', 'media')
const audioUIWidget: DOMWidget<HTMLAudioElement, string> =
node.addDOMWidget(inputName, /* name=*/ 'audioUI', audio)
audioUIWidget.options.canvasOnly = false
audioUIWidget.options.canvasOnly = true
let mediaRecorder: MediaRecorder | null = null
let isRecording = false
@@ -376,12 +376,10 @@ app.registerExtension({
mediaRecorder.stop()
}
},
{ serialize: false, canvasOnly: false }
{ serialize: false, canvasOnly: true }
)
recordWidget.label = t('g.startRecording')
// Override the type for Vue rendering while keeping 'button' for LiteGraph
recordWidget.type = 'audiorecord'
const originalOnRemoved = node.onRemoved
node.onRemoved = function () {

View File

@@ -151,7 +151,7 @@ interface DrawTitleTextOptions extends DrawTitleOptions {
default_title_color: string
}
export interface DrawTitleBoxOptions extends DrawTitleOptions {
interface DrawTitleBoxOptions extends DrawTitleOptions {
box_size?: number
}

View File

@@ -2,7 +2,6 @@ import type { BaseLGraph, LGraph } from '@/lib/litegraph/src/LGraph'
import type { LGraphButton } from '@/lib/litegraph/src/LGraphButton'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { DrawTitleBoxOptions } from '@/lib/litegraph/src/LGraphNode'
import { LLink } from '@/lib/litegraph/src/LLink'
import type { ResolvedConnection } from '@/lib/litegraph/src/LLink'
import { RecursionError } from '@/lib/litegraph/src/infrastructure/RecursionError'
@@ -10,7 +9,6 @@ import type {
ISubgraphInput,
IWidgetLocator
} from '@/lib/litegraph/src/interfaces'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type {
INodeInputSlot,
ISlotType,
@@ -34,10 +32,6 @@ import { ExecutableNodeDTO } from './ExecutableNodeDTO'
import type { ExecutableLGraphNode, ExecutionId } from './ExecutableNodeDTO'
import type { SubgraphInput } from './SubgraphInput'
const workflowSvg = new Image()
workflowSvg.src =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' width='16' height='16'%3E%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3E%3Cpath stroke='white' stroke-linecap='round' stroke-width='1.3' d='M9.18613 3.09999H6.81377M9.18613 12.9H7.55288c-3.08678 0-5.35171-2.99581-4.60305-6.08843l.3054-1.26158M14.7486 2.1721l-.5931 2.45c-.132.54533-.6065.92789-1.1508.92789h-2.2993c-.77173 0-1.33797-.74895-1.1508-1.5221l.5931-2.45c.132-.54533.6065-.9279 1.1508-.9279h2.2993c.7717 0 1.3379.74896 1.1508 1.52211Zm-8.3033 0-.59309 2.45c-.13201.54533-.60646.92789-1.15076.92789H2.4021c-.7717 0-1.33793-.74895-1.15077-1.5221l.59309-2.45c.13201-.54533.60647-.9279 1.15077-.9279h2.29935c.77169 0 1.33792.74896 1.15076 1.52211Zm8.3033 9.8-.5931 2.45c-.132.5453-.6065.9279-1.1508.9279h-2.2993c-.77173 0-1.33797-.749-1.1508-1.5221l.5931-2.45c.132-.5453.6065-.9279 1.1508-.9279h2.2993c.7717 0 1.3379.7489 1.1508 1.5221Z'/%3E%3C/svg%3E %3C/svg%3E"
/**
* An instance of a {@link Subgraph}, displayed as a node on the containing (parent) graph.
*/
@@ -561,31 +555,6 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
}
}
override drawTitleBox(
ctx: CanvasRenderingContext2D,
{
scale,
low_quality = false,
title_height = LiteGraph.NODE_TITLE_HEIGHT,
box_size = 10
}: DrawTitleBoxOptions
): void {
if (this.onDrawTitleBox) {
this.onDrawTitleBox(ctx, title_height, this.renderingSize, scale)
return
}
ctx.save()
ctx.fillStyle = '#3b82f6'
ctx.beginPath()
ctx.roundRect(6, -24.5, 22, 20, 5)
ctx.fill()
if (!low_quality) {
ctx.translate(25, 23)
ctx.scale(-1.5, 1.5)
ctx.drawImage(workflowSvg, 0, -title_height, box_size, box_size)
}
ctx.restore()
}
/**
* Synchronizes widget values from this SubgraphNode instance to the

View File

@@ -79,6 +79,7 @@ export type IWidget =
| ISelectButtonWidget
| ITextareaWidget
| IAssetWidget
| IAudioRecordWidget
export interface IBooleanWidget extends IBaseWidget<boolean, 'toggle'> {
type: 'toggle'
@@ -227,6 +228,11 @@ export interface ITextareaWidget extends IBaseWidget<string, 'textarea'> {
value: string
}
export interface IAudioRecordWidget extends IBaseWidget<string, 'audiorecord'> {
type: 'audiorecord'
value: string
}
export interface IAssetWidget
extends IBaseWidget<string, 'asset', IWidgetOptions<string[]>> {
type: 'asset'

View File

@@ -128,9 +128,6 @@
"Comfy_Graph_ConvertToSubgraph": {
"label": "Convert Selection to Subgraph"
},
"Comfy_Graph_EditSubgraphWidgets": {
"label": "Edit Subgraph Widgets"
},
"Comfy_Graph_ExitSubgraph": {
"label": "Exit Subgraph"
},
@@ -140,9 +137,6 @@
"Comfy_Graph_GroupSelectedNodes": {
"label": "Group Selected Nodes"
},
"Comfy_Graph_ToggleWidgetPromotion": {
"label": "Toggle promotion of hovered widget"
},
"Comfy_Graph_UnpackSubgraph": {
"label": "Unpack the selected Subgraph"
},

View File

@@ -1102,7 +1102,6 @@
"overwriteBlueprintTitle": "Overwrite existing blueprint?",
"overwriteBlueprint": "Saving will overwrite the current blueprint with your changes",
"blueprintName": "Subgraph name",
"promoteOutsideSubgraph": "Can't promote widget when not in subgraph",
"publish": "Publish Subgraph",
"publishSuccess": "Saved to Nodes Library",
"publishSuccessMessage": "You can find your subgraph blueprint in the nodes library under \"Subgraph Blueprints\"",
@@ -1213,11 +1212,9 @@
"Export": "Export",
"Export (API)": "Export (API)",
"Convert Selection to Subgraph": "Convert Selection to Subgraph",
"Edit Subgraph Widgets": "Edit Subgraph Widgets",
"Exit Subgraph": "Exit Subgraph",
"Fit Group To Contents": "Fit Group To Contents",
"Group Selected Nodes": "Group Selected Nodes",
"Toggle promotion of hovered widget": "Toggle promotion of hovered widget",
"Unpack the selected Subgraph": "Unpack the selected Subgraph",
"Convert selected nodes to group node": "Convert selected nodes to group node",
"Manage group nodes": "Manage group nodes",
@@ -1866,12 +1863,6 @@
"success": "Account Deleted",
"successDetail": "Your account has been successfully deleted."
},
"reauthRequired": {
"title": "Re-authentication Required",
"message": "For security reasons, this action requires you to sign in again. Would you like to proceed?",
"confirm": "Sign In Again",
"cancel": "Cancel"
},
"loginButton": {
"tooltipHelp": "Login to be able to use \"API Nodes\"",
"tooltipLearnMore": "Learn more..."

View File

@@ -1532,12 +1532,10 @@
},
"outputs": {
"0": {
"name": "positive",
"tooltip": null
"name": "positive"
},
"1": {
"name": "negative",
"tooltip": null
"name": "negative"
}
}
},
@@ -10375,11 +10373,6 @@
"type": {
"name": "type"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"SkipLayerGuidanceDiT": {

View File

@@ -17,4 +17,4 @@ const DISTRIBUTION: Distribution = __DISTRIBUTION__
/** Distribution type checks */
export const isDesktop = DISTRIBUTION === 'desktop' || isElectron() // TODO: replace with build var
export const isCloud = DISTRIBUTION === 'cloud'
// export const isLocalhost = DISTRIBUTION === 'localhost' || (!isDesktop && !isCloud)
// export const isLocalhost = !isDesktop && !isCloud

View File

@@ -1,9 +1,8 @@
<template>
<div
v-if="visible && initialized"
id="comfy-minimap"
ref="minimapRef"
class="minimap-main-container absolute right-0 bottom-[58px] z-1000 flex"
class="minimap-main-container absolute right-2 bottom-[66px] z-1000 flex"
>
<MiniMapPanel
v-if="showOptionsPanel"

View File

@@ -2,11 +2,7 @@ import { useThrottleFn } from '@vueuse/core'
import { ref, watch } from 'vue'
import type { Ref } from 'vue'
import type {
LGraph,
LGraphNode,
LGraphTriggerEvent
} from '@/lib/litegraph/src/litegraph'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { api } from '@/scripts/api'
@@ -18,7 +14,6 @@ interface GraphCallbacks {
onNodeAdded?: (node: LGraphNode) => void
onNodeRemoved?: (node: LGraphNode) => void
onConnectionChange?: (node: LGraphNode) => void
onTrigger?: (event: LGraphTriggerEvent) => void
}
export function useMinimapGraph(
@@ -58,8 +53,7 @@ export function useMinimapGraph(
const originalCallbacks: GraphCallbacks = {
onNodeAdded: g.onNodeAdded,
onNodeRemoved: g.onNodeRemoved,
onConnectionChange: g.onConnectionChange,
onTrigger: g.onTrigger
onConnectionChange: g.onConnectionChange
}
originalCallbacksMap.set(g.id, originalCallbacks)
@@ -78,22 +72,6 @@ export function useMinimapGraph(
originalCallbacks.onConnectionChange?.call(this, node)
void handleGraphChangedThrottled()
}
g.onTrigger = function (event: LGraphTriggerEvent) {
originalCallbacks.onTrigger?.call(this, event)
// Listen for visual property changes that affect minimap rendering
if (
event.type === 'node:property:changed' &&
(event.property === 'mode' ||
event.property === 'bgcolor' ||
event.property === 'color')
) {
// Invalidate cache for this node to force redraw
nodeStatesCache.delete(String(event.nodeId))
void handleGraphChangedThrottled()
}
}
}
const cleanupEventListeners = (oldGraph?: LGraph) => {
@@ -111,7 +89,6 @@ export function useMinimapGraph(
g.onNodeAdded = originalCallbacks.onNodeAdded
g.onNodeRemoved = originalCallbacks.onNodeRemoved
g.onConnectionChange = originalCallbacks.onConnectionChange
g.onTrigger = originalCallbacks.onTrigger
originalCallbacksMap.delete(g.id)
}

View File

@@ -37,7 +37,6 @@
</IconButton>
</div>
<div v-if="isSubgraphNode" class="icon-[comfy--workflow] size-4" />
<!-- Node Title -->
<div
v-tooltip.top="tooltipConfig"

View File

@@ -33,20 +33,16 @@ const selectedItems = computed(() => {
})
const chevronClass = computed(() =>
cn(
'mr-2 size-4 transition-transform duration-200 flex-shrink-0 text-button-icon',
{
'rotate-180': props.isOpen
}
)
cn('mr-2 size-4 transition-transform duration-200 flex-shrink-0', {
'rotate-180': props.isOpen
})
)
const theButtonStyle = computed(() =>
cn('bg-transparent border-0 outline-none text-text-secondary', {
cn('bg-transparent border-0 outline-none text-zinc-400', {
'hover:bg-node-component-widget-input-surface/30 cursor-pointer':
!props.disabled,
'cursor-not-allowed': props.disabled,
'text-text-primary': selectedItems.value.length > 0
'cursor-not-allowed': props.disabled
})
)
</script>

View File

@@ -0,0 +1,24 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IAudioRecordWidget } from '@/lib/litegraph/src/types/widgets'
import type {
AudioRecordInputSpec,
InputSpec as InputSpecV2
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
export const useAudioRecordWidget = (): ComfyWidgetConstructorV2 => {
return (node: LGraphNode, inputSpec: InputSpecV2): IAudioRecordWidget => {
const {
name,
default: defaultValue = '',
options = {}
} = inputSpec as AudioRecordInputSpec
const widget = node.addWidget('audiorecord', name, defaultValue, () => {}, {
serialize: true,
...options
}) as IAudioRecordWidget
return widget
}
}

View File

@@ -1,6 +1,5 @@
import type { ResultItemType } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
/**
* Format time in MM:SS format
@@ -24,6 +23,10 @@ export function getAudioUrlFromPath(
return api.apiURL(getResourceURL(subfolder, filename, type))
}
function getRandParam() {
return '&rand=' + Math.random()
}
export function getResourceURL(
subfolder: string,
filename: string,
@@ -33,7 +36,7 @@ export function getResourceURL(
'filename=' + encodeURIComponent(filename),
'type=' + type,
'subfolder=' + subfolder,
app.getRandParam().substring(1)
getRandParam().substring(1)
].join('&')
return `/view?${params}`

View File

@@ -152,6 +152,13 @@ const zTextareaInputSpec = zBaseInputOptions.extend({
.optional()
})
const zAudioRecordInputSpec = zBaseInputOptions.extend({
type: z.literal('AUDIORECORD'),
name: z.string(),
isOptional: z.boolean().optional(),
options: z.record(z.unknown()).optional()
})
const zCustomInputSpec = zBaseInputOptions.extend({
type: z.string(),
name: z.string(),
@@ -167,6 +174,7 @@ const zInputSpec = z.union([
zColorInputSpec,
zFileUploadInputSpec,
zImageInputSpec,
zAudioRecordInputSpec,
zImageCompareInputSpec,
zMarkdownInputSpec,
zTreeSelectInputSpec,
@@ -222,6 +230,7 @@ export type GalleriaInputSpec = z.infer<typeof zGalleriaInputSpec>
export type SelectButtonInputSpec = z.infer<typeof zSelectButtonInputSpec>
export type TextareaInputSpec = z.infer<typeof zTextareaInputSpec>
export type CustomInputSpec = z.infer<typeof zCustomInputSpec>
export type AudioRecordInputSpec = z.infer<typeof zAudioRecordInputSpec>
export type InputSpec = z.infer<typeof zInputSpec>
export type OutputSpec = z.infer<typeof zOutputSpec>

View File

@@ -16,7 +16,6 @@ import {
} from '@/lib/litegraph/src/litegraph'
import type { Vector2 } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
@@ -337,7 +336,6 @@ export class ComfyApp {
}
getRandParam() {
if (isCloud) return ''
return '&rand=' + Math.random()
}

View File

@@ -6,6 +6,7 @@ import type {
IStringWidget
} from '@/lib/litegraph/src/types/widgets'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useAudioRecordWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useAudioRecordWidget'
import { useBooleanWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useBooleanWidget'
import { useChartWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useChartWidget'
import { useColorWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useColorWidget'
@@ -304,5 +305,6 @@ export const ComfyWidgets: Record<string, ComfyWidgetConstructor> = {
CHART: transformWidgetConstructorV2ToV1(useChartWidget()),
GALLERIA: transformWidgetConstructorV2ToV1(useGalleriaWidget()),
SELECTBUTTON: transformWidgetConstructorV2ToV1(useSelectButtonWidget()),
TEXTAREA: transformWidgetConstructorV2ToV1(useTextareaWidget())
TEXTAREA: transformWidgetConstructorV2ToV1(useTextareaWidget()),
AUDIO_RECORD: transformWidgetConstructorV2ToV1(useAudioRecordWidget())
}

View File

@@ -91,7 +91,7 @@ export const useColorPaletteService = () => {
propertyMaybe: unknown
): propertyMaybe is keyof typeof THEME_PROPERTY_MAP {
return (
(propertyMaybe as keyof typeof THEME_PROPERTY_MAP) in THEME_PROPERTY_MAP
typeof propertyMaybe === 'string' && propertyMaybe in THEME_PROPERTY_MAP
)
}

View File

@@ -15,9 +15,6 @@ export const useKeybindingService = () => {
const settingStore = useSettingStore()
const dialogStore = useDialogStore()
// Keys that LiteGraph handles but aren't in core keybindings
const canvasBindedKeys = ['Delete', 'Backspace']
// Helper function to determine if an event should be forwarded to canvas
const shouldForwardToCanvas = (event: KeyboardEvent): boolean => {
// Don't forward if modifier keys are pressed (except shift)
@@ -25,7 +22,10 @@ export const useKeybindingService = () => {
return false
}
return canvasBindedKeys.includes(event.key)
// Keys that LiteGraph handles but aren't in core keybindings
const canvasKeys = ['Delete', 'Backspace']
return canvasKeys.includes(event.key)
}
const keybindHandler = async function (event: KeyboardEvent) {

View File

@@ -8,6 +8,7 @@ import { addWidgetPromotionOptions } from '@/core/graph/subgraph/proxyWidgetUtil
import { showSubgraphNodeDialog } from '@/core/graph/subgraph/useSubgraphNodeDialog'
import { st, t } from '@/i18n'
import {
LGraphBadge,
LGraphCanvas,
LGraphEventMode,
LGraphNode,
@@ -134,6 +135,19 @@ export const useLitegraphService = () => {
this.#setInitialSize()
this.serialize_widgets = true
void extensionService.invokeExtensionsAsync('nodeCreated', this)
this.badges.push(
new LGraphBadge({
text: '',
iconOptions: {
unicode: '\ue96e',
fontFamily: 'PrimeIcons',
color: '#ffffff',
fontSize: 12
},
fgColor: '#ffffff',
bgColor: '#3b82f6'
})
)
}
/**
@@ -831,7 +845,7 @@ export const useLitegraphService = () => {
)
}
if (this.graph && !this.graph.isRootGraph) {
const [x, y] = canvas.graph_mouse
const [x, y] = canvas.canvas_mouse
const overWidget = this.getWidgetOnPos(x, y, true)
if (overWidget) {
addWidgetPromotionOptions(options, overWidget, this)

View File

@@ -54,7 +54,7 @@ export interface TreeExplorerNode<T = any> extends TreeNode {
event: MouseEvent
) => void | Promise<void>
/** Function to handle errors */
handleError?: (this: TreeExplorerNode<T>, error: unknown) => void
handleError?: (this: TreeExplorerNode<T>, error: Error) => void
/** Extra context menu items */
contextMenuItems?:
| MenuItem[]

View File

@@ -1,31 +0,0 @@
/**
* Utility functions for handling workbench events
*/
/**
* Checks if an event target is within the graph canvas or related UI elements
* (minimap, canvas controls, etc.)
*
* Used by clipboard handlers to determine if copy/paste events should be
* intercepted for graph operations vs. allowing default browser behavior
* for text inputs and other UI elements.
*
* @param target - The event target to check
* @returns true if the target is within graph-related UI elements
*/
export function isEventTargetInGraph(target: EventTarget | null): boolean {
if (!(target instanceof Element)) {
return false
}
return (
target.id === 'graph-canvas' ||
target.id === 'comfy-minimap' ||
target.id === 'graph-canvas-controls' ||
target.classList.contains('graph-canvas-container') ||
target.classList.contains('litegraph') ||
target.closest('#comfy-minimap') !== null ||
target.closest('#graph-canvas-controls') !== null ||
target.closest('#graph-canvas-container') !== null
)
}

View File

@@ -1,353 +0,0 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ErrorRecoveryStrategy } from '@/composables/useErrorHandling'
import { useErrorHandling } from '@/composables/useErrorHandling'
describe('useErrorHandling', () => {
let errorHandler: ReturnType<typeof useErrorHandling>
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createPinia())
errorHandler = useErrorHandling()
})
describe('wrapWithErrorHandlingAsync', () => {
it('should execute action successfully', async () => {
const action = vi.fn(async () => 'success')
const wrapped = errorHandler.wrapWithErrorHandlingAsync(action)
const result = await wrapped()
expect(result).toBe('success')
expect(action).toHaveBeenCalledOnce()
})
it('should call error handler when action throws', async () => {
const testError = new Error('test error')
const action = vi.fn(async () => {
throw testError
})
const customErrorHandler = vi.fn()
const wrapped = errorHandler.wrapWithErrorHandlingAsync(
action,
customErrorHandler
)
await wrapped()
expect(customErrorHandler).toHaveBeenCalledWith(testError)
})
it('should call finally handler after success', async () => {
const action = vi.fn(async () => 'success')
const finallyHandler = vi.fn()
const wrapped = errorHandler.wrapWithErrorHandlingAsync(
action,
undefined,
finallyHandler
)
await wrapped()
expect(finallyHandler).toHaveBeenCalledOnce()
})
it('should call finally handler after error', async () => {
const action = vi.fn(async () => {
throw new Error('test error')
})
const finallyHandler = vi.fn()
const wrapped = errorHandler.wrapWithErrorHandlingAsync(
action,
vi.fn(),
finallyHandler
)
await wrapped()
expect(finallyHandler).toHaveBeenCalledOnce()
})
describe('error recovery', () => {
it('should not use recovery strategy when no error occurs', async () => {
const action = vi.fn(async () => 'success')
const recoveryStrategy: ErrorRecoveryStrategy = {
shouldHandle: vi.fn(() => true),
recover: vi.fn()
}
const wrapped = errorHandler.wrapWithErrorHandlingAsync(
action,
undefined,
undefined,
[recoveryStrategy]
)
await wrapped()
expect(recoveryStrategy.shouldHandle).not.toHaveBeenCalled()
expect(recoveryStrategy.recover).not.toHaveBeenCalled()
})
it('should use recovery strategy when it matches error', async () => {
const testError = new Error('test error')
const action = vi.fn(async () => {
throw testError
})
const recoveryStrategy: ErrorRecoveryStrategy = {
shouldHandle: vi.fn((error) => error === testError),
recover: vi.fn(async () => {
// Recovery succeeds, does nothing
})
}
const wrapped = errorHandler.wrapWithErrorHandlingAsync(
action,
vi.fn(),
undefined,
[recoveryStrategy]
)
await wrapped()
expect(recoveryStrategy.shouldHandle).toHaveBeenCalledWith(testError)
expect(recoveryStrategy.recover).toHaveBeenCalled()
})
it('should pass action and args to recovery strategy', async () => {
const testError = new Error('test error')
const action = vi.fn(async (_arg1: string, _arg2: number) => {
throw testError
})
const recoveryStrategy: ErrorRecoveryStrategy<[string, number], void> =
{
shouldHandle: vi.fn(() => true),
recover: vi.fn()
}
const wrapped = errorHandler.wrapWithErrorHandlingAsync(
action,
vi.fn(),
undefined,
[recoveryStrategy]
)
await wrapped('test', 123)
expect(recoveryStrategy.recover).toHaveBeenCalledWith(
testError,
action,
['test', 123]
)
})
it('should retry operation when recovery succeeds', async () => {
let attemptCount = 0
const action = vi.fn(async (value: string) => {
attemptCount++
if (attemptCount === 1) {
throw new Error('first attempt failed')
}
return `success: ${value}`
})
const recoveryStrategy: ErrorRecoveryStrategy<[string], string> = {
shouldHandle: vi.fn(() => true),
recover: vi.fn(async (_error, retry, args) => {
await retry(...args)
})
}
const wrapped = errorHandler.wrapWithErrorHandlingAsync(
action,
vi.fn(),
undefined,
[recoveryStrategy]
)
await wrapped('test-value')
expect(action).toHaveBeenCalledTimes(2)
expect(recoveryStrategy.recover).toHaveBeenCalledOnce()
})
it('should not call error handler when recovery succeeds', async () => {
const action = vi.fn(async () => {
throw new Error('test error')
})
const customErrorHandler = vi.fn()
const recoveryStrategy: ErrorRecoveryStrategy = {
shouldHandle: vi.fn(() => true),
recover: vi.fn(async () => {
// Recovery succeeds
})
}
const wrapped = errorHandler.wrapWithErrorHandlingAsync(
action,
customErrorHandler,
undefined,
[recoveryStrategy]
)
await wrapped()
expect(customErrorHandler).not.toHaveBeenCalled()
})
it('should call error handler when recovery fails', async () => {
const originalError = new Error('original error')
const recoveryError = new Error('recovery error')
const action = vi.fn(async () => {
throw originalError
})
const customErrorHandler = vi.fn()
const recoveryStrategy: ErrorRecoveryStrategy = {
shouldHandle: vi.fn(() => true),
recover: vi.fn(async () => {
throw recoveryError
})
}
const wrapped = errorHandler.wrapWithErrorHandlingAsync(
action,
customErrorHandler,
undefined,
[recoveryStrategy]
)
await wrapped()
expect(customErrorHandler).toHaveBeenCalledWith(originalError)
})
it('should try multiple recovery strategies in order', async () => {
const testError = new Error('test error')
const action = vi.fn(async () => {
throw testError
})
const strategy1: ErrorRecoveryStrategy = {
shouldHandle: vi.fn(() => false),
recover: vi.fn()
}
const strategy2: ErrorRecoveryStrategy = {
shouldHandle: vi.fn(() => true),
recover: vi.fn(async () => {
// Recovery succeeds
})
}
const strategy3: ErrorRecoveryStrategy = {
shouldHandle: vi.fn(() => true),
recover: vi.fn()
}
const wrapped = errorHandler.wrapWithErrorHandlingAsync(
action,
vi.fn(),
undefined,
[strategy1, strategy2, strategy3]
)
await wrapped()
expect(strategy1.shouldHandle).toHaveBeenCalledWith(testError)
expect(strategy1.recover).not.toHaveBeenCalled()
expect(strategy2.shouldHandle).toHaveBeenCalledWith(testError)
expect(strategy2.recover).toHaveBeenCalled()
// Strategy 3 should not be checked because strategy 2 handled it
expect(strategy3.shouldHandle).not.toHaveBeenCalled()
expect(strategy3.recover).not.toHaveBeenCalled()
})
it('should fall back to error handler when no strategy matches', async () => {
const testError = new Error('test error')
const action = vi.fn(async () => {
throw testError
})
const customErrorHandler = vi.fn()
const strategy: ErrorRecoveryStrategy = {
shouldHandle: vi.fn(() => false),
recover: vi.fn()
}
const wrapped = errorHandler.wrapWithErrorHandlingAsync(
action,
customErrorHandler,
undefined,
[strategy]
)
await wrapped()
expect(strategy.shouldHandle).toHaveBeenCalledWith(testError)
expect(strategy.recover).not.toHaveBeenCalled()
expect(customErrorHandler).toHaveBeenCalledWith(testError)
})
it('should work with synchronous actions', async () => {
const testError = new Error('test error')
const action = vi.fn(() => {
throw testError
})
const recoveryStrategy: ErrorRecoveryStrategy = {
shouldHandle: vi.fn(() => true),
recover: vi.fn(async () => {
// Recovery succeeds
})
}
const wrapped = errorHandler.wrapWithErrorHandlingAsync(
action,
vi.fn(),
undefined,
[recoveryStrategy]
)
await wrapped()
expect(recoveryStrategy.recover).toHaveBeenCalled()
})
})
describe('backward compatibility', () => {
it('should work without recovery strategies parameter', async () => {
const action = vi.fn(async () => 'success')
const wrapped = errorHandler.wrapWithErrorHandlingAsync(action)
const result = await wrapped()
expect(result).toBe('success')
})
it('should work with empty recovery strategies array', async () => {
const testError = new Error('test error')
const action = vi.fn(async () => {
throw testError
})
const customErrorHandler = vi.fn()
const wrapped = errorHandler.wrapWithErrorHandlingAsync(
action,
customErrorHandler,
undefined,
[]
)
await wrapped()
expect(customErrorHandler).toHaveBeenCalledWith(testError)
})
})
})
})