diff --git a/.claude/commands/comprehensive-pr-review.md b/.claude/commands/comprehensive-pr-review.md index 84708564e..1b4047e78 100644 --- a/.claude/commands/comprehensive-pr-review.md +++ b/.claude/commands/comprehensive-pr-review.md @@ -67,9 +67,9 @@ This is critical for better file inspection: Use git locally for much faster analysis: -1. Get list of changed files: `git diff --name-only "origin/$BASE_BRANCH" > changed_files.txt` -2. Get the full diff: `git diff "origin/$BASE_BRANCH" > pr_diff.txt` -3. Get detailed file changes with status: `git diff --name-status "origin/$BASE_BRANCH" > file_changes.txt` +1. Get list of changed files: `git diff --name-only "$BASE_SHA" > changed_files.txt` +2. Get the full diff: `git diff "$BASE_SHA" > pr_diff.txt` +3. Get detailed file changes with status: `git diff --name-status "$BASE_SHA" > file_changes.txt` ### Step 1.5: Create Analysis Cache diff --git a/.gitattributes b/.gitattributes index de05efbf4..bd0518cde 100644 --- a/.gitattributes +++ b/.gitattributes @@ -13,4 +13,4 @@ # Generated files src/types/comfyRegistryTypes.ts linguist-generated=true -src/types/generatedManagerTypes.ts linguist-generated=true +src/workbench/extensions/manager/types/generatedManagerTypes.ts linguist-generated=true diff --git a/.github/workflows/backport.yaml b/.github/workflows/backport.yaml index 907695e57..178bd4ee8 100644 --- a/.github/workflows/backport.yaml +++ b/.github/workflows/backport.yaml @@ -4,10 +4,25 @@ on: pull_request_target: types: [closed, labeled] branches: [main] + workflow_dispatch: + inputs: + pr_number: + description: 'PR number to backport' + required: true + type: string + force_rerun: + description: 'Force rerun even if backports exist' + required: false + type: boolean + default: false jobs: backport: - if: github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'needs-backport') + if: > + (github.event_name == 'pull_request_target' && + github.event.pull_request.merged == true && + contains(github.event.pull_request.labels.*.name, 'needs-backport')) || + github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest permissions: contents: write @@ -15,6 +30,35 @@ jobs: issues: write steps: + - name: Validate inputs for manual triggers + if: github.event_name == 'workflow_dispatch' + run: | + # Validate PR number format + if ! [[ "${{ inputs.pr_number }}" =~ ^[0-9]+$ ]]; then + echo "::error::Invalid PR number format. Must be a positive integer." + exit 1 + fi + + # Validate PR exists and is merged + if ! gh pr view "${{ inputs.pr_number }}" --json merged >/dev/null 2>&1; then + echo "::error::PR #${{ inputs.pr_number }} not found or inaccessible." + exit 1 + fi + + MERGED=$(gh pr view "${{ inputs.pr_number }}" --json merged --jq '.merged') + if [ "$MERGED" != "true" ]; then + echo "::error::PR #${{ inputs.pr_number }} is not merged. Only merged PRs can be backported." + exit 1 + fi + + # Validate PR has needs-backport label + if ! gh pr view "${{ inputs.pr_number }}" --json labels --jq '.labels[].name' | grep -q "needs-backport"; then + echo "::error::PR #${{ inputs.pr_number }} does not have 'needs-backport' label." + exit 1 + fi + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Checkout repository uses: actions/checkout@v4 with: @@ -29,7 +73,7 @@ jobs: id: check-existing env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PR_NUMBER: ${{ github.event.pull_request.number }} + PR_NUMBER: ${{ github.event_name == 'workflow_dispatch' && inputs.pr_number || github.event.pull_request.number }} run: | # Check for existing backport PRs for this PR number EXISTING_BACKPORTS=$(gh pr list --state all --search "backport-${PR_NUMBER}-to" --json title,headRefName,baseRefName | jq -r '.[].headRefName') @@ -39,6 +83,13 @@ jobs: exit 0 fi + # For manual triggers with force_rerun, proceed anyway + if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.force_rerun }}" = "true" ]; then + echo "skip=false" >> $GITHUB_OUTPUT + echo "::warning::Force rerun requested - existing backports will be updated" + exit 0 + fi + echo "Found existing backport PRs:" echo "$EXISTING_BACKPORTS" echo "skip=true" >> $GITHUB_OUTPUT @@ -50,8 +101,17 @@ jobs: run: | # Extract version labels (e.g., "1.24", "1.22") VERSIONS="" - LABELS='${{ toJSON(github.event.pull_request.labels) }}' - for label in $(echo "$LABELS" | jq -r '.[].name'); do + + 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 + + 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 @@ -75,12 +135,20 @@ jobs: if: steps.check-existing.outputs.skip != 'true' id: backport env: - PR_NUMBER: ${{ github.event.pull_request.number }} - PR_TITLE: ${{ github.event.pull_request.title }} - MERGE_COMMIT: ${{ github.event.pull_request.merge_commit_sha }} + PR_NUMBER: ${{ github.event_name == 'workflow_dispatch' && inputs.pr_number || github.event.pull_request.number }} run: | FAILED="" SUCCESS="" + + # Get PR data for manual triggers + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + PR_DATA=$(gh pr view ${{ inputs.pr_number }} --json title,mergeCommit) + PR_TITLE=$(echo "$PR_DATA" | jq -r '.title') + MERGE_COMMIT=$(echo "$PR_DATA" | jq -r '.mergeCommit.oid') + else + PR_TITLE="${{ github.event.pull_request.title }}" + MERGE_COMMIT="${{ github.event.pull_request.merge_commit_sha }}" + fi for version in ${{ steps.versions.outputs.versions }}; do echo "::group::Backporting to core/${version}" @@ -133,10 +201,18 @@ jobs: if: steps.check-existing.outputs.skip != 'true' && steps.backport.outputs.success env: GH_TOKEN: ${{ secrets.PR_GH_TOKEN }} - PR_TITLE: ${{ github.event.pull_request.title }} - PR_NUMBER: ${{ github.event.pull_request.number }} - PR_AUTHOR: ${{ github.event.pull_request.user.login }} + PR_NUMBER: ${{ github.event_name == 'workflow_dispatch' && inputs.pr_number || github.event.pull_request.number }} run: | + # Get PR data for manual triggers + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + PR_DATA=$(gh pr view ${{ inputs.pr_number }} --json title,author) + PR_TITLE=$(echo "$PR_DATA" | jq -r '.title') + PR_AUTHOR=$(echo "$PR_DATA" | jq -r '.author.login') + else + PR_TITLE="${{ github.event.pull_request.title }}" + PR_AUTHOR="${{ github.event.pull_request.user.login }}" + fi + for backport in ${{ steps.backport.outputs.success }}; do IFS=':' read -r version branch <<< "${backport}" @@ -165,9 +241,16 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - PR_NUMBER="${{ github.event.pull_request.number }}" - PR_AUTHOR="${{ github.event.pull_request.user.login }}" - MERGE_COMMIT="${{ github.event.pull_request.merge_commit_sha }}" + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + PR_DATA=$(gh pr view ${{ inputs.pr_number }} --json author,mergeCommit) + PR_NUMBER="${{ inputs.pr_number }}" + PR_AUTHOR=$(echo "$PR_DATA" | jq -r '.author.login') + MERGE_COMMIT=$(echo "$PR_DATA" | jq -r '.mergeCommit.oid') + else + PR_NUMBER="${{ github.event.pull_request.number }}" + PR_AUTHOR="${{ github.event.pull_request.user.login }}" + MERGE_COMMIT="${{ github.event.pull_request.merge_commit_sha }}" + fi for failure in ${{ steps.backport.outputs.failed }}; do IFS=':' read -r version reason conflicts <<< "${failure}" diff --git a/.gitignore b/.gitignore index 5473190ea..32e1b6624 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,7 @@ components.d.ts tests-ui/data/* tests-ui/ComfyUI_examples tests-ui/workflows/examples +coverage/ # Browser tests /test-results/ diff --git a/CODEOWNERS b/CODEOWNERS index 8d4e4a90f..cd1b4e508 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1,17 +1,61 @@ -# Admins -* @Comfy-Org/comfy_frontend_devs +# Desktop/Electron +/src/types/desktop/ @webfiltered +/src/constants/desktopDialogs.ts @webfiltered +/src/constants/desktopMaintenanceTasks.ts @webfiltered +/src/stores/electronDownloadStore.ts @webfiltered +/src/extensions/core/electronAdapter.ts @webfiltered +/src/views/DesktopDialogView.vue @webfiltered +/src/components/install/ @webfiltered +/src/components/maintenance/ @webfiltered +/vite.electron.config.mts @webfiltered -# Maintainers -*.md @Comfy-Org/comfy_maintainer -/tests-ui/ @Comfy-Org/comfy_maintainer -/browser_tests/ @Comfy-Org/comfy_maintainer -/.env_example @Comfy-Org/comfy_maintainer +# Common UI Components +/src/components/chip/ @viva-jinyi +/src/components/card/ @viva-jinyi +/src/components/button/ @viva-jinyi +/src/components/input/ @viva-jinyi -# Translations (AIGODLIKE team + shinshin86) -/src/locales/ @Yorha4D @KarryCharon @DorotaLuna @shinshin86 @Comfy-Org/comfy_maintainer +# Topbar +/src/components/topbar/ @pythongosssss -# Load 3D extension -/src/extensions/core/load3d.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs +# Thumbnail +/src/renderer/core/thumbnail/ @pythongosssss -# Mask Editor extension -/src/extensions/core/maskeditor.ts @brucew4yn3rp @trsommer @Comfy-Org/comfy_frontend_devs +# Legacy UI +/scripts/ui/ @pythongosssss + +# Link rendering +/src/renderer/core/canvas/links/ @benceruleanlu + +# Node help system +/src/utils/nodeHelpUtil.ts @benceruleanlu +/src/stores/workspace/nodeHelpStore.ts @benceruleanlu +/src/services/nodeHelpService.ts @benceruleanlu + +# Selection toolbox +/src/components/graph/selectionToolbox/ @Myestery + +# Minimap +/src/renderer/extensions/minimap/ @jtydhr88 + +# Assets +/src/platform/assets/ @arjansingh + +# Workflow Templates +/src/platform/workflow/templates/ @Myestery @christian-byrne @comfyui-wiki +/src/components/templates/ @Myestery @christian-byrne @comfyui-wiki + +# Mask Editor +/src/extensions/core/maskeditor.ts @trsommer @brucew4yn3rp +/src/extensions/core/maskEditorLayerFilenames.ts @trsommer @brucew4yn3rp +/src/extensions/core/maskEditorOld.ts @trsommer @brucew4yn3rp + +# 3D +/src/extensions/core/load3d.ts @jtydhr88 +/src/components/load3d/ @jtydhr88 + +# Manager +/src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata + +# Translations +/src/locales/ @Yorha4D @KarryCharon @shinshin86 @Comfy-Org/comfy_maintainer diff --git a/browser_tests/assets/vueNodes/simple-triple.json b/browser_tests/assets/vueNodes/simple-triple.json new file mode 100644 index 000000000..9b665191d --- /dev/null +++ b/browser_tests/assets/vueNodes/simple-triple.json @@ -0,0 +1 @@ +{"id":"4412323e-2509-4258-8abc-68ddeea8f9e1","revision":0,"last_node_id":39,"last_link_id":29,"nodes":[{"id":37,"type":"KSampler","pos":[3635.923095703125,870.237548828125],"size":[428,437],"flags":{},"order":0,"mode":0,"inputs":[{"localized_name":"model","name":"model","type":"MODEL","link":null},{"localized_name":"positive","name":"positive","type":"CONDITIONING","link":null},{"localized_name":"negative","name":"negative","type":"CONDITIONING","link":null},{"localized_name":"latent_image","name":"latent_image","type":"LATENT","link":null},{"localized_name":"seed","name":"seed","type":"INT","widget":{"name":"seed"},"link":null},{"localized_name":"steps","name":"steps","type":"INT","widget":{"name":"steps"},"link":null},{"localized_name":"cfg","name":"cfg","type":"FLOAT","widget":{"name":"cfg"},"link":null},{"localized_name":"sampler_name","name":"sampler_name","type":"COMBO","widget":{"name":"sampler_name"},"link":null},{"localized_name":"scheduler","name":"scheduler","type":"COMBO","widget":{"name":"scheduler"},"link":null},{"localized_name":"denoise","name":"denoise","type":"FLOAT","widget":{"name":"denoise"},"link":null}],"outputs":[{"localized_name":"LATENT","name":"LATENT","type":"LATENT","links":null}],"properties":{"Node name for S&R":"KSampler"},"widgets_values":[0,"randomize",20,8,"euler","simple",1]},{"id":38,"type":"VAEDecode","pos":[4164.01611328125,925.5230712890625],"size":[193.25,107],"flags":{},"order":1,"mode":0,"inputs":[{"localized_name":"samples","name":"samples","type":"LATENT","link":null},{"localized_name":"vae","name":"vae","type":"VAE","link":null}],"outputs":[{"localized_name":"IMAGE","name":"IMAGE","type":"IMAGE","links":null}],"properties":{"Node name for S&R":"VAEDecode"}},{"id":39,"type":"CLIPTextEncode","pos":[3259.289794921875,927.2508544921875],"size":[239.9375,155],"flags":{},"order":2,"mode":0,"inputs":[{"localized_name":"clip","name":"clip","type":"CLIP","link":null},{"localized_name":"text","name":"text","type":"STRING","widget":{"name":"text"},"link":null}],"outputs":[{"localized_name":"CONDITIONING","name":"CONDITIONING","type":"CONDITIONING","links":null}],"properties":{"Node name for S&R":"CLIPTextEncode"},"widgets_values":[""]}],"links":[],"groups":[],"config":{},"extra":{"ds":{"scale":1.1576250000000001,"offset":[-2808.366467322067,-478.34316506594797]}},"version":0.4} \ No newline at end of file diff --git a/browser_tests/helpers/fitToView.ts b/browser_tests/helpers/fitToView.ts new file mode 100644 index 000000000..af6c10e9d --- /dev/null +++ b/browser_tests/helpers/fitToView.ts @@ -0,0 +1,104 @@ +import type { ReadOnlyRect } from '../../src/lib/litegraph/src/interfaces' +import type { ComfyPage } from '../fixtures/ComfyPage' + +interface FitToViewOptions { + selectionOnly?: boolean + zoom?: number + padding?: number +} + +/** + * Instantly fits the canvas view to graph content without waiting for UI animation. + * + * Lives outside the shared fixture to keep the default ComfyPage interactions user-oriented. + */ +export async function fitToViewInstant( + comfyPage: ComfyPage, + options: FitToViewOptions = {} +) { + const { selectionOnly = false, zoom = 0.75, padding = 10 } = options + + const rectangles = await comfyPage.page.evaluate< + ReadOnlyRect[] | null, + { selectionOnly: boolean } + >( + ({ selectionOnly }) => { + const app = window['app'] + if (!app?.canvas) return null + + const canvas = app.canvas + const items = (() => { + if (selectionOnly && canvas.selectedItems?.size) { + return Array.from(canvas.selectedItems) + } + try { + return Array.from(canvas.positionableItems ?? []) + } catch { + return [] + } + })() + + if (!items.length) return null + + const rects: ReadOnlyRect[] = [] + + for (const item of items) { + const rect = item?.boundingRect + if (!rect) continue + + const x = Number(rect[0]) + const y = Number(rect[1]) + const width = Number(rect[2]) + const height = Number(rect[3]) + + rects.push([x, y, width, height] as const) + } + + return rects.length ? rects : null + }, + { selectionOnly } + ) + + if (!rectangles || rectangles.length === 0) return + + let minX = Infinity + let minY = Infinity + let maxX = -Infinity + let maxY = -Infinity + + for (const [x, y, width, height] of rectangles) { + minX = Math.min(minX, Number(x)) + minY = Math.min(minY, Number(y)) + maxX = Math.max(maxX, Number(x) + Number(width)) + maxY = Math.max(maxY, Number(y) + Number(height)) + } + + const hasFiniteBounds = + Number.isFinite(minX) && + Number.isFinite(minY) && + Number.isFinite(maxX) && + Number.isFinite(maxY) + + if (!hasFiniteBounds) return + + const bounds: ReadOnlyRect = [ + minX - padding, + minY - padding, + maxX - minX + 2 * padding, + maxY - minY + 2 * padding + ] + + await comfyPage.page.evaluate( + ({ bounds, zoom }) => { + const app = window['app'] + if (!app?.canvas) return + + const canvas = app.canvas + canvas.ds.fitToBounds(bounds, { zoom }) + canvas.setDirty(true, true) + }, + { bounds, zoom } + ) + + await comfyPage.nextFrame() +} diff --git a/browser_tests/tests/vueNodes/NodeHeader.spec.ts b/browser_tests/tests/vueNodes/NodeHeader.spec.ts index 7a8ae5dd2..336e2672d 100644 --- a/browser_tests/tests/vueNodes/NodeHeader.spec.ts +++ b/browser_tests/tests/vueNodes/NodeHeader.spec.ts @@ -6,7 +6,7 @@ import { VueNodeFixture } from '../../fixtures/utils/vueNodeFixtures' test.describe('NodeHeader', () => { test.beforeEach(async ({ comfyPage }) => { - await comfyPage.setSetting('Comfy.UseNewMenu', 'Enabled') + await comfyPage.setSetting('Comfy.UseNewMenu', 'Top') await comfyPage.setSetting('Comfy.Graph.CanvasMenu', false) await comfyPage.setSetting('Comfy.EnableTooltips', true) await comfyPage.setSetting('Comfy.VueNodes.Enabled', true) diff --git a/browser_tests/tests/vueNodes/linkInteraction.spec.ts b/browser_tests/tests/vueNodes/linkInteraction.spec.ts new file mode 100644 index 000000000..d6b1bccc1 --- /dev/null +++ b/browser_tests/tests/vueNodes/linkInteraction.spec.ts @@ -0,0 +1,221 @@ +import type { Locator } from '@playwright/test' + +import { getSlotKey } from '../../../src/renderer/core/layout/slots/slotIdentifier' +import { + comfyExpect as expect, + comfyPageFixture as test +} from '../../fixtures/ComfyPage' +import { fitToViewInstant } from '../../helpers/fitToView' + +async function getCenter(locator: Locator): Promise<{ x: number; y: number }> { + const box = await locator.boundingBox() + if (!box) throw new Error('Slot bounding box not available') + return { + x: box.x + box.width / 2, + y: box.y + box.height / 2 + } +} + +test.describe('Vue Node Link Interaction', () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.UseNewMenu', 'Top') + await comfyPage.setSetting('Comfy.VueNodes.Enabled', true) + await comfyPage.setup() + await comfyPage.loadWorkflow('vueNodes/simple-triple') + await comfyPage.vueNodes.waitForNodes() + await fitToViewInstant(comfyPage) + }) + + test('should show a link dragging out from a slot when dragging on a slot', async ({ + comfyPage, + comfyMouse + }) => { + const samplerNodes = await comfyPage.getNodeRefsByType('KSampler') + expect(samplerNodes.length).toBeGreaterThan(0) + + const samplerNode = samplerNodes[0] + const outputSlot = await samplerNode.getOutput(0) + await outputSlot.removeLinks() + await comfyPage.nextFrame() + + const slotKey = getSlotKey(String(samplerNode.id), 0, false) + const slotLocator = comfyPage.page.locator(`[data-slot-key="${slotKey}"]`) + await expect(slotLocator).toBeVisible() + + const start = await getCenter(slotLocator) + const canvasBox = await comfyPage.canvas.boundingBox() + if (!canvasBox) throw new Error('Canvas bounding box not available') + + // Arbitrary value + const dragTarget = { + x: start.x + 180, + y: start.y - 140 + } + + await comfyMouse.move(start) + await comfyMouse.drag(dragTarget) + await comfyPage.nextFrame() + + try { + await expect(comfyPage.canvas).toHaveScreenshot( + 'vue-node-dragging-link.png' + ) + } finally { + await comfyMouse.drop() + } + }) + + test('should create a link when dropping on a compatible slot', async ({ + comfyPage + }) => { + const samplerNodes = await comfyPage.getNodeRefsByType('KSampler') + expect(samplerNodes.length).toBeGreaterThan(0) + const samplerNode = samplerNodes[0] + + const vaeNodes = await comfyPage.getNodeRefsByType('VAEDecode') + expect(vaeNodes.length).toBeGreaterThan(0) + const vaeNode = vaeNodes[0] + + const samplerOutput = await samplerNode.getOutput(0) + const vaeInput = await vaeNode.getInput(0) + + const outputSlotKey = getSlotKey(String(samplerNode.id), 0, false) + const inputSlotKey = getSlotKey(String(vaeNode.id), 0, true) + + const outputSlot = comfyPage.page.locator( + `[data-slot-key="${outputSlotKey}"]` + ) + const inputSlot = comfyPage.page.locator( + `[data-slot-key="${inputSlotKey}"]` + ) + + await expect(outputSlot).toBeVisible() + await expect(inputSlot).toBeVisible() + + await outputSlot.dragTo(inputSlot) + await comfyPage.nextFrame() + + expect(await samplerOutput.getLinkCount()).toBe(1) + expect(await vaeInput.getLinkCount()).toBe(1) + + const linkDetails = await comfyPage.page.evaluate((sourceId) => { + const app = window['app'] + const graph = app?.canvas?.graph ?? app?.graph + if (!graph) return null + + const source = graph.getNodeById(sourceId) + if (!source) return null + + const linkId = source.outputs[0]?.links?.[0] + if (linkId == null) return null + + const link = graph.links[linkId] + if (!link) return null + + return { + originId: link.origin_id, + originSlot: link.origin_slot, + targetId: link.target_id, + targetSlot: link.target_slot + } + }, samplerNode.id) + + expect(linkDetails).not.toBeNull() + expect(linkDetails).toMatchObject({ + originId: samplerNode.id, + originSlot: 0, + targetId: vaeNode.id, + targetSlot: 0 + }) + }) + + test('should not create a link when slot types are incompatible', async ({ + comfyPage + }) => { + const samplerNodes = await comfyPage.getNodeRefsByType('KSampler') + expect(samplerNodes.length).toBeGreaterThan(0) + const samplerNode = samplerNodes[0] + + const clipNodes = await comfyPage.getNodeRefsByType('CLIPTextEncode') + expect(clipNodes.length).toBeGreaterThan(0) + const clipNode = clipNodes[0] + + const samplerOutput = await samplerNode.getOutput(0) + const clipInput = await clipNode.getInput(0) + + const outputSlotKey = getSlotKey(String(samplerNode.id), 0, false) + const inputSlotKey = getSlotKey(String(clipNode.id), 0, true) + + const outputSlot = comfyPage.page.locator( + `[data-slot-key="${outputSlotKey}"]` + ) + const inputSlot = comfyPage.page.locator( + `[data-slot-key="${inputSlotKey}"]` + ) + + await expect(outputSlot).toBeVisible() + await expect(inputSlot).toBeVisible() + + await outputSlot.dragTo(inputSlot) + await comfyPage.nextFrame() + + expect(await samplerOutput.getLinkCount()).toBe(0) + expect(await clipInput.getLinkCount()).toBe(0) + + const graphLinkCount = await comfyPage.page.evaluate((sourceId) => { + const app = window['app'] + const graph = app?.canvas?.graph ?? app?.graph + if (!graph) return 0 + + const source = graph.getNodeById(sourceId) + if (!source) return 0 + + return source.outputs[0]?.links?.length ?? 0 + }, samplerNode.id) + + expect(graphLinkCount).toBe(0) + }) + + test('should not create a link when dropping onto a slot on the same node', async ({ + comfyPage + }) => { + const samplerNodes = await comfyPage.getNodeRefsByType('KSampler') + expect(samplerNodes.length).toBeGreaterThan(0) + const samplerNode = samplerNodes[0] + + const samplerOutput = await samplerNode.getOutput(0) + const samplerInput = await samplerNode.getInput(3) + + const outputSlotKey = getSlotKey(String(samplerNode.id), 0, false) + const inputSlotKey = getSlotKey(String(samplerNode.id), 3, true) + + const outputSlot = comfyPage.page.locator( + `[data-slot-key="${outputSlotKey}"]` + ) + const inputSlot = comfyPage.page.locator( + `[data-slot-key="${inputSlotKey}"]` + ) + + await expect(outputSlot).toBeVisible() + await expect(inputSlot).toBeVisible() + + await outputSlot.dragTo(inputSlot) + await comfyPage.nextFrame() + + expect(await samplerOutput.getLinkCount()).toBe(0) + expect(await samplerInput.getLinkCount()).toBe(0) + + const graphLinkCount = await comfyPage.page.evaluate((sourceId) => { + const app = window['app'] + const graph = app?.canvas?.graph ?? app?.graph + if (!graph) return 0 + + const source = graph.getNodeById(sourceId) + if (!source) return 0 + + return source.outputs[0]?.links?.length ?? 0 + }, samplerNode.id) + + expect(graphLinkCount).toBe(0) + }) +}) diff --git a/browser_tests/tests/vueNodes/linkInteraction.spec.ts-snapshots/vue-node-dragging-link-chromium-linux.png b/browser_tests/tests/vueNodes/linkInteraction.spec.ts-snapshots/vue-node-dragging-link-chromium-linux.png new file mode 100644 index 000000000..d4c32b4ea Binary files /dev/null and b/browser_tests/tests/vueNodes/linkInteraction.spec.ts-snapshots/vue-node-dragging-link-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/nodeInteractions/selectionState.spec.ts b/browser_tests/tests/vueNodes/nodeInteractions/selectionState.spec.ts new file mode 100644 index 000000000..ff8b6f951 --- /dev/null +++ b/browser_tests/tests/vueNodes/nodeInteractions/selectionState.spec.ts @@ -0,0 +1,47 @@ +import { + comfyExpect as expect, + comfyPageFixture as test +} from '../../../fixtures/ComfyPage' + +test.describe('Vue Node Selection', () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.VueNodes.Enabled', true) + await comfyPage.vueNodes.waitForNodes() + }) + + const modifiers = [ + { key: 'Control', name: 'ctrl' }, + { key: 'Shift', name: 'shift' } + ] as const + + for (const { key: modifier, name } of modifiers) { + test(`should allow selecting multiple nodes with ${name}+click`, async ({ + comfyPage + }) => { + await comfyPage.page.getByText('Load Checkpoint').click() + expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(1) + + await comfyPage.page.getByText('Empty Latent Image').click({ + modifiers: [modifier] + }) + expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(2) + + await comfyPage.page.getByText('KSampler').click({ + modifiers: [modifier] + }) + expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(3) + }) + + test(`should allow de-selecting nodes with ${name}+click`, async ({ + comfyPage + }) => { + await comfyPage.page.getByText('Load Checkpoint').click() + expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(1) + + await comfyPage.page.getByText('Load Checkpoint').click({ + modifiers: [modifier] + }) + expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(0) + }) + } +}) diff --git a/browser_tests/tests/vueNodes/nodeStates/bypass.spec.ts b/browser_tests/tests/vueNodes/nodeStates/bypass.spec.ts new file mode 100644 index 000000000..c80a86503 --- /dev/null +++ b/browser_tests/tests/vueNodes/nodeStates/bypass.spec.ts @@ -0,0 +1,49 @@ +import { + comfyExpect as expect, + comfyPageFixture as test +} from '../../../fixtures/ComfyPage' + +const BYPASS_HOTKEY = 'Control+b' +const BYPASS_CLASS = /before:bg-bypass\/60/ + +test.describe('Vue Node Bypass', () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.VueNodes.Enabled', true) + await comfyPage.vueNodes.waitForNodes() + }) + + test('should allow toggling bypass on a selected node with hotkey', async ({ + comfyPage + }) => { + const checkpointNode = comfyPage.page.locator('[data-node-id]').filter({ + hasText: 'Load Checkpoint' + }) + await checkpointNode.getByText('Load Checkpoint').click() + await comfyPage.page.keyboard.press(BYPASS_HOTKEY) + await expect(checkpointNode).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 + }) => { + const checkpointNode = comfyPage.page.locator('[data-node-id]').filter({ + hasText: 'Load Checkpoint' + }) + const ksamplerNode = comfyPage.page.locator('[data-node-id]').filter({ + hasText: 'KSampler' + }) + + await checkpointNode.getByText('Load Checkpoint').click() + await ksamplerNode.getByText('KSampler').click({ modifiers: ['Control'] }) + await comfyPage.page.keyboard.press(BYPASS_HOTKEY) + await expect(checkpointNode).toHaveClass(BYPASS_CLASS) + await expect(ksamplerNode).toHaveClass(BYPASS_CLASS) + + await comfyPage.page.keyboard.press(BYPASS_HOTKEY) + await expect(checkpointNode).not.toHaveClass(BYPASS_CLASS) + await expect(ksamplerNode).not.toHaveClass(BYPASS_CLASS) + }) +}) diff --git a/build/plugins/comfyAPIPlugin.ts b/build/plugins/comfyAPIPlugin.ts index 3f795a219..5b7f4bec4 100644 --- a/build/plugins/comfyAPIPlugin.ts +++ b/build/plugins/comfyAPIPlugin.ts @@ -1,5 +1,5 @@ import path from 'path' -import { Plugin } from 'vite' +import type { Plugin } from 'vite' interface ShimResult { code: string diff --git a/build/plugins/generateImportMapPlugin.ts b/build/plugins/generateImportMapPlugin.ts index 80ccb6c9f..bbbf14c2c 100644 --- a/build/plugins/generateImportMapPlugin.ts +++ b/build/plugins/generateImportMapPlugin.ts @@ -1,7 +1,7 @@ import glob from 'fast-glob' import fs from 'fs-extra' import { dirname, join } from 'node:path' -import { HtmlTagDescriptor, Plugin, normalizePath } from 'vite' +import { type HtmlTagDescriptor, type Plugin, normalizePath } from 'vite' interface ImportMapSource { name: string diff --git a/eslint.config.ts b/eslint.config.ts index 94f8bb5f2..04f4b2578 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -77,12 +77,25 @@ export default defineConfig([ '@typescript-eslint/prefer-as-const': 'off', '@typescript-eslint/consistent-type-imports': 'error', '@typescript-eslint/no-import-type-side-effects': 'error', + '@typescript-eslint/no-empty-object-type': [ + 'error', + { + allowInterfaces: 'always' + } + ], 'unused-imports/no-unused-imports': 'error', 'vue/no-v-html': 'off', // Enforce dark-theme: instead of dark: prefix 'vue/no-restricted-class': ['error', '/^dark:/'], 'vue/multi-word-component-names': 'off', // TODO: fix 'vue/no-template-shadow': 'off', // TODO: fix + /* Toggle on to do additional until we can clean up existing violations. + 'vue/no-unused-emit-declarations': 'error', + 'vue/no-unused-properties': 'error', + 'vue/no-unused-refs': 'error', + 'vue/no-use-v-else-with-v-for': 'error', + 'vue/no-useless-v-bind': 'error', + // */ 'vue/one-component-per-file': 'off', // TODO: fix 'vue/require-default-prop': 'off', // TODO: fix -- this one is very worthwhile // Restrict deprecated PrimeVue components diff --git a/index.html b/index.html index de7710c63..8684af476 100644 --- a/index.html +++ b/index.html @@ -8,8 +8,8 @@ - - + + diff --git a/knip.config.ts b/knip.config.ts index 81911a736..0dcbf7d50 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -22,7 +22,7 @@ const config: KnipConfig = { ], ignore: [ // Auto generated manager types - 'src/types/generatedManagerTypes.ts', + 'src/workbench/extensions/manager/types/generatedManagerTypes.ts', 'src/types/comfyRegistryTypes.ts', // Used by a custom node (that should move off of this) 'src/scripts/ui/components/splitButton.ts', diff --git a/package.json b/package.json index 770ef7e04..923f04b7e 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,8 @@ "preview": "nx preview", "lint": "eslint src --cache", "lint:fix": "eslint src --cache --fix", + "lint:unstaged": "git diff --name-only HEAD | grep -E '\\.(js|ts|vue|mts)$' | xargs -r eslint --cache", + "lint:unstaged:fix": "git diff --name-only HEAD | grep -E '\\.(js|ts|vue|mts)$' | xargs -r eslint --cache --fix", "lint:no-cache": "eslint src", "lint:fix:no-cache": "eslint src --fix", "knip": "knip --cache", @@ -94,6 +96,7 @@ "vite-plugin-html": "^3.2.2", "vite-plugin-vue-devtools": "^7.7.6", "vitest": "^3.2.4", + "vue-component-type-helpers": "^3.0.7", "vue-eslint-parser": "^10.2.0", "vue-tsc": "^3.0.7", "zip-dir": "^2.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 75fc42327..6ce1f4d34 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -339,6 +339,9 @@ importers: vitest: specifier: ^3.2.4 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: ^3.0.7 + version: 3.0.7 vue-eslint-parser: specifier: ^10.2.0 version: 10.2.0(eslint@9.35.0(jiti@2.4.2)) diff --git a/src/base/common/async.ts b/src/base/common/async.ts new file mode 100644 index 000000000..a97f6f1bd --- /dev/null +++ b/src/base/common/async.ts @@ -0,0 +1,98 @@ +/** + * Cross-browser async utilities for scheduling tasks during browser idle time + * with proper fallbacks for browsers that don't support requestIdleCallback. + * + * Implementation based on: + * https://github.com/microsoft/vscode/blob/main/src/vs/base/common/async.ts + */ + +interface IdleDeadline { + didTimeout: boolean + timeRemaining(): number +} + +interface IDisposable { + dispose(): void +} + +/** + * Internal implementation function that handles the actual scheduling logic. + * Uses feature detection to determine whether to use native requestIdleCallback + * or fall back to setTimeout-based implementation. + */ +let _runWhenIdle: ( + targetWindow: any, + callback: (idle: IdleDeadline) => void, + timeout?: number +) => IDisposable + +/** + * Execute the callback during the next browser idle period. + * Falls back to setTimeout-based scheduling in browsers without native support. + */ +export let runWhenGlobalIdle: ( + callback: (idle: IdleDeadline) => void, + timeout?: number + ) => IDisposable + + // Self-invoking function to set up the idle callback implementation +;(function () { + const safeGlobal: any = globalThis + + if ( + typeof safeGlobal.requestIdleCallback !== 'function' || + typeof safeGlobal.cancelIdleCallback !== 'function' + ) { + // Fallback implementation for browsers without native support (e.g., Safari) + _runWhenIdle = (_targetWindow, runner, _timeout?) => { + setTimeout(() => { + if (disposed) { + return + } + + // Simulate IdleDeadline - give 15ms window (one frame at ~64fps) + const end = Date.now() + 15 + const deadline: IdleDeadline = { + didTimeout: true, + timeRemaining() { + return Math.max(0, end - Date.now()) + } + } + + runner(Object.freeze(deadline)) + }) + + let disposed = false + return { + dispose() { + if (disposed) { + return + } + disposed = true + } + } + } + } else { + // Native requestIdleCallback implementation + _runWhenIdle = (targetWindow: typeof safeGlobal, runner, timeout?) => { + const handle: number = targetWindow.requestIdleCallback( + runner, + typeof timeout === 'number' ? { timeout } : undefined + ) + + let disposed = false + return { + dispose() { + if (disposed) { + return + } + disposed = true + targetWindow.cancelIdleCallback(handle) + } + } + } + } + + runWhenGlobalIdle = (runner, timeout) => + _runWhenIdle(globalThis, runner, timeout) +})() diff --git a/src/components/dialog/content/LoadWorkflowWarning.vue b/src/components/dialog/content/LoadWorkflowWarning.vue index 1adcc2fe9..a89c94981 100644 --- a/src/components/dialog/content/LoadWorkflowWarning.vue +++ b/src/components/dialog/content/LoadWorkflowWarning.vue @@ -59,14 +59,13 @@ import { useI18n } from 'vue-i18n' import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue' import MissingCoreNodesMessage from '@/components/dialog/content/MissingCoreNodesMessage.vue' import { useMissingNodes } from '@/composables/nodePack/useMissingNodes' -import { useManagerState } from '@/composables/useManagerState' import { useToastStore } from '@/platform/updates/common/toastStore' -import { useComfyManagerStore } from '@/stores/comfyManagerStore' import { useDialogStore } from '@/stores/dialogStore' import type { MissingNodeType } from '@/types/comfy' -import { ManagerTab } from '@/types/comfyManagerTypes' - -import PackInstallButton from './manager/button/PackInstallButton.vue' +import PackInstallButton from '@/workbench/extensions/manager/components/manager/button/PackInstallButton.vue' +import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState' +import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore' +import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes' const props = defineProps<{ missingNodeTypes: MissingNodeType[] diff --git a/src/components/dialog/content/MissingCoreNodesMessage.vue b/src/components/dialog/content/MissingCoreNodesMessage.vue index cf81441f1..10030a9e9 100644 --- a/src/components/dialog/content/MissingCoreNodesMessage.vue +++ b/src/components/dialog/content/MissingCoreNodesMessage.vue @@ -43,11 +43,11 @@ diff --git a/src/components/graph/GraphCanvas.vue b/src/components/graph/GraphCanvas.vue index 3abf47813..73020bb2e 100644 --- a/src/components/graph/GraphCanvas.vue +++ b/src/components/graph/GraphCanvas.vue @@ -33,7 +33,7 @@ @@ -76,9 +73,9 @@ import { useEventListener, whenever } from '@vueuse/core' import { computed, + nextTick, onMounted, onUnmounted, - provide, ref, shallowRef, watch, @@ -116,14 +113,11 @@ import { useWorkflowStore } from '@/platform/workflow/management/stores/workflow import { useWorkflowAutoSave } from '@/platform/workflow/persistence/composables/useWorkflowAutoSave' import { useWorkflowPersistence } from '@/platform/workflow/persistence/composables/useWorkflowPersistence' import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' -import { SelectedNodeIdsKey } from '@/renderer/core/canvas/injectionKeys' import { attachSlotLinkPreviewRenderer } from '@/renderer/core/canvas/links/slotLinkPreviewRenderer' import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions' import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue' import MiniMap from '@/renderer/extensions/minimap/MiniMap.vue' import VueGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue' -import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers' -import { useExecutionStateProvider } from '@/renderer/extensions/vueNodes/execution/useExecutionStateProvider' import { UnauthorizedError, api } from '@/scripts/api' import { app as comfyApp } from '@/scripts/app' import { ChangeTracker } from '@/scripts/changeTracker' @@ -170,17 +164,30 @@ const minimapEnabled = computed(() => settingStore.get('Comfy.Minimap.Visible')) // Feature flags const { shouldRenderVueNodes } = useVueFeatureFlags() -const isVueNodesEnabled = computed(() => shouldRenderVueNodes.value) // Vue node system -const vueNodeLifecycle = useVueNodeLifecycle(isVueNodesEnabled) -const viewportCulling = useViewportCulling( - isVueNodesEnabled, - vueNodeLifecycle.vueNodeData, - vueNodeLifecycle.nodeDataTrigger, - vueNodeLifecycle.nodeManager +const vueNodeLifecycle = useVueNodeLifecycle() +const viewportCulling = useViewportCulling() + +const handleVueNodeLifecycleReset = async () => { + if (shouldRenderVueNodes.value) { + vueNodeLifecycle.disposeNodeManagerAndSyncs() + await nextTick() + vueNodeLifecycle.initializeNodeManager() + } +} + +watch(() => canvasStore.currentGraph, handleVueNodeLifecycleReset) + +watch( + () => canvasStore.isInSubgraph, + async (newValue, oldValue) => { + if (oldValue && !newValue) { + useWorkflowStore().updateActiveGraph() + } + await handleVueNodeLifecycleReset() + } ) -const nodeEventHandlers = useNodeEventHandlers(vueNodeLifecycle.nodeManager) const nodePositions = vueNodeLifecycle.nodePositions const nodeSizes = vueNodeLifecycle.nodeSizes @@ -191,23 +198,6 @@ const handleTransformUpdate = () => { // TODO: Fix paste position sync in separate PR vueNodeLifecycle.detectChangesInRAF.value() } -const handleNodeSelect = nodeEventHandlers.handleNodeSelect -const handleNodeCollapse = nodeEventHandlers.handleNodeCollapse -const handleNodeTitleUpdate = nodeEventHandlers.handleNodeTitleUpdate - -// Provide selection state to all Vue nodes -const selectedNodeIds = computed( - () => - new Set( - canvasStore.selectedItems - .filter((item) => item.id !== undefined) - .map((item) => String(item.id)) - ) -) -provide(SelectedNodeIdsKey, selectedNodeIds) - -// Provide execution state to all Vue nodes -useExecutionStateProvider() watchEffect(() => { nodeDefStore.showDeprecated = settingStore.get('Comfy.Node.ShowDeprecated') diff --git a/src/components/graph/NodeTooltip.vue b/src/components/graph/NodeTooltip.vue index f16e4ea3e..6419326d6 100644 --- a/src/components/graph/NodeTooltip.vue +++ b/src/components/graph/NodeTooltip.vue @@ -68,7 +68,7 @@ const onIdle = () => { ctor.title_mode !== LiteGraph.NO_TITLE && canvas.graph_mouse[1] < node.pos[1] // If we are over a node, but not within the node then we are on its title ) { - return showTooltip(nodeDef.description) + return showTooltip(nodeDef?.description) } if (node.flags?.collapsed) return @@ -83,7 +83,7 @@ const onIdle = () => { const inputName = node.inputs[inputSlot].name const translatedTooltip = st( `nodeDefs.${normalizeI18nKey(node.type ?? '')}.inputs.${normalizeI18nKey(inputName)}.tooltip`, - nodeDef.inputs[inputName]?.tooltip ?? '' + nodeDef?.inputs[inputName]?.tooltip ?? '' ) return showTooltip(translatedTooltip) } @@ -97,7 +97,7 @@ const onIdle = () => { if (outputSlot !== -1) { const translatedTooltip = st( `nodeDefs.${normalizeI18nKey(node.type ?? '')}.outputs.${outputSlot}.tooltip`, - nodeDef.outputs[outputSlot]?.tooltip ?? '' + nodeDef?.outputs[outputSlot]?.tooltip ?? '' ) return showTooltip(translatedTooltip) } @@ -107,7 +107,7 @@ const onIdle = () => { if (widget && !isDOMWidget(widget)) { const translatedTooltip = st( `nodeDefs.${normalizeI18nKey(node.type ?? '')}.inputs.${normalizeI18nKey(widget.name)}.tooltip`, - nodeDef.inputs[widget.name]?.tooltip ?? '' + nodeDef?.inputs[widget.name]?.tooltip ?? '' ) // Widget tooltip can be set dynamically, current translation collection does not support this. return showTooltip(widget.tooltip ?? translatedTooltip) diff --git a/src/components/graph/SelectionToolbox.vue b/src/components/graph/SelectionToolbox.vue index 067b04346..f0a18ed3e 100644 --- a/src/components/graph/SelectionToolbox.vue +++ b/src/components/graph/SelectionToolbox.vue @@ -11,7 +11,7 @@ :style="`backgroundColor: ${containerStyles.backgroundColor};`" :pt="{ header: 'hidden', - content: 'px-1 py-1 h-10 px-1 flex flex-row gap-1' + content: 'p-1 h-10 flex flex-row gap-1' }" @wheel="canvasInteractions.handleWheel" > diff --git a/src/components/helpcenter/HelpCenterMenuContent.vue b/src/components/helpcenter/HelpCenterMenuContent.vue index 5885d56d1..aef8ff751 100644 --- a/src/components/helpcenter/HelpCenterMenuContent.vue +++ b/src/components/helpcenter/HelpCenterMenuContent.vue @@ -142,14 +142,14 @@ import { useI18n } from 'vue-i18n' import PuzzleIcon from '@/components/icons/PuzzleIcon.vue' import { useConflictAcknowledgment } from '@/composables/useConflictAcknowledgment' -import { useManagerState } from '@/composables/useManagerState' import { useSettingStore } from '@/platform/settings/settingStore' import type { ReleaseNote } from '@/platform/updates/common/releaseService' import { useReleaseStore } from '@/platform/updates/common/releaseStore' import { useCommandStore } from '@/stores/commandStore' -import { ManagerTab } from '@/types/comfyManagerTypes' import { electronAPI, isElectron } from '@/utils/envUtil' import { formatVersionAnchor } from '@/utils/formatUtil' +import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState' +import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes' // Types interface MenuItem { diff --git a/src/components/topbar/CommandMenubar.vue b/src/components/topbar/CommandMenubar.vue index 9ab70f3a0..e9f33f812 100644 --- a/src/components/topbar/CommandMenubar.vue +++ b/src/components/topbar/CommandMenubar.vue @@ -82,7 +82,6 @@ import { useI18n } from 'vue-i18n' import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue' import SettingDialogHeader from '@/components/dialog/header/SettingDialogHeader.vue' -import { useManagerState } from '@/composables/useManagerState' import SettingDialogContent from '@/platform/settings/components/SettingDialogContent.vue' import { useSettingStore } from '@/platform/settings/settingStore' import { useColorPaletteService } from '@/services/colorPaletteService' @@ -90,10 +89,11 @@ import { useCommandStore } from '@/stores/commandStore' import { useDialogStore } from '@/stores/dialogStore' import { useMenuItemStore } from '@/stores/menuItemStore' import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore' -import { ManagerTab } from '@/types/comfyManagerTypes' import { showNativeSystemMenu } from '@/utils/envUtil' import { normalizeI18nKey } from '@/utils/formatUtil' import { whileMouseDown } from '@/utils/mouseDownUtil' +import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState' +import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes' const colorPaletteStore = useColorPaletteStore() const colorPaletteService = useColorPaletteService() diff --git a/src/composables/auth/useCurrentUser.ts b/src/composables/auth/useCurrentUser.ts index cf89c1069..37b9e4866 100644 --- a/src/composables/auth/useCurrentUser.ts +++ b/src/composables/auth/useCurrentUser.ts @@ -34,14 +34,8 @@ export const useCurrentUser = () => { return null }) - const onUserResolved = (callback: (user: AuthUserInfo) => void) => { - if (resolvedUserInfo.value) { - callback(resolvedUserInfo.value) - } - - const stop = whenever(resolvedUserInfo, callback) - return () => stop() - } + const onUserResolved = (callback: (user: AuthUserInfo) => void) => + whenever(resolvedUserInfo, callback, { immediate: true }) const userDisplayName = computed(() => { if (isApiKeyLogin.value) { diff --git a/src/composables/graph/useGraphNodeManager.ts b/src/composables/graph/useGraphNodeManager.ts index ae430987a..618b3087a 100644 --- a/src/composables/graph/useGraphNodeManager.ts +++ b/src/composables/graph/useGraphNodeManager.ts @@ -68,7 +68,7 @@ interface SpatialMetrics { nodesInIndex: number } -interface GraphNodeManager { +export interface GraphNodeManager { // Reactive state - safe data extracted from LiteGraph nodes vueNodeData: ReadonlyMap nodeState: ReadonlyMap diff --git a/src/composables/graph/useViewportCulling.ts b/src/composables/graph/useViewportCulling.ts index 6fc835e7e..f311af01c 100644 --- a/src/composables/graph/useViewportCulling.ts +++ b/src/composables/graph/useViewportCulling.ts @@ -6,26 +6,20 @@ * 2. Set display none on element to avoid cascade resolution overhead * 3. Only run when transform changes (event driven) */ -import { type Ref, computed } from 'vue' +import { computed } from 'vue' -import type { VueNodeData } from '@/composables/graph/useGraphNodeManager' +import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle' +import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags' import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import { app as comfyApp } from '@/scripts/app' -interface NodeManager { - getNode: (id: string) => any -} - -export function useViewportCulling( - isVueNodesEnabled: Ref, - vueNodeData: Ref>, - nodeDataTrigger: Ref, - nodeManager: Ref -) { +export function useViewportCulling() { const canvasStore = useCanvasStore() + const { shouldRenderVueNodes } = useVueFeatureFlags() + const { vueNodeData, nodeDataTrigger, nodeManager } = useVueNodeLifecycle() const allNodes = computed(() => { - if (!isVueNodesEnabled.value) return [] + if (!shouldRenderVueNodes.value) return [] void nodeDataTrigger.value // Force re-evaluation when nodeManager initializes return Array.from(vueNodeData.value.values()) }) @@ -84,7 +78,7 @@ export function useViewportCulling( * Uses RAF to batch updates for smooth performance */ const handleTransformUpdate = () => { - if (!isVueNodesEnabled.value) return + if (!shouldRenderVueNodes.value) return // Cancel previous RAF if still pending if (rafId !== null) { diff --git a/src/composables/graph/useVueNodeLifecycle.ts b/src/composables/graph/useVueNodeLifecycle.ts index 54296b900..d2c1bcfcd 100644 --- a/src/composables/graph/useVueNodeLifecycle.ts +++ b/src/composables/graph/useVueNodeLifecycle.ts @@ -8,13 +8,16 @@ * - Reactive state management for node data, positions, and sizes * - Memory management and proper cleanup */ -import { type Ref, computed, readonly, ref, shallowRef, watch } from 'vue' +import { createSharedComposable } from '@vueuse/core' +import { computed, readonly, ref, shallowRef, watch } from 'vue' import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager' import type { + GraphNodeManager, NodeState, VueNodeData } from '@/composables/graph/useGraphNodeManager' +import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags' import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph' import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations' @@ -24,13 +27,12 @@ import { useLinkLayoutSync } from '@/renderer/core/layout/sync/useLinkLayoutSync import { useSlotLayoutSync } from '@/renderer/core/layout/sync/useSlotLayoutSync' import { app as comfyApp } from '@/scripts/app' -export function useVueNodeLifecycle(isVueNodesEnabled: Ref) { +function useVueNodeLifecycleIndividual() { const canvasStore = useCanvasStore() const layoutMutations = useLayoutMutations() + const { shouldRenderVueNodes } = useVueFeatureFlags() - const nodeManager = shallowRef | null>( - null - ) + const nodeManager = shallowRef(null) const cleanupNodeManager = shallowRef<(() => void) | null>(null) // Sync management @@ -57,10 +59,12 @@ export function useVueNodeLifecycle(isVueNodesEnabled: Ref) { const isNodeManagerReady = computed(() => nodeManager.value !== null) const initializeNodeManager = () => { - if (!comfyApp.graph || nodeManager.value) return + // Use canvas graph if available (handles subgraph contexts), fallback to app graph + const activeGraph = comfyApp.canvas?.graph || comfyApp.graph + if (!activeGraph || nodeManager.value) return // Initialize the core node manager - const manager = useGraphNodeManager(comfyApp.graph) + const manager = useGraphNodeManager(activeGraph) nodeManager.value = manager cleanupNodeManager.value = manager.cleanup @@ -71,8 +75,8 @@ export function useVueNodeLifecycle(isVueNodesEnabled: Ref) { nodeSizes.value = manager.nodeSizes detectChangesInRAF.value = manager.detectChangesInRAF - // Initialize layout system with existing nodes - const nodes = comfyApp.graph._nodes.map((node: LGraphNode) => ({ + // Initialize layout system with existing nodes from active graph + 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] @@ -80,7 +84,7 @@ export function useVueNodeLifecycle(isVueNodesEnabled: Ref) { layoutStore.initializeFromLiteGraph(nodes) // Seed reroutes into the Layout Store so hit-testing uses the new path - for (const reroute of comfyApp.graph.reroutes.values()) { + for (const reroute of activeGraph.reroutes.values()) { const [x, y] = reroute.pos const parent = reroute.parentId ?? undefined const linkIds = Array.from(reroute.linkIds) @@ -88,7 +92,7 @@ export function useVueNodeLifecycle(isVueNodesEnabled: Ref) { } // Seed existing links into the Layout Store (topology only) - for (const link of comfyApp.graph._links.values()) { + for (const link of activeGraph._links.values()) { layoutMutations.createLink( link.id, link.origin_id, @@ -142,7 +146,9 @@ export function useVueNodeLifecycle(isVueNodesEnabled: Ref) { // Watch for Vue nodes enabled state changes watch( - () => isVueNodesEnabled.value && Boolean(comfyApp.graph), + () => + shouldRenderVueNodes.value && + Boolean(comfyApp.canvas?.graph || comfyApp.graph), (enabled) => { if (enabled) { initializeNodeManager() @@ -155,7 +161,7 @@ export function useVueNodeLifecycle(isVueNodesEnabled: Ref) { // Consolidated watch for slot layout sync management watch( - [() => canvasStore.canvas, () => isVueNodesEnabled.value], + [() => canvasStore.canvas, () => shouldRenderVueNodes.value], ([canvas, vueMode], [, oldVueMode]) => { const modeChanged = vueMode !== oldVueMode @@ -187,7 +193,7 @@ export function useVueNodeLifecycle(isVueNodesEnabled: Ref) { // Handle case where Vue nodes are enabled but graph starts empty const setupEmptyGraphListener = () => { if ( - isVueNodesEnabled.value && + shouldRenderVueNodes.value && comfyApp.graph && !nodeManager.value && comfyApp.graph._nodes.length === 0 @@ -198,7 +204,7 @@ export function useVueNodeLifecycle(isVueNodesEnabled: Ref) { comfyApp.graph.onNodeAdded = originalOnNodeAdded // Initialize node manager if needed - if (isVueNodesEnabled.value && !nodeManager.value) { + if (shouldRenderVueNodes.value && !nodeManager.value) { initializeNodeManager() } @@ -244,3 +250,7 @@ export function useVueNodeLifecycle(isVueNodesEnabled: Ref) { cleanup } } + +export const useVueNodeLifecycle = createSharedComposable( + useVueNodeLifecycleIndividual +) diff --git a/src/composables/node/useNodePricing.ts b/src/composables/node/useNodePricing.ts index e85e6adb6..91f957463 100644 --- a/src/composables/node/useNodePricing.ts +++ b/src/composables/node/useNodePricing.ts @@ -1548,6 +1548,71 @@ const apiNodeCosts: Record = }, ByteDanceImageReferenceNode: { displayPrice: byteDanceVideoPricingCalculator + }, + WanTextToVideoApi: { + displayPrice: (node: LGraphNode): string => { + const durationWidget = node.widgets?.find( + (w) => w.name === 'duration' + ) as IComboWidget + const resolutionWidget = node.widgets?.find( + (w) => w.name === 'size' + ) as IComboWidget + + if (!durationWidget || !resolutionWidget) return '$0.05-0.15/second' + + const seconds = parseFloat(String(durationWidget.value)) + const resolutionStr = String(resolutionWidget.value).toLowerCase() + + const resKey = resolutionStr.includes('1080') + ? '1080p' + : resolutionStr.includes('720') + ? '720p' + : resolutionStr.includes('480') + ? '480p' + : resolutionStr.match(/^\s*(\d{3,4}p)/)?.[1] ?? '' + + const pricePerSecond: Record = { + '480p': 0.05, + '720p': 0.1, + '1080p': 0.15 + } + + const pps = pricePerSecond[resKey] + if (isNaN(seconds) || !pps) return '$0.05-0.15/second' + + const cost = (pps * seconds).toFixed(2) + return `$${cost}/Run` + } + }, + WanImageToVideoApi: { + displayPrice: (node: LGraphNode): string => { + const durationWidget = node.widgets?.find( + (w) => w.name === 'duration' + ) as IComboWidget + const resolutionWidget = node.widgets?.find( + (w) => w.name === 'resolution' + ) as IComboWidget + + if (!durationWidget || !resolutionWidget) return '$0.05-0.15/second' + + const seconds = parseFloat(String(durationWidget.value)) + const resolution = String(resolutionWidget.value).trim().toLowerCase() + + const pricePerSecond: Record = { + '480p': 0.05, + '720p': 0.1, + '1080p': 0.15 + } + + const pps = pricePerSecond[resolution] + if (isNaN(seconds) || !pps) return '$0.05-0.15/second' + + const cost = (pps * seconds).toFixed(2) + return `$${cost}/Run` + } + }, + WanTextToImageApi: { + displayPrice: '$0.03/Run' } } @@ -1647,7 +1712,9 @@ export const useNodePricing = () => { ByteDanceTextToVideoNode: ['model', 'duration', 'resolution'], ByteDanceImageToVideoNode: ['model', 'duration', 'resolution'], ByteDanceFirstLastFrameNode: ['model', 'duration', 'resolution'], - ByteDanceImageReferenceNode: ['model', 'duration', 'resolution'] + ByteDanceImageReferenceNode: ['model', 'duration', 'resolution'], + WanTextToVideoApi: ['duration', 'size'], + WanImageToVideoApi: ['duration', 'resolution'] } return widgetMap[nodeType] || [] } diff --git a/src/composables/nodePack/useInstalledPacks.ts b/src/composables/nodePack/useInstalledPacks.ts index 147c5ca70..5d2f5b88d 100644 --- a/src/composables/nodePack/useInstalledPacks.ts +++ b/src/composables/nodePack/useInstalledPacks.ts @@ -2,9 +2,9 @@ import { whenever } from '@vueuse/core' import { computed, onUnmounted, ref } from 'vue' import { useNodePacks } from '@/composables/nodePack/useNodePacks' -import { useComfyManagerStore } from '@/stores/comfyManagerStore' -import type { UseNodePacksOptions } from '@/types/comfyManagerTypes' import type { components } from '@/types/comfyRegistryTypes' +import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore' +import type { UseNodePacksOptions } from '@/workbench/extensions/manager/types/comfyManagerTypes' export const useInstalledPacks = (options: UseNodePacksOptions = {}) => { const comfyManagerStore = useComfyManagerStore() diff --git a/src/composables/nodePack/useMissingNodes.ts b/src/composables/nodePack/useMissingNodes.ts index cd2c25abf..4a17a5e42 100644 --- a/src/composables/nodePack/useMissingNodes.ts +++ b/src/composables/nodePack/useMissingNodes.ts @@ -5,10 +5,10 @@ import { useWorkflowPacks } from '@/composables/nodePack/useWorkflowPacks' import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode' import type { LGraphNode } from '@/lib/litegraph/src/litegraph' import { app } from '@/scripts/app' -import { useComfyManagerStore } from '@/stores/comfyManagerStore' import { useNodeDefStore } from '@/stores/nodeDefStore' import type { components } from '@/types/comfyRegistryTypes' import { collectAllNodes } from '@/utils/graphTraversalUtil' +import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore' /** * Composable to find missing NodePacks from workflow diff --git a/src/composables/nodePack/useNodePacks.ts b/src/composables/nodePack/useNodePacks.ts index a9616d8c8..2a8852ca2 100644 --- a/src/composables/nodePack/useNodePacks.ts +++ b/src/composables/nodePack/useNodePacks.ts @@ -2,7 +2,7 @@ import { get, useAsyncState } from '@vueuse/core' import type { Ref } from 'vue' import { useComfyRegistryStore } from '@/stores/comfyRegistryStore' -import type { UseNodePacksOptions } from '@/types/comfyManagerTypes' +import type { UseNodePacksOptions } from '@/workbench/extensions/manager/types/comfyManagerTypes' /** * Handles fetching node packs from the registry given a list of node pack IDs diff --git a/src/composables/nodePack/usePackUpdateStatus.ts b/src/composables/nodePack/usePackUpdateStatus.ts index f8344cd2b..80369d852 100644 --- a/src/composables/nodePack/usePackUpdateStatus.ts +++ b/src/composables/nodePack/usePackUpdateStatus.ts @@ -1,8 +1,8 @@ +import { compare, valid } from 'semver' import { computed } from 'vue' -import { useComfyManagerStore } from '@/stores/comfyManagerStore' import type { components } from '@/types/comfyRegistryTypes' -import { compareVersions, isSemVer } from '@/utils/formatUtil' +import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore' export const usePackUpdateStatus = ( nodePack: components['schemas']['Node'] @@ -16,14 +16,14 @@ export const usePackUpdateStatus = ( const latestVersion = computed(() => nodePack.latest_version?.version) const isNightlyPack = computed( - () => !!installedVersion.value && !isSemVer(installedVersion.value) + () => !!installedVersion.value && !valid(installedVersion.value) ) const isUpdateAvailable = computed(() => { if (!isInstalled.value || isNightlyPack.value || !latestVersion.value) { return false } - return compareVersions(latestVersion.value, installedVersion.value) > 0 + return compare(latestVersion.value, installedVersion.value) > 0 }) return { diff --git a/src/composables/nodePack/usePacksSelection.ts b/src/composables/nodePack/usePacksSelection.ts index 7bfbaa4c3..a5d382767 100644 --- a/src/composables/nodePack/usePacksSelection.ts +++ b/src/composables/nodePack/usePacksSelection.ts @@ -1,7 +1,7 @@ import { type Ref, computed } from 'vue' -import { useComfyManagerStore } from '@/stores/comfyManagerStore' import type { components } from '@/types/comfyRegistryTypes' +import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore' type NodePack = components['schemas']['Node'] diff --git a/src/composables/nodePack/useUpdateAvailableNodes.ts b/src/composables/nodePack/useUpdateAvailableNodes.ts index 593c867d5..2bc6a76c3 100644 --- a/src/composables/nodePack/useUpdateAvailableNodes.ts +++ b/src/composables/nodePack/useUpdateAvailableNodes.ts @@ -1,9 +1,9 @@ +import { compare, valid } from 'semver' import { computed, onMounted } from 'vue' import { useInstalledPacks } from '@/composables/nodePack/useInstalledPacks' -import { useComfyManagerStore } from '@/stores/comfyManagerStore' import type { components } from '@/types/comfyRegistryTypes' -import { compareVersions, isSemVer } from '@/utils/formatUtil' +import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore' /** * Composable to find NodePacks that have updates available @@ -25,13 +25,13 @@ export const useUpdateAvailableNodes = () => { ) const latestVersion = pack.latest_version?.version - const isNightlyPack = !!installedVersion && !isSemVer(installedVersion) + const isNightlyPack = !!installedVersion && !valid(installedVersion) if (isNightlyPack || !latestVersion) { return false } - return compareVersions(latestVersion, installedVersion) > 0 + return compare(latestVersion, installedVersion) > 0 } // Same filtering logic as ManagerDialogContent.vue diff --git a/src/composables/nodePack/useWorkflowPacks.ts b/src/composables/nodePack/useWorkflowPacks.ts index 7284f178b..532a15edc 100644 --- a/src/composables/nodePack/useWorkflowPacks.ts +++ b/src/composables/nodePack/useWorkflowPacks.ts @@ -7,9 +7,9 @@ import { app } from '@/scripts/app' import { useComfyRegistryStore } from '@/stores/comfyRegistryStore' import { useNodeDefStore } from '@/stores/nodeDefStore' import { useSystemStatsStore } from '@/stores/systemStatsStore' -import type { UseNodePacksOptions } from '@/types/comfyManagerTypes' import type { components } from '@/types/comfyRegistryTypes' import { collectAllNodes } from '@/utils/graphTraversalUtil' +import type { UseNodePacksOptions } from '@/workbench/extensions/manager/types/comfyManagerTypes' type WorkflowPack = { id: diff --git a/src/composables/useConflictDetection.ts b/src/composables/useConflictDetection.ts index abf4d9498..2a30a4045 100644 --- a/src/composables/useConflictDetection.ts +++ b/src/composables/useConflictDetection.ts @@ -5,9 +5,7 @@ import { computed, getCurrentInstance, onUnmounted, readonly, ref } from 'vue' import { useInstalledPacks } from '@/composables/nodePack/useInstalledPacks' import { useConflictAcknowledgment } from '@/composables/useConflictAcknowledgment' import config from '@/config' -import { useComfyManagerService } from '@/services/comfyManagerService' import { useComfyRegistryService } from '@/services/comfyRegistryService' -import { useComfyManagerStore } from '@/stores/comfyManagerStore' import { useConflictDetectionStore } from '@/stores/conflictDetectionStore' import { useSystemStatsStore } from '@/stores/systemStatsStore' import type { SystemStats } from '@/types' @@ -28,6 +26,8 @@ import { satisfiesVersion, utilCheckVersionCompatibility } from '@/utils/versionUtil' +import { useComfyManagerService } from '@/workbench/extensions/manager/services/comfyManagerService' +import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore' /** * Composable for conflict detection system. @@ -641,7 +641,9 @@ export function useConflictDetection() { async function initializeConflictDetection(): Promise { try { // Check if manager is new Manager before proceeding - const { useManagerState } = await import('@/composables/useManagerState') + const { useManagerState } = await import( + '@/workbench/extensions/manager/composables/useManagerState' + ) const managerState = useManagerState() if (!managerState.isNewManagerUI.value) { diff --git a/src/composables/useCoreCommands.ts b/src/composables/useCoreCommands.ts index c53985bc4..00d97ef88 100644 --- a/src/composables/useCoreCommands.ts +++ b/src/composables/useCoreCommands.ts @@ -1,6 +1,5 @@ import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions' import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems' -import { ManagerUIState, useManagerState } from '@/composables/useManagerState' import { useModelSelectorDialog } from '@/composables/useModelSelectorDialog' import { DEFAULT_DARK_COLOR_PALETTE, @@ -41,12 +40,16 @@ import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore' import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore' import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore' import { useWorkspaceStore } from '@/stores/workspaceStore' -import { ManagerTab } from '@/types/comfyManagerTypes' import { getAllNonIoNodesInSubgraph, getExecutionIdsForSelectedNodes } from '@/utils/graphTraversalUtil' import { filterOutputNodes } from '@/utils/nodeFilterUtil' +import { + ManagerUIState, + useManagerState +} from '@/workbench/extensions/manager/composables/useManagerState' +import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes' import { useWorkflowTemplateSelectorDialog } from './useWorkflowTemplateSelectorDialog' diff --git a/src/composables/useImportFailedDetection.ts b/src/composables/useImportFailedDetection.ts index b6cd8c791..7d77841e6 100644 --- a/src/composables/useImportFailedDetection.ts +++ b/src/composables/useImportFailedDetection.ts @@ -2,9 +2,9 @@ import { type ComputedRef, computed, unref } from 'vue' import { useI18n } from 'vue-i18n' import { useDialogService } from '@/services/dialogService' -import { useComfyManagerStore } from '@/stores/comfyManagerStore' import { useConflictDetectionStore } from '@/stores/conflictDetectionStore' import type { ConflictDetail } from '@/types/conflictDetectionTypes' +import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore' /** * Extracting import failed conflicts from conflict list diff --git a/src/composables/useRegistrySearch.ts b/src/composables/useRegistrySearch.ts index 0a42e7b9a..c6dc9e90e 100644 --- a/src/composables/useRegistrySearch.ts +++ b/src/composables/useRegistrySearch.ts @@ -5,9 +5,9 @@ import { computed, ref, watch } from 'vue' import { DEFAULT_PAGE_SIZE } from '@/constants/searchConstants' import { useRegistrySearchGateway } from '@/services/gateway/registrySearchGateway' import type { SearchAttribute } from '@/types/algoliaTypes' -import { SortableAlgoliaField } from '@/types/comfyManagerTypes' import type { components } from '@/types/comfyRegistryTypes' import type { QuerySuggestion, SearchMode } from '@/types/searchServiceTypes' +import { SortableAlgoliaField } from '@/workbench/extensions/manager/types/comfyManagerTypes' type RegistryNodePack = components['schemas']['Node'] diff --git a/src/composables/useServerLogs.ts b/src/composables/useServerLogs.ts index 1fc67cf61..8398f5542 100644 --- a/src/composables/useServerLogs.ts +++ b/src/composables/useServerLogs.ts @@ -3,7 +3,7 @@ import { onUnmounted, ref } from 'vue' import type { LogsWsMessage } from '@/schemas/apiSchema' import { api } from '@/scripts/api' -import type { components } from '@/types/generatedManagerTypes' +import type { components } from '@/workbench/extensions/manager/types/generatedManagerTypes' const LOGS_MESSAGE_TYPE = 'logs' const MANAGER_WS_TASK_DONE_NAME = 'cm-task-completed' diff --git a/src/composables/useVueFeatureFlags.ts b/src/composables/useVueFeatureFlags.ts index 87836c221..f863fc187 100644 --- a/src/composables/useVueFeatureFlags.ts +++ b/src/composables/useVueFeatureFlags.ts @@ -2,16 +2,17 @@ * Vue-related feature flags composable * Manages local settings-driven flags and LiteGraph integration */ +import { createSharedComposable } from '@vueuse/core' import { computed, watch } from 'vue' import { useSettingStore } from '@/platform/settings/settingStore' import { LiteGraph } from '../lib/litegraph/src/litegraph' -export const useVueFeatureFlags = () => { +function useVueFeatureFlagsIndividual() { const settingStore = useSettingStore() - const isVueNodesEnabled = computed(() => { + const shouldRenderVueNodes = computed(() => { try { return settingStore.get('Comfy.VueNodes.Enabled') ?? false } catch { @@ -19,20 +20,20 @@ export const useVueFeatureFlags = () => { } }) - // Whether Vue nodes should render - const shouldRenderVueNodes = computed(() => isVueNodesEnabled.value) - - // Sync the Vue nodes flag with LiteGraph global settings - const syncVueNodesFlag = () => { - LiteGraph.vueNodesMode = isVueNodesEnabled.value - } - // Watch for changes and update LiteGraph immediately - watch(isVueNodesEnabled, syncVueNodesFlag, { immediate: true }) + watch( + shouldRenderVueNodes, + () => { + LiteGraph.vueNodesMode = shouldRenderVueNodes.value + }, + { immediate: true } + ) return { - isVueNodesEnabled, - shouldRenderVueNodes, - syncVueNodesFlag + shouldRenderVueNodes } } + +export const useVueFeatureFlags = createSharedComposable( + useVueFeatureFlagsIndividual +) diff --git a/src/constants/coreKeybindings.ts b/src/constants/coreKeybindings.ts index b4245f789..fe2bde835 100644 --- a/src/constants/coreKeybindings.ts +++ b/src/constants/coreKeybindings.ts @@ -122,14 +122,14 @@ export const CORE_KEYBINDINGS: Keybinding[] = [ key: '.' }, commandId: 'Comfy.Canvas.FitView', - targetElementId: 'graph-canvas' + targetElementId: 'graph-canvas-container' }, { combo: { key: 'p' }, commandId: 'Comfy.Canvas.ToggleSelected.Pin', - targetElementId: 'graph-canvas' + targetElementId: 'graph-canvas-container' }, { combo: { @@ -137,7 +137,7 @@ export const CORE_KEYBINDINGS: Keybinding[] = [ alt: true }, commandId: 'Comfy.Canvas.ToggleSelectedNodes.Collapse', - targetElementId: 'graph-canvas' + targetElementId: 'graph-canvas-container' }, { combo: { @@ -145,7 +145,7 @@ export const CORE_KEYBINDINGS: Keybinding[] = [ ctrl: true }, commandId: 'Comfy.Canvas.ToggleSelectedNodes.Bypass', - targetElementId: 'graph-canvas' + targetElementId: 'graph-canvas-container' }, { combo: { @@ -153,7 +153,7 @@ export const CORE_KEYBINDINGS: Keybinding[] = [ ctrl: true }, commandId: 'Comfy.Canvas.ToggleSelectedNodes.Mute', - targetElementId: 'graph-canvas' + targetElementId: 'graph-canvas-container' }, { combo: { diff --git a/src/extensions/core/groupNode.ts b/src/extensions/core/groupNode.ts index eab82634a..4b3ce78bc 100644 --- a/src/extensions/core/groupNode.ts +++ b/src/extensions/core/groupNode.ts @@ -11,16 +11,16 @@ import { } from '@/lib/litegraph/src/litegraph' import { useToastStore } from '@/platform/updates/common/toastStore' import { - ComfyLink, - ComfyNode, - ComfyWorkflowJSON + type ComfyLink, + type ComfyNode, + type ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema' import type { ComfyNodeDef } from '@/schemas/nodeDefSchema' import { useDialogService } from '@/services/dialogService' import { useExecutionStore } from '@/stores/executionStore' import { useNodeDefStore } from '@/stores/nodeDefStore' import { useWidgetStore } from '@/stores/widgetStore' -import { ComfyExtension } from '@/types/comfy' +import { type ComfyExtension } from '@/types/comfy' import { ExecutableGroupNodeChildDTO } from '@/utils/executableGroupNodeChildDTO' import { GROUP } from '@/utils/executableGroupNodeDto' import { deserialiseAndCreate, serialise } from '@/utils/vintageClipboard' diff --git a/src/extensions/core/load3d.ts b/src/extensions/core/load3d.ts index 698be00dd..c2d8b5d69 100644 --- a/src/extensions/core/load3d.ts +++ b/src/extensions/core/load3d.ts @@ -9,7 +9,7 @@ import Load3dUtils from '@/extensions/core/load3d/Load3dUtils' import { t } from '@/i18n' import type { IStringWidget } from '@/lib/litegraph/src/types/widgets' import { useToastStore } from '@/platform/updates/common/toastStore' -import { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' +import { type CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' import { api } from '@/scripts/api' import { ComfyApp, app } from '@/scripts/app' import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget' diff --git a/src/extensions/core/load3d/AnimationManager.ts b/src/extensions/core/load3d/AnimationManager.ts index f3efd220e..542edfdc2 100644 --- a/src/extensions/core/load3d/AnimationManager.ts +++ b/src/extensions/core/load3d/AnimationManager.ts @@ -1,9 +1,9 @@ import * as THREE from 'three' import { - AnimationItem, - AnimationManagerInterface, - EventManagerInterface + type AnimationItem, + type AnimationManagerInterface, + type EventManagerInterface } from '@/extensions/core/load3d/interfaces' export class AnimationManager implements AnimationManagerInterface { diff --git a/src/extensions/core/load3d/CameraManager.ts b/src/extensions/core/load3d/CameraManager.ts index 2b6b568c9..624ce6882 100644 --- a/src/extensions/core/load3d/CameraManager.ts +++ b/src/extensions/core/load3d/CameraManager.ts @@ -2,11 +2,11 @@ import * as THREE from 'three' import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls' import { - CameraManagerInterface, - CameraState, - CameraType, - EventManagerInterface, - NodeStorageInterface + type CameraManagerInterface, + type CameraState, + type CameraType, + type EventManagerInterface, + type NodeStorageInterface } from './interfaces' export class CameraManager implements CameraManagerInterface { diff --git a/src/extensions/core/load3d/ControlsManager.ts b/src/extensions/core/load3d/ControlsManager.ts index d19160b53..ab28b7698 100644 --- a/src/extensions/core/load3d/ControlsManager.ts +++ b/src/extensions/core/load3d/ControlsManager.ts @@ -2,9 +2,9 @@ import * as THREE from 'three' import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls' import { - ControlsManagerInterface, - EventManagerInterface, - NodeStorageInterface + type ControlsManagerInterface, + type EventManagerInterface, + type NodeStorageInterface } from './interfaces' export class ControlsManager implements ControlsManagerInterface { diff --git a/src/extensions/core/load3d/EventManager.ts b/src/extensions/core/load3d/EventManager.ts index e06669ce2..64b87eb9b 100644 --- a/src/extensions/core/load3d/EventManager.ts +++ b/src/extensions/core/load3d/EventManager.ts @@ -1,4 +1,4 @@ -import { EventCallback, EventManagerInterface } from './interfaces' +import { type EventCallback, type EventManagerInterface } from './interfaces' export class EventManager implements EventManagerInterface { private listeners: { [key: string]: EventCallback[] } = {} diff --git a/src/extensions/core/load3d/LightingManager.ts b/src/extensions/core/load3d/LightingManager.ts index 20212dbdc..384df3110 100644 --- a/src/extensions/core/load3d/LightingManager.ts +++ b/src/extensions/core/load3d/LightingManager.ts @@ -1,6 +1,9 @@ import * as THREE from 'three' -import { EventManagerInterface, LightingManagerInterface } from './interfaces' +import { + type EventManagerInterface, + type LightingManagerInterface +} from './interfaces' export class LightingManager implements LightingManagerInterface { lights: THREE.Light[] = [] diff --git a/src/extensions/core/load3d/Load3d.ts b/src/extensions/core/load3d/Load3d.ts index 37121ba6b..0b4c4f307 100644 --- a/src/extensions/core/load3d/Load3d.ts +++ b/src/extensions/core/load3d/Load3d.ts @@ -1,7 +1,7 @@ import * as THREE from 'three' import { LGraphNode } from '@/lib/litegraph/src/litegraph' -import { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' +import { type CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' import { CameraManager } from './CameraManager' import { ControlsManager } from './ControlsManager' @@ -16,11 +16,11 @@ import { SceneManager } from './SceneManager' import { SceneModelManager } from './SceneModelManager' import { ViewHelperManager } from './ViewHelperManager' import { - CameraState, - CaptureResult, - Load3DOptions, - MaterialMode, - UpDirection + type CameraState, + type CaptureResult, + type Load3DOptions, + type MaterialMode, + type UpDirection } from './interfaces' class Load3d { diff --git a/src/extensions/core/load3d/Load3dAnimation.ts b/src/extensions/core/load3d/Load3dAnimation.ts index 78a9dfae8..82ff2c099 100644 --- a/src/extensions/core/load3d/Load3dAnimation.ts +++ b/src/extensions/core/load3d/Load3dAnimation.ts @@ -4,7 +4,7 @@ import { LGraphNode } from '@/lib/litegraph/src/litegraph' import { AnimationManager } from './AnimationManager' import Load3d from './Load3d' -import { Load3DOptions } from './interfaces' +import { type Load3DOptions } from './interfaces' class Load3dAnimation extends Load3d { private animationManager: AnimationManager diff --git a/src/extensions/core/load3d/LoaderManager.ts b/src/extensions/core/load3d/LoaderManager.ts index ccf06787b..2c753e6fc 100644 --- a/src/extensions/core/load3d/LoaderManager.ts +++ b/src/extensions/core/load3d/LoaderManager.ts @@ -9,9 +9,9 @@ import { t } from '@/i18n' import { useToastStore } from '@/platform/updates/common/toastStore' import { - EventManagerInterface, - LoaderManagerInterface, - ModelManagerInterface + type EventManagerInterface, + type LoaderManagerInterface, + type ModelManagerInterface } from './interfaces' export class LoaderManager implements LoaderManagerInterface { diff --git a/src/extensions/core/load3d/NodeStorage.ts b/src/extensions/core/load3d/NodeStorage.ts index 44417fbc6..09aac60d4 100644 --- a/src/extensions/core/load3d/NodeStorage.ts +++ b/src/extensions/core/load3d/NodeStorage.ts @@ -1,6 +1,6 @@ import { LGraphNode } from '@/lib/litegraph/src/litegraph' -import { NodeStorageInterface } from './interfaces' +import { type NodeStorageInterface } from './interfaces' export class NodeStorage implements NodeStorageInterface { private node: LGraphNode diff --git a/src/extensions/core/load3d/PreviewManager.ts b/src/extensions/core/load3d/PreviewManager.ts index 514fd3726..36cd2b325 100644 --- a/src/extensions/core/load3d/PreviewManager.ts +++ b/src/extensions/core/load3d/PreviewManager.ts @@ -1,7 +1,10 @@ import * as THREE from 'three' import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls' -import { EventManagerInterface, PreviewManagerInterface } from './interfaces' +import { + type EventManagerInterface, + type PreviewManagerInterface +} from './interfaces' export class PreviewManager implements PreviewManagerInterface { previewCamera: THREE.Camera diff --git a/src/extensions/core/load3d/RecordingManager.ts b/src/extensions/core/load3d/RecordingManager.ts index 252e6b58b..679fa9c5d 100644 --- a/src/extensions/core/load3d/RecordingManager.ts +++ b/src/extensions/core/load3d/RecordingManager.ts @@ -1,6 +1,6 @@ import * as THREE from 'three' -import { EventManagerInterface } from './interfaces' +import { type EventManagerInterface } from './interfaces' export class RecordingManager { private mediaRecorder: MediaRecorder | null = null diff --git a/src/extensions/core/load3d/SceneManager.ts b/src/extensions/core/load3d/SceneManager.ts index 4f46dda4b..722e4f439 100644 --- a/src/extensions/core/load3d/SceneManager.ts +++ b/src/extensions/core/load3d/SceneManager.ts @@ -2,7 +2,10 @@ import * as THREE from 'three' import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls' import Load3dUtils from './Load3dUtils' -import { EventManagerInterface, SceneManagerInterface } from './interfaces' +import { + type EventManagerInterface, + type SceneManagerInterface +} from './interfaces' export class SceneManager implements SceneManagerInterface { scene: THREE.Scene diff --git a/src/extensions/core/load3d/SceneModelManager.ts b/src/extensions/core/load3d/SceneModelManager.ts index d4cd6f795..94f597bf2 100644 --- a/src/extensions/core/load3d/SceneModelManager.ts +++ b/src/extensions/core/load3d/SceneModelManager.ts @@ -2,7 +2,7 @@ import * as THREE from 'three' import { LineMaterial } from 'three/examples/jsm/lines/LineMaterial' import { LineSegments2 } from 'three/examples/jsm/lines/LineSegments2' import { LineSegmentsGeometry } from 'three/examples/jsm/lines/LineSegmentsGeometry' -import { GLTF } from 'three/examples/jsm/loaders/GLTFLoader' +import { type GLTF } from 'three/examples/jsm/loaders/GLTFLoader' import { mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils' import { ColoredShadowMaterial } from './conditional-lines/ColoredShadowMaterial' @@ -11,11 +11,11 @@ import { ConditionalEdgesShader } from './conditional-lines/ConditionalEdgesShad import { ConditionalLineMaterial } from './conditional-lines/Lines2/ConditionalLineMaterial' import { ConditionalLineSegmentsGeometry } from './conditional-lines/Lines2/ConditionalLineSegmentsGeometry' import { - EventManagerInterface, - Load3DOptions, - MaterialMode, - ModelManagerInterface, - UpDirection + type EventManagerInterface, + type Load3DOptions, + type MaterialMode, + type ModelManagerInterface, + type UpDirection } from './interfaces' export class SceneModelManager implements ModelManagerInterface { diff --git a/src/extensions/core/load3d/ViewHelperManager.ts b/src/extensions/core/load3d/ViewHelperManager.ts index eeb8f9ad2..f68e5e0cb 100644 --- a/src/extensions/core/load3d/ViewHelperManager.ts +++ b/src/extensions/core/load3d/ViewHelperManager.ts @@ -2,7 +2,10 @@ import * as THREE from 'three' import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls' import { ViewHelper } from 'three/examples/jsm/helpers/ViewHelper' -import { NodeStorageInterface, ViewHelperManagerInterface } from './interfaces' +import { + type NodeStorageInterface, + type ViewHelperManagerInterface +} from './interfaces' export class ViewHelperManager implements ViewHelperManagerInterface { viewHelper: ViewHelper = {} as ViewHelper diff --git a/src/extensions/core/load3d/interfaces.ts b/src/extensions/core/load3d/interfaces.ts index 86d33edc9..967465a9d 100644 --- a/src/extensions/core/load3d/interfaces.ts +++ b/src/extensions/core/load3d/interfaces.ts @@ -2,13 +2,13 @@ import * as THREE from 'three' import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls' import { ViewHelper } from 'three/examples/jsm/helpers/ViewHelper' import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader' -import { GLTF, GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader' +import { type GLTF, GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader' import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader' import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader' import { STLLoader } from 'three/examples/jsm/loaders/STLLoader' import { LGraphNode } from '@/lib/litegraph/src/litegraph' -import { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' +import { type CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' export type Load3DNodeType = 'Load3D' | 'Preview3D' diff --git a/src/extensions/core/previewAny.ts b/src/extensions/core/previewAny.ts index 9931ddaa1..5266f9af9 100644 --- a/src/extensions/core/previewAny.ts +++ b/src/extensions/core/previewAny.ts @@ -4,7 +4,7 @@ https://github.com/rgthree/rgthree-comfy/blob/main/py/display_any.py upstream requested in https://github.com/Kosinkadink/rfcs/blob/main/rfcs/0000-corenodes.md#preview-nodes */ import { app } from '@/scripts/app' -import { DOMWidget } from '@/scripts/domWidget' +import { type DOMWidget } from '@/scripts/domWidget' import { ComfyWidgets } from '@/scripts/widgets' import { useExtensionService } from '@/services/extensionService' diff --git a/src/extensions/core/saveMesh.ts b/src/extensions/core/saveMesh.ts index 2c6f0857c..3c84cf942 100644 --- a/src/extensions/core/saveMesh.ts +++ b/src/extensions/core/saveMesh.ts @@ -2,7 +2,7 @@ import { nextTick } from 'vue' import Load3D from '@/components/load3d/Load3D.vue' import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration' -import { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' +import { type CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget' import { useExtensionService } from '@/services/extensionService' import { useLoad3dService } from '@/services/load3dService' diff --git a/src/extensions/core/uploadAudio.ts b/src/extensions/core/uploadAudio.ts index 1b858b240..4c0e25b2c 100644 --- a/src/extensions/core/uploadAudio.ts +++ b/src/extensions/core/uploadAudio.ts @@ -15,7 +15,7 @@ import type { ResultItemType } from '@/schemas/apiSchema' import type { ComfyNodeDef } from '@/schemas/nodeDefSchema' import type { DOMWidget } from '@/scripts/domWidget' import { useAudioService } from '@/services/audioService' -import { NodeLocatorId } from '@/types' +import { type NodeLocatorId } from '@/types' import { getNodeByLocatorId } from '@/utils/graphTraversalUtil' import { api } from '../../scripts/api' diff --git a/src/extensions/core/uploadImage.ts b/src/extensions/core/uploadImage.ts index 4ed7130ee..4cb910dae 100644 --- a/src/extensions/core/uploadImage.ts +++ b/src/extensions/core/uploadImage.ts @@ -1,6 +1,6 @@ import { - ComfyNodeDef, - InputSpec, + type ComfyNodeDef, + type InputSpec, isComboInputSpecV1 } from '@/schemas/nodeDefSchema' diff --git a/src/i18n.ts b/src/i18n.ts index 102ac2600..38a8dfe95 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -76,7 +76,7 @@ export const i18n = createI18n({ }) /** Convenience shorthand: i18n.global */ -export const { t, te } = i18n.global +export const { t, te, d } = i18n.global /** * Safe translation function that returns the fallback message if the key is not found. diff --git a/src/lib/litegraph/src/LLink.ts b/src/lib/litegraph/src/LLink.ts index 58ae4e090..71b41f23d 100644 --- a/src/lib/litegraph/src/LLink.ts +++ b/src/lib/litegraph/src/LLink.ts @@ -205,7 +205,7 @@ export class LLink implements LinkSegment, Serialisable { network: Pick, linkSegment: LinkSegment ): Reroute[] { - if (!linkSegment.parentId) return [] + if (linkSegment.parentId === undefined) return [] return network.reroutes.get(linkSegment.parentId)?.getReroutes() ?? [] } @@ -229,7 +229,7 @@ export class LLink implements LinkSegment, Serialisable { linkSegment: LinkSegment, rerouteId: RerouteId ): Reroute | null | undefined { - if (!linkSegment.parentId) return + if (linkSegment.parentId === undefined) return return network.reroutes .get(linkSegment.parentId) ?.findNextReroute(rerouteId) @@ -498,7 +498,7 @@ export class LLink implements LinkSegment, Serialisable { target_slot: this.target_slot, type: this.type } - if (this.parentId) copy.parentId = this.parentId + if (this.parentId !== undefined) copy.parentId = this.parentId return copy } } diff --git a/src/lib/litegraph/src/litegraph.ts b/src/lib/litegraph/src/litegraph.ts index 46202a219..098c30e7a 100644 --- a/src/lib/litegraph/src/litegraph.ts +++ b/src/lib/litegraph/src/litegraph.ts @@ -48,7 +48,6 @@ export interface LinkReleaseContextExtended { links: ConnectingLink[] } -// eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface LiteGraphCanvasEvent extends CustomEvent {} export interface LGraphNodeConstructor { @@ -140,7 +139,7 @@ export { BaseWidget } from './widgets/BaseWidget' export { LegacyWidget } from './widgets/LegacyWidget' -export { isComboWidget } from './widgets/widgetMap' +export { isComboWidget, isAssetWidget } from './widgets/widgetMap' // Additional test-specific exports export { LGraphButton } from './LGraphButton' export { MovingOutputLink } from './canvas/MovingOutputLink' diff --git a/src/lib/litegraph/src/widgets/AssetWidget.ts b/src/lib/litegraph/src/widgets/AssetWidget.ts index f8a8e1209..1a5047beb 100644 --- a/src/lib/litegraph/src/widgets/AssetWidget.ts +++ b/src/lib/litegraph/src/widgets/AssetWidget.ts @@ -13,6 +13,22 @@ export class AssetWidget this.value = widget.value?.toString() ?? '' } + override set value(value: IAssetWidget['value']) { + const oldValue = this.value + super.value = value + + // Force canvas redraw when value changes to show update immediately + if (oldValue !== value && this.node.graph?.list_of_graphcanvas) { + for (const canvas of this.node.graph.list_of_graphcanvas) { + canvas.setDirty(true) + } + } + } + + override get value(): IAssetWidget['value'] { + return super.value + } + override get _displayValue(): string { return String(this.value) //FIXME: Resolve asset name } diff --git a/src/lib/litegraph/src/widgets/widgetMap.ts b/src/lib/litegraph/src/widgets/widgetMap.ts index 02cdb5597..0e6a34fe5 100644 --- a/src/lib/litegraph/src/widgets/widgetMap.ts +++ b/src/lib/litegraph/src/widgets/widgetMap.ts @@ -1,5 +1,6 @@ import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode' import type { + IAssetWidget, IBaseWidget, IComboWidget, IWidget, @@ -132,4 +133,9 @@ export function isComboWidget(widget: IBaseWidget): widget is IComboWidget { return widget.type === 'combo' } +/** Type guard: Narrow **from {@link IBaseWidget}** to {@link IAssetWidget}. */ +export function isAssetWidget(widget: IBaseWidget): widget is IAssetWidget { + return widget.type === 'asset' +} + // #endregion Type Guards diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 4e21417a2..3a633989a 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -1892,6 +1892,13 @@ "noModelsInFolder": "No {type} available in this folder", "searchAssetsPlaceholder": "Search assets...", "allModels": "All Models", - "unknown": "Unknown" + "unknown": "Unknown", + "fileFormats": "File formats", + "baseModels": "Base models", + "sortBy": "Sort by", + "sortAZ": "A-Z", + "sortZA": "Z-A", + "sortRecent": "Recent", + "sortPopular": "Popular" } } diff --git a/src/platform/assets/components/AssetBrowserModal.stories.ts b/src/platform/assets/components/AssetBrowserModal.stories.ts index acc93181d..9d2321d57 100644 --- a/src/platform/assets/components/AssetBrowserModal.stories.ts +++ b/src/platform/assets/components/AssetBrowserModal.stories.ts @@ -1,6 +1,7 @@ import type { Meta, StoryObj } from '@storybook/vue3-vite' import AssetBrowserModal from '@/platform/assets/components/AssetBrowserModal.vue' +import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser' import { createMockAssets, mockAssets @@ -56,7 +57,7 @@ export const Default: Story = { render: (args) => ({ components: { AssetBrowserModal }, setup() { - const onAssetSelect = (asset: any) => { + const onAssetSelect = (asset: AssetDisplayItem) => { console.log('Selected asset:', asset) } const onClose = () => { @@ -96,7 +97,7 @@ export const SingleAssetType: Story = { render: (args) => ({ components: { AssetBrowserModal }, setup() { - const onAssetSelect = (asset: any) => { + const onAssetSelect = (asset: AssetDisplayItem) => { console.log('Selected asset:', asset) } const onClose = () => { @@ -145,7 +146,7 @@ export const NoLeftPanel: Story = { render: (args) => ({ components: { AssetBrowserModal }, setup() { - const onAssetSelect = (asset: any) => { + const onAssetSelect = (asset: AssetDisplayItem) => { console.log('Selected asset:', asset) } const onClose = () => { diff --git a/src/platform/assets/components/AssetBrowserModal.vue b/src/platform/assets/components/AssetBrowserModal.vue index de05f437d..cb45f38ba 100644 --- a/src/platform/assets/components/AssetBrowserModal.vue +++ b/src/platform/assets/components/AssetBrowserModal.vue @@ -12,7 +12,7 @@ :nav-items="availableCategories" > @@ -37,7 +37,7 @@ diff --git a/src/platform/assets/components/AssetCard.vue b/src/platform/assets/components/AssetCard.vue index e379099c1..be7c45ca5 100644 --- a/src/platform/assets/components/AssetCard.vue +++ b/src/platform/assets/components/AssetCard.vue @@ -14,7 +14,7 @@ 'bg-ivory-100 border border-gray-300 dark-theme:bg-charcoal-400 dark-theme:border-charcoal-600', 'hover:transform hover:-translate-y-0.5 hover:shadow-lg hover:shadow-black/10 hover:border-gray-400', 'dark-theme:hover:shadow-lg dark-theme:hover:shadow-black/30 dark-theme:hover:border-charcoal-700', - 'focus:outline-none focus:ring-2 focus:ring-blue-500 dark-theme:focus:ring-blue-400' + 'focus:outline-none focus:transform focus:-translate-y-0.5 focus:shadow-lg focus:shadow-black/10 dark-theme:focus:shadow-black/30' ], // Div-specific styles !interactive && [ diff --git a/src/platform/assets/components/AssetFilterBar.vue b/src/platform/assets/components/AssetFilterBar.vue index 1f3295b43..904ce3e82 100644 --- a/src/platform/assets/components/AssetFilterBar.vue +++ b/src/platform/assets/components/AssetFilterBar.vue @@ -3,7 +3,7 @@
void + ): Promise { if (import.meta.env.DEV) { - console.log('Asset selected:', asset.id, asset.name) + console.debug('Asset selected:', assetId) + } + + if (!onSelect) { + return + } + + try { + const detailAsset = await assetService.getAssetDetails(assetId) + const filename = detailAsset.user_metadata?.filename + const validatedFilename = assetFilenameSchema.safeParse(filename) + if (!validatedFilename.success) { + console.error( + 'Invalid asset filename:', + validatedFilename.error.errors, + 'for asset:', + assetId + ) + return + } + + onSelect(validatedFilename.data) + } catch (error) { + console.error(`Failed to fetch asset details for ${assetId}:`, error) } - return asset.id } return { @@ -182,7 +212,6 @@ export function useAssetBrowser(assets: AssetItem[] = []) { filteredAssets, // Actions - selectAsset, - transformAssetForDisplay + selectAssetWithCallback } } diff --git a/src/platform/assets/composables/useAssetBrowserDialog.ts b/src/platform/assets/composables/useAssetBrowserDialog.ts index e5f63eead..31f75c353 100644 --- a/src/platform/assets/composables/useAssetBrowserDialog.ts +++ b/src/platform/assets/composables/useAssetBrowserDialog.ts @@ -1,5 +1,7 @@ import AssetBrowserModal from '@/platform/assets/components/AssetBrowserModal.vue' -import { useDialogStore } from '@/stores/dialogStore' +import type { AssetItem } from '@/platform/assets/schemas/assetSchema' +import { assetService } from '@/platform/assets/services/assetService' +import { type DialogComponentProps, useDialogStore } from '@/stores/dialogStore' interface AssetBrowserDialogProps { /** ComfyUI node type for context (e.g., 'CheckpointLoaderSimple') */ @@ -8,36 +10,29 @@ interface AssetBrowserDialogProps { inputName: string /** Current selected asset value */ currentValue?: string - /** Callback for when an asset is selected */ - onAssetSelected?: (assetPath: string) => void + /** + * Callback for when an asset is selected + * @param {string} filename - The validated filename from user_metadata.filename + */ + onAssetSelected?: (filename: string) => void } export const useAssetBrowserDialog = () => { const dialogStore = useDialogStore() const dialogKey = 'global-asset-browser' - function hide() { - dialogStore.closeDialog({ key: dialogKey }) - } - - function show(props: AssetBrowserDialogProps) { - const handleAssetSelected = (assetPath: string) => { - props.onAssetSelected?.(assetPath) - hide() // Auto-close on selection + async function show(props: AssetBrowserDialogProps) { + const handleAssetSelected = (filename: string) => { + props.onAssetSelected?.(filename) + dialogStore.closeDialog({ key: dialogKey }) } - - const handleClose = () => { - hide() - } - - // Default dialog configuration for AssetBrowserModal - const dialogComponentProps = { + const dialogComponentProps: DialogComponentProps = { headless: true, modal: true, - closable: false, + closable: true, pt: { root: { - class: 'rounded-2xl overflow-hidden' + class: 'rounded-2xl overflow-hidden asset-browser-dialog' }, header: { class: 'p-0 hidden' @@ -48,6 +43,17 @@ export const useAssetBrowserDialog = () => { } } + const assets: AssetItem[] = await assetService + .getAssetsForNodeType(props.nodeType) + .catch((error) => { + console.error( + 'Failed to fetch assets for node type:', + props.nodeType, + error + ) + return [] + }) + dialogStore.showDialog({ key: dialogKey, component: AssetBrowserModal, @@ -55,12 +61,13 @@ export const useAssetBrowserDialog = () => { nodeType: props.nodeType, inputName: props.inputName, currentValue: props.currentValue, + assets, onSelect: handleAssetSelected, - onClose: handleClose + onClose: () => dialogStore.closeDialog({ key: dialogKey }) }, dialogComponentProps }) } - return { show, hide } + return { show } } diff --git a/src/platform/assets/schemas/assetSchema.ts b/src/platform/assets/schemas/assetSchema.ts index fab41649a..2c051a30d 100644 --- a/src/platform/assets/schemas/assetSchema.ts +++ b/src/platform/assets/schemas/assetSchema.ts @@ -4,13 +4,13 @@ import { z } from 'zod' const zAsset = z.object({ id: z.string(), name: z.string(), - asset_hash: z.string(), + asset_hash: z.string().nullable(), size: z.number(), - mime_type: z.string(), + mime_type: z.string().nullable(), tags: z.array(z.string()), preview_url: z.string().optional(), created_at: z.string(), - updated_at: z.string(), + updated_at: z.string().optional(), last_access_time: z.string(), user_metadata: z.record(z.unknown()).optional(), // API allows arbitrary key-value pairs preview_id: z.string().nullable().optional() @@ -33,6 +33,14 @@ const zModelFile = z.object({ pathIndex: z.number() }) +// Filename validation schema +export const assetFilenameSchema = z + .string() + .min(1, 'Filename cannot be empty') + .regex(/^[^\\:*?"<>|]+$/, 'Invalid filename characters') // Allow forward slashes, block backslashes and other unsafe chars + .regex(/^(?!\/|.*\.\.)/, 'Path must not start with / or contain ..') // Prevent absolute paths and directory traversal + .trim() + // Export schemas following repository patterns export const assetResponseSchema = zAssetResponse diff --git a/src/platform/assets/services/assetService.ts b/src/platform/assets/services/assetService.ts index 74b20a753..7d0f82cbb 100644 --- a/src/platform/assets/services/assetService.ts +++ b/src/platform/assets/services/assetService.ts @@ -1,6 +1,7 @@ import { fromZodError } from 'zod-validation-error' import { + type AssetItem, type AssetResponse, type ModelFile, type ModelFolder, @@ -127,10 +128,75 @@ function createAssetService() { ) } + /** + * Gets assets for a specific node type by finding the matching category + * and fetching all assets with that category tag + * + * @param nodeType - The ComfyUI node type (e.g., 'CheckpointLoaderSimple') + * @returns Promise - Full asset objects with preserved metadata + */ + async function getAssetsForNodeType(nodeType: string): Promise { + if (!nodeType || typeof nodeType !== 'string') { + return [] + } + + // Find the category for this node type using efficient O(1) lookup + const modelToNodeStore = useModelToNodeStore() + const category = modelToNodeStore.getCategoryForNodeType(nodeType) + + if (!category) { + return [] + } + + // Fetch assets for this category using same API pattern as getAssetModels + const data = await handleAssetRequest( + `${ASSETS_ENDPOINT}?include_tags=${MODELS_TAG},${category}`, + `assets for ${nodeType}` + ) + + // Return full AssetItem[] objects (don't strip like getAssetModels does) + return ( + data?.assets?.filter( + (asset) => + !asset.tags.includes(MISSING_TAG) && asset.tags.includes(category) + ) ?? [] + ) + } + + /** + * Gets complete details for a specific asset by ID + * Calls the detail endpoint which includes user_metadata and all fields + * + * @param id - The asset ID + * @returns Promise - Complete asset object with user_metadata + */ + async function getAssetDetails(id: string): Promise { + const res = await api.fetchApi(`${ASSETS_ENDPOINT}/${id}`) + if (!res.ok) { + throw new Error( + `Unable to load asset details for ${id}: Server returned ${res.status}. Please try again.` + ) + } + const data = await res.json() + + // Validate the single asset response against our schema + const result = assetResponseSchema.safeParse({ assets: [data] }) + if (result.success && result.data.assets?.[0]) { + return result.data.assets[0] + } + + const error = result.error + ? fromZodError(result.error) + : 'Unknown validation error' + throw new Error(`Invalid asset response against zod schema:\n${error}`) + } + return { getAssetModelFolders, getAssetModels, - isAssetBrowserEligible + isAssetBrowserEligible, + getAssetsForNodeType, + getAssetDetails } } diff --git a/src/platform/settings/constants/coreSettings.ts b/src/platform/settings/constants/coreSettings.ts index d592a92f0..4adf2db9d 100644 --- a/src/platform/settings/constants/coreSettings.ts +++ b/src/platform/settings/constants/coreSettings.ts @@ -595,7 +595,7 @@ export const CORE_SETTINGS: SettingParams[] = [ migrateDeprecatedValue: (value: any[]) => { return value.map((keybinding) => { if (keybinding['targetSelector'] === '#graph-canvas') { - keybinding['targetElementId'] = 'graph-canvas' + keybinding['targetElementId'] = 'graph-canvas-container' } return keybinding }) diff --git a/src/platform/settings/settingStore.ts b/src/platform/settings/settingStore.ts index 2d94e38e5..5a1573efb 100644 --- a/src/platform/settings/settingStore.ts +++ b/src/platform/settings/settingStore.ts @@ -1,5 +1,6 @@ import _ from 'es-toolkit/compat' import { defineStore } from 'pinia' +import { compare, valid } from 'semver' import { ref } from 'vue' import type { SettingParams } from '@/platform/settings/types' @@ -7,7 +8,6 @@ import type { Settings } from '@/schemas/apiSchema' import { api } from '@/scripts/api' import { app } from '@/scripts/app' import type { TreeNode } from '@/types/treeExplorerTypes' -import { compareVersions, isSemVer } from '@/utils/formatUtil' export const getSettingInfo = (setting: SettingParams) => { const parts = setting.category || setting.id.split('.') @@ -132,20 +132,25 @@ export const useSettingStore = defineStore('setting', () => { if (installedVersion) { const sortedVersions = Object.keys(defaultsByInstallVersion).sort( - (a, b) => compareVersions(b, a) + (a, b) => compare(b, a) ) for (const version of sortedVersions) { // Ensure the version is in a valid format before comparing - if (!isSemVer(version)) { + if (!valid(version)) { continue } - if (compareVersions(installedVersion, version) >= 0) { - const versionedDefault = defaultsByInstallVersion[version] - return typeof versionedDefault === 'function' - ? versionedDefault() - : versionedDefault + if (compare(installedVersion, version) >= 0) { + const versionedDefault = + defaultsByInstallVersion[ + version as keyof typeof defaultsByInstallVersion + ] + if (versionedDefault !== undefined) { + return typeof versionedDefault === 'function' + ? versionedDefault() + : versionedDefault + } } } } diff --git a/src/platform/updates/common/releaseStore.ts b/src/platform/updates/common/releaseStore.ts index f34e525f5..470c92272 100644 --- a/src/platform/updates/common/releaseStore.ts +++ b/src/platform/updates/common/releaseStore.ts @@ -1,11 +1,12 @@ import { until } from '@vueuse/core' import { defineStore } from 'pinia' +import { compare } from 'semver' import { computed, ref } from 'vue' import { useSettingStore } from '@/platform/settings/settingStore' import { useSystemStatsStore } from '@/stores/systemStatsStore' import { isElectron } from '@/utils/envUtil' -import { compareVersions, stringToLocale } from '@/utils/formatUtil' +import { stringToLocale } from '@/utils/formatUtil' import { type ReleaseNote, useReleaseService } from './releaseService' @@ -56,16 +57,19 @@ export const useReleaseStore = defineStore('release', () => { const isNewVersionAvailable = computed( () => !!recentRelease.value && - compareVersions( + compare( recentRelease.value.version, - currentComfyUIVersion.value + currentComfyUIVersion.value || '0.0.0' ) > 0 ) const isLatestVersion = computed( () => !!recentRelease.value && - !compareVersions(recentRelease.value.version, currentComfyUIVersion.value) + compare( + recentRelease.value.version, + currentComfyUIVersion.value || '0.0.0' + ) === 0 ) const hasMediumOrHighAttention = computed(() => diff --git a/src/platform/updates/common/versionCompatibilityStore.ts b/src/platform/updates/common/versionCompatibilityStore.ts index 46b25cf33..cc85f945b 100644 --- a/src/platform/updates/common/versionCompatibilityStore.ts +++ b/src/platform/updates/common/versionCompatibilityStore.ts @@ -1,6 +1,6 @@ import { until, useStorage } from '@vueuse/core' import { defineStore } from 'pinia' -import * as semver from 'semver' +import { gt, valid } from 'semver' import { computed } from 'vue' import config from '@/config' @@ -26,13 +26,13 @@ export const useVersionCompatibilityStore = defineStore( if ( !frontendVersion.value || !requiredFrontendVersion.value || - !semver.valid(frontendVersion.value) || - !semver.valid(requiredFrontendVersion.value) + !valid(frontendVersion.value) || + !valid(requiredFrontendVersion.value) ) { return false } // Returns true if required version is greater than frontend version - return semver.gt(requiredFrontendVersion.value, frontendVersion.value) + return gt(requiredFrontendVersion.value, frontendVersion.value) }) const isFrontendNewer = computed(() => { diff --git a/src/renderer/core/canvas/canvasStore.ts b/src/renderer/core/canvas/canvasStore.ts index 6e09d95a3..ec38940fe 100644 --- a/src/renderer/core/canvas/canvasStore.ts +++ b/src/renderer/core/canvas/canvasStore.ts @@ -1,8 +1,10 @@ +import { useEventListener, whenever } from '@vueuse/core' import { defineStore } from 'pinia' import { type Raw, computed, markRaw, ref, shallowRef } from 'vue' import type { Point, Positionable } from '@/lib/litegraph/src/interfaces' import type { + LGraph, LGraphCanvas, LGraphGroup, LGraphNode @@ -94,9 +96,43 @@ export const useCanvasStore = defineStore('canvas', () => { appScalePercentage.value = Math.round(newScale * 100) } + const currentGraph = shallowRef(null) + const isInSubgraph = ref(false) + + // Provide selection state to all Vue nodes + const selectedNodeIds = computed( + () => + new Set( + selectedItems.value + .filter((item) => item.id !== undefined) + .map((item) => String(item.id)) + ) + ) + + whenever( + () => canvas.value, + (newCanvas) => { + useEventListener( + newCanvas.canvas, + 'litegraph:set-graph', + (event: CustomEvent<{ newGraph: LGraph; oldGraph: LGraph }>) => { + const newGraph = event.detail?.newGraph || app.canvas?.graph + currentGraph.value = newGraph + isInSubgraph.value = Boolean(app.canvas?.subgraph) + } + ) + + useEventListener(newCanvas.canvas, 'subgraph-opened', () => { + isInSubgraph.value = true + }) + }, + { immediate: true } + ) + return { canvas, selectedItems, + selectedNodeIds, nodeSelected, groupSelected, rerouteSelected, @@ -105,6 +141,8 @@ export const useCanvasStore = defineStore('canvas', () => { getCanvas, setAppZoomFromPercentage, initScaleSync, - cleanupScaleSync + cleanupScaleSync, + currentGraph, + isInSubgraph } }) diff --git a/src/renderer/core/canvas/injectionKeys.ts b/src/renderer/core/canvas/injectionKeys.ts deleted file mode 100644 index 5c850c100..000000000 --- a/src/renderer/core/canvas/injectionKeys.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { InjectionKey, Ref } from 'vue' - -import type { NodeProgressState } from '@/schemas/apiSchema' - -/** - * Injection key for providing selected node IDs to Vue node components. - * Contains a reactive Set of selected node IDs (as strings). - */ -export const SelectedNodeIdsKey: InjectionKey>> = - Symbol('selectedNodeIds') - -/** - * Injection key for providing executing node IDs to Vue node components. - * Contains a reactive Set of currently executing node IDs (as strings). - */ -export const ExecutingNodeIdsKey: InjectionKey>> = - Symbol('executingNodeIds') - -/** - * Injection key for providing node progress states to Vue node components. - * Contains a reactive Record of node IDs to their current progress state. - */ -export const NodeProgressStatesKey: InjectionKey< - Ref> -> = Symbol('nodeProgressStates') diff --git a/src/renderer/core/layout/injectionKeys.ts b/src/renderer/core/layout/injectionKeys.ts index dd6efda21..8e0e0e1d6 100644 --- a/src/renderer/core/layout/injectionKeys.ts +++ b/src/renderer/core/layout/injectionKeys.ts @@ -1,6 +1,6 @@ import type { InjectionKey } from 'vue' -import type { Point } from '@/renderer/core/layout/types' +import type { useTransformState } from '@/renderer/core/layout/transform/useTransformState' /** * Lightweight, injectable transform state used by layout-aware components. @@ -21,29 +21,11 @@ import type { Point } from '@/renderer/core/layout/types' * const state = inject(TransformStateKey)! * const screen = state.canvasToScreen({ x: 100, y: 50 }) */ -interface TransformState { - /** Convert a screen-space point (CSS pixels) to canvas space. */ - screenToCanvas: (p: Point) => Point - /** Convert a canvas-space point to screen space (CSS pixels). */ - canvasToScreen: (p: Point) => Point - /** Current pan/zoom; `x`/`y` are offsets, `z` is scale. */ - camera?: { x: number; y: number; z: number } - /** - * Test whether a node's rectangle intersects the (expanded) viewport. - * Handy for viewport culling and lazy work. - * - * @param nodePos Top-left in canvas space `[x, y]` - * @param nodeSize Size in canvas units `[width, height]` - * @param viewport Screen-space viewport `{ width, height }` - * @param margin Optional fractional margin (e.g. `0.2` = 20%) - */ - isNodeInViewport?: ( - nodePos: ArrayLike, - nodeSize: ArrayLike, - viewport: { width: number; height: number }, - margin?: number - ) => boolean -} +interface TransformState + extends Pick< + ReturnType, + 'screenToCanvas' | 'canvasToScreen' | 'camera' | 'isNodeInViewport' + > {} export const TransformStateKey: InjectionKey = Symbol('transformState') diff --git a/src/renderer/core/layout/sync/useSlotLayoutSync.ts b/src/renderer/core/layout/sync/useSlotLayoutSync.ts index 618d1857f..281199e8b 100644 --- a/src/renderer/core/layout/sync/useSlotLayoutSync.ts +++ b/src/renderer/core/layout/sync/useSlotLayoutSync.ts @@ -134,7 +134,11 @@ export function useSlotLayoutSync() { restoreHandlers = () => { graph.onNodeAdded = origNodeAdded || undefined graph.onNodeRemoved = origNodeRemoved || undefined - graph.onTrigger = origTrigger || undefined + // Only restore onTrigger if Vue nodes are not active + // Vue node manager sets its own onTrigger handler + if (!LiteGraph.vueNodesMode) { + graph.onTrigger = origTrigger || undefined + } graph.onAfterChange = origAfterChange || undefined } diff --git a/src/renderer/extensions/minimap/composables/useMinimapGraph.ts b/src/renderer/extensions/minimap/composables/useMinimapGraph.ts index 4f1c0dd8e..c5bf9aa6c 100644 --- a/src/renderer/extensions/minimap/composables/useMinimapGraph.ts +++ b/src/renderer/extensions/minimap/composables/useMinimapGraph.ts @@ -1,11 +1,13 @@ import { useThrottleFn } from '@vueuse/core' -import { ref } from 'vue' +import { ref, watch } from 'vue' import type { Ref } from 'vue' 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' +import { MinimapDataSourceFactory } from '../data/MinimapDataSourceFactory' import type { UpdateFlags } from '../types' interface GraphCallbacks { @@ -28,6 +30,9 @@ export function useMinimapGraph( viewport: false }) + // Track LayoutStore version for change detection + const layoutStoreVersion = layoutStore.getVersion() + // Map to store original callbacks per graph ID const originalCallbacksMap = new Map() @@ -96,28 +101,30 @@ export function useMinimapGraph( let positionChanged = false let connectionChanged = false - if (g._nodes.length !== lastNodeCount.value) { + // Use unified data source for change detection + const dataSource = MinimapDataSourceFactory.create(g) + + // Check for node count changes + const currentNodeCount = dataSource.getNodeCount() + if (currentNodeCount !== lastNodeCount.value) { structureChanged = true - lastNodeCount.value = g._nodes.length + lastNodeCount.value = currentNodeCount } - for (const node of g._nodes) { - const key = node.id - const currentState = `${node.pos[0]},${node.pos[1]},${node.size[0]},${node.size[1]}` + // Check for node position/size changes + const nodes = dataSource.getNodes() + for (const node of nodes) { + const nodeId = node.id + const currentState = `${node.x},${node.y},${node.width},${node.height}` - if (nodeStatesCache.get(key) !== currentState) { + if (nodeStatesCache.get(nodeId) !== currentState) { positionChanged = true - nodeStatesCache.set(key, currentState) + nodeStatesCache.set(nodeId, currentState) } } - const currentLinks = JSON.stringify(g.links || {}) - if (currentLinks !== linksCache.value) { - connectionChanged = true - linksCache.value = currentLinks - } - - const currentNodeIds = new Set(g._nodes.map((n: LGraphNode) => n.id)) + // Clean up removed nodes from cache + const currentNodeIds = new Set(nodes.map((n) => n.id)) for (const [nodeId] of nodeStatesCache) { if (!currentNodeIds.has(nodeId)) { nodeStatesCache.delete(nodeId) @@ -125,6 +132,13 @@ export function useMinimapGraph( } } + // TODO: update when Layoutstore tracks links + const currentLinks = JSON.stringify(g.links || {}) + if (currentLinks !== linksCache.value) { + connectionChanged = true + linksCache.value = currentLinks + } + if (structureChanged || positionChanged) { updateFlags.value.bounds = true updateFlags.value.nodes = true @@ -140,6 +154,10 @@ export function useMinimapGraph( const init = () => { setupEventListeners() api.addEventListener('graphChanged', handleGraphChangedThrottled) + + watch(layoutStoreVersion, () => { + void handleGraphChangedThrottled() + }) } const destroy = () => { diff --git a/src/renderer/extensions/minimap/composables/useMinimapViewport.ts b/src/renderer/extensions/minimap/composables/useMinimapViewport.ts index da67e5a7c..6f947a307 100644 --- a/src/renderer/extensions/minimap/composables/useMinimapViewport.ts +++ b/src/renderer/extensions/minimap/composables/useMinimapViewport.ts @@ -5,9 +5,9 @@ import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformS import type { LGraph } from '@/lib/litegraph/src/litegraph' import { calculateMinimapScale, - calculateNodeBounds, enforceMinimumBounds } from '@/renderer/core/spatial/boundsCalculator' +import { MinimapDataSourceFactory } from '@/renderer/extensions/minimap/data/MinimapDataSourceFactory' import type { MinimapBounds, MinimapCanvas, ViewportTransform } from '../types' @@ -53,17 +53,15 @@ export function useMinimapViewport( } const calculateGraphBounds = (): MinimapBounds => { - const g = graph.value - if (!g || !g._nodes || g._nodes.length === 0) { + // Use unified data source + const dataSource = MinimapDataSourceFactory.create(graph.value) + + if (!dataSource.hasData()) { return { minX: 0, minY: 0, maxX: 100, maxY: 100, width: 100, height: 100 } } - const bounds = calculateNodeBounds(g._nodes) - if (!bounds) { - return { minX: 0, minY: 0, maxX: 100, maxY: 100, width: 100, height: 100 } - } - - return enforceMinimumBounds(bounds) + const sourceBounds = dataSource.getBounds() + return enforceMinimumBounds(sourceBounds) } const calculateScale = () => { diff --git a/src/renderer/extensions/minimap/data/AbstractMinimapDataSource.ts b/src/renderer/extensions/minimap/data/AbstractMinimapDataSource.ts new file mode 100644 index 000000000..4aae340b4 --- /dev/null +++ b/src/renderer/extensions/minimap/data/AbstractMinimapDataSource.ts @@ -0,0 +1,95 @@ +import type { LGraph } from '@/lib/litegraph/src/litegraph' +import { calculateNodeBounds } from '@/renderer/core/spatial/boundsCalculator' + +import type { + IMinimapDataSource, + MinimapBounds, + MinimapGroupData, + MinimapLinkData, + MinimapNodeData +} from '../types' + +/** + * Abstract base class for minimap data sources + * Provides common functionality and shared implementation + */ +export abstract class AbstractMinimapDataSource implements IMinimapDataSource { + constructor(protected graph: LGraph | null) {} + + // Abstract methods that must be implemented by subclasses + abstract getNodes(): MinimapNodeData[] + abstract getNodeCount(): number + abstract hasData(): boolean + + // Shared implementation using calculateNodeBounds + getBounds(): MinimapBounds { + const nodes = this.getNodes() + if (nodes.length === 0) { + return { minX: 0, minY: 0, maxX: 100, maxY: 100, width: 100, height: 100 } + } + + // Convert MinimapNodeData to the format expected by calculateNodeBounds + const compatibleNodes = nodes.map((node) => ({ + pos: [node.x, node.y], + size: [node.width, node.height] + })) + + const bounds = calculateNodeBounds(compatibleNodes) + if (!bounds) { + return { minX: 0, minY: 0, maxX: 100, maxY: 100, width: 100, height: 100 } + } + + return bounds + } + + // Shared implementation for groups + getGroups(): MinimapGroupData[] { + if (!this.graph?._groups) return [] + return this.graph._groups.map((group) => ({ + x: group.pos[0], + y: group.pos[1], + width: group.size[0], + height: group.size[1], + color: group.color + })) + } + + // TODO: update when Layoutstore supports links + getLinks(): MinimapLinkData[] { + if (!this.graph) return [] + return this.extractLinksFromGraph(this.graph) + } + + protected extractLinksFromGraph(graph: LGraph): MinimapLinkData[] { + const links: MinimapLinkData[] = [] + const nodeMap = new Map(this.getNodes().map((n) => [n.id, n])) + + for (const node of graph._nodes) { + if (!node.outputs) continue + + const sourceNodeData = nodeMap.get(String(node.id)) + if (!sourceNodeData) continue + + for (const output of node.outputs) { + if (!output.links) continue + + for (const linkId of output.links) { + const link = graph.links[linkId] + if (!link) continue + + const targetNodeData = nodeMap.get(String(link.target_id)) + if (!targetNodeData) continue + + links.push({ + sourceNode: sourceNodeData, + targetNode: targetNodeData, + sourceSlot: link.origin_slot, + targetSlot: link.target_slot + }) + } + } + } + + return links + } +} diff --git a/src/renderer/extensions/minimap/data/LayoutStoreDataSource.ts b/src/renderer/extensions/minimap/data/LayoutStoreDataSource.ts new file mode 100644 index 000000000..c0daf7030 --- /dev/null +++ b/src/renderer/extensions/minimap/data/LayoutStoreDataSource.ts @@ -0,0 +1,42 @@ +import { layoutStore } from '@/renderer/core/layout/store/layoutStore' + +import type { MinimapNodeData } from '../types' +import { AbstractMinimapDataSource } from './AbstractMinimapDataSource' + +/** + * Layout Store data source implementation + */ +export class LayoutStoreDataSource extends AbstractMinimapDataSource { + getNodes(): MinimapNodeData[] { + const allNodes = layoutStore.getAllNodes().value + if (allNodes.size === 0) return [] + + const nodes: MinimapNodeData[] = [] + + for (const [nodeId, layout] of allNodes) { + // Find corresponding LiteGraph node for additional properties + const graphNode = this.graph?._nodes?.find((n) => String(n.id) === nodeId) + + nodes.push({ + id: nodeId, + x: layout.position.x, + y: layout.position.y, + width: layout.size.width, + height: layout.size.height, + bgcolor: graphNode?.bgcolor, + mode: graphNode?.mode, + hasErrors: graphNode?.has_errors + }) + } + + return nodes + } + + getNodeCount(): number { + return layoutStore.getAllNodes().value.size + } + + hasData(): boolean { + return this.getNodeCount() > 0 + } +} diff --git a/src/renderer/extensions/minimap/data/LiteGraphDataSource.ts b/src/renderer/extensions/minimap/data/LiteGraphDataSource.ts new file mode 100644 index 000000000..8e1048e75 --- /dev/null +++ b/src/renderer/extensions/minimap/data/LiteGraphDataSource.ts @@ -0,0 +1,30 @@ +import type { MinimapNodeData } from '../types' +import { AbstractMinimapDataSource } from './AbstractMinimapDataSource' + +/** + * LiteGraph data source implementation + */ +export class LiteGraphDataSource extends AbstractMinimapDataSource { + getNodes(): MinimapNodeData[] { + if (!this.graph?._nodes) return [] + + return this.graph._nodes.map((node) => ({ + id: String(node.id), + x: node.pos[0], + y: node.pos[1], + width: node.size[0], + height: node.size[1], + bgcolor: node.bgcolor, + mode: node.mode, + hasErrors: node.has_errors + })) + } + + getNodeCount(): number { + return this.graph?._nodes?.length ?? 0 + } + + hasData(): boolean { + return this.getNodeCount() > 0 + } +} diff --git a/src/renderer/extensions/minimap/data/MinimapDataSourceFactory.ts b/src/renderer/extensions/minimap/data/MinimapDataSourceFactory.ts new file mode 100644 index 000000000..49b15ed9e --- /dev/null +++ b/src/renderer/extensions/minimap/data/MinimapDataSourceFactory.ts @@ -0,0 +1,22 @@ +import type { LGraph } from '@/lib/litegraph/src/litegraph' +import { layoutStore } from '@/renderer/core/layout/store/layoutStore' + +import type { IMinimapDataSource } from '../types' +import { LayoutStoreDataSource } from './LayoutStoreDataSource' +import { LiteGraphDataSource } from './LiteGraphDataSource' + +/** + * Factory for creating the appropriate data source + */ +export class MinimapDataSourceFactory { + static create(graph: LGraph | null): IMinimapDataSource { + // Check if LayoutStore has data + const layoutStoreHasData = layoutStore.getAllNodes().value.size > 0 + + if (layoutStoreHasData) { + return new LayoutStoreDataSource(graph) + } + + return new LiteGraphDataSource(graph) + } +} diff --git a/src/renderer/extensions/minimap/minimapCanvasRenderer.ts b/src/renderer/extensions/minimap/minimapCanvasRenderer.ts index 2e0790ca9..3e547ce68 100644 --- a/src/renderer/extensions/minimap/minimapCanvasRenderer.ts +++ b/src/renderer/extensions/minimap/minimapCanvasRenderer.ts @@ -3,7 +3,12 @@ import { LGraphEventMode } from '@/lib/litegraph/src/litegraph' import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore' import { adjustColor } from '@/utils/colorUtil' -import type { MinimapRenderContext } from './types' +import { MinimapDataSourceFactory } from './data/MinimapDataSourceFactory' +import type { + IMinimapDataSource, + MinimapNodeData, + MinimapRenderContext +} from './types' /** * Get theme-aware colors for the minimap @@ -25,24 +30,49 @@ function getMinimapColors() { } } +/** + * Get node color based on settings and node properties (Single Responsibility) + */ +function getNodeColor( + node: MinimapNodeData, + settings: MinimapRenderContext['settings'], + colors: ReturnType +): string { + if (settings.renderBypass && node.mode === LGraphEventMode.BYPASS) { + return colors.bypassColor + } + + if (settings.nodeColors) { + if (node.bgcolor) { + return colors.isLightTheme + ? adjustColor(node.bgcolor, { lightness: 0.5 }) + : node.bgcolor + } + return colors.nodeColorDefault + } + + return colors.nodeColor +} + /** * Render groups on the minimap */ function renderGroups( ctx: CanvasRenderingContext2D, - graph: LGraph, + dataSource: IMinimapDataSource, offsetX: number, offsetY: number, context: MinimapRenderContext, colors: ReturnType ) { - if (!graph._groups || graph._groups.length === 0) return + const groups = dataSource.getGroups() + if (groups.length === 0) return - for (const group of graph._groups) { - const x = (group.pos[0] - context.bounds.minX) * context.scale + offsetX - const y = (group.pos[1] - context.bounds.minY) * context.scale + offsetY - const w = group.size[0] * context.scale - const h = group.size[1] * context.scale + for (const group of groups) { + const x = (group.x - context.bounds.minX) * context.scale + offsetX + const y = (group.y - context.bounds.minY) * context.scale + offsetY + const w = group.width * context.scale + const h = group.height * context.scale let color = colors.groupColor @@ -64,45 +94,34 @@ function renderGroups( */ function renderNodes( ctx: CanvasRenderingContext2D, - graph: LGraph, + dataSource: IMinimapDataSource, offsetX: number, offsetY: number, context: MinimapRenderContext, colors: ReturnType ) { - if (!graph._nodes || graph._nodes.length === 0) return + const nodes = dataSource.getNodes() + if (nodes.length === 0) return - // Group nodes by color for batch rendering + // Group nodes by color for batch rendering (performance optimization) const nodesByColor = new Map< string, Array<{ x: number; y: number; w: number; h: number; hasErrors?: boolean }> >() - for (const node of graph._nodes) { - const x = (node.pos[0] - context.bounds.minX) * context.scale + offsetX - const y = (node.pos[1] - context.bounds.minY) * context.scale + offsetY - const w = node.size[0] * context.scale - const h = node.size[1] * context.scale + for (const node of nodes) { + const x = (node.x - context.bounds.minX) * context.scale + offsetX + const y = (node.y - context.bounds.minY) * context.scale + offsetY + const w = node.width * context.scale + const h = node.height * context.scale - let color = colors.nodeColor - - if (context.settings.renderBypass && node.mode === LGraphEventMode.BYPASS) { - color = colors.bypassColor - } else if (context.settings.nodeColors) { - color = colors.nodeColorDefault - - if (node.bgcolor) { - color = colors.isLightTheme - ? adjustColor(node.bgcolor, { lightness: 0.5 }) - : node.bgcolor - } - } + const color = getNodeColor(node, context.settings, colors) if (!nodesByColor.has(color)) { nodesByColor.set(color, []) } - nodesByColor.get(color)!.push({ x, y, w, h, hasErrors: node.has_errors }) + nodesByColor.get(color)!.push({ x, y, w, h, hasErrors: node.hasErrors }) } // Batch render nodes by color @@ -132,13 +151,14 @@ function renderNodes( */ function renderConnections( ctx: CanvasRenderingContext2D, - graph: LGraph, + dataSource: IMinimapDataSource, offsetX: number, offsetY: number, context: MinimapRenderContext, colors: ReturnType ) { - if (!graph || !graph._nodes) return + const links = dataSource.getLinks() + if (links.length === 0) return ctx.strokeStyle = colors.linkColor ctx.lineWidth = 0.3 @@ -151,41 +171,28 @@ function renderConnections( y2: number }> = [] - for (const node of graph._nodes) { - if (!node.outputs) continue + for (const link of links) { + const x1 = + (link.sourceNode.x - context.bounds.minX) * context.scale + offsetX + const y1 = + (link.sourceNode.y - context.bounds.minY) * context.scale + offsetY + const x2 = + (link.targetNode.x - context.bounds.minX) * context.scale + offsetX + const y2 = + (link.targetNode.y - context.bounds.minY) * context.scale + offsetY - const x1 = (node.pos[0] - context.bounds.minX) * context.scale + offsetX - const y1 = (node.pos[1] - context.bounds.minY) * context.scale + offsetY + const outputX = x1 + link.sourceNode.width * context.scale + const outputY = y1 + link.sourceNode.height * context.scale * 0.2 + const inputX = x2 + const inputY = y2 + link.targetNode.height * context.scale * 0.2 - for (const output of node.outputs) { - if (!output.links) continue + // Draw connection line + ctx.beginPath() + ctx.moveTo(outputX, outputY) + ctx.lineTo(inputX, inputY) + ctx.stroke() - for (const linkId of output.links) { - const link = graph.links[linkId] - if (!link) continue - - const targetNode = graph.getNodeById(link.target_id) - if (!targetNode) continue - - const x2 = - (targetNode.pos[0] - context.bounds.minX) * context.scale + offsetX - const y2 = - (targetNode.pos[1] - context.bounds.minY) * context.scale + offsetY - - const outputX = x1 + node.size[0] * context.scale - const outputY = y1 + node.size[1] * context.scale * 0.2 - const inputX = x2 - const inputY = y2 + targetNode.size[1] * context.scale * 0.2 - - // Draw connection line - ctx.beginPath() - ctx.moveTo(outputX, outputY) - ctx.lineTo(inputX, inputY) - ctx.stroke() - - connections.push({ x1: outputX, y1: outputY, x2: inputX, y2: inputY }) - } - } + connections.push({ x1: outputX, y1: outputY, x2: inputX, y2: inputY }) } // Render connection slots on top @@ -217,8 +224,11 @@ export function renderMinimapToCanvas( // Clear canvas ctx.clearRect(0, 0, context.width, context.height) + // Create unified data source (Dependency Inversion) + const dataSource = MinimapDataSourceFactory.create(graph) + // Fast path for empty graph - if (!graph || !graph._nodes || graph._nodes.length === 0) { + if (!dataSource.hasData()) { return } @@ -228,12 +238,12 @@ export function renderMinimapToCanvas( // Render in correct order: groups -> links -> nodes if (context.settings.showGroups) { - renderGroups(ctx, graph, offsetX, offsetY, context, colors) + renderGroups(ctx, dataSource, offsetX, offsetY, context, colors) } if (context.settings.showLinks) { - renderConnections(ctx, graph, offsetX, offsetY, context, colors) + renderConnections(ctx, dataSource, offsetX, offsetY, context, colors) } - renderNodes(ctx, graph, offsetX, offsetY, context, colors) + renderNodes(ctx, dataSource, offsetX, offsetY, context, colors) } diff --git a/src/renderer/extensions/minimap/types.ts b/src/renderer/extensions/minimap/types.ts index fbea21c83..b458718ea 100644 --- a/src/renderer/extensions/minimap/types.ts +++ b/src/renderer/extensions/minimap/types.ts @@ -2,6 +2,7 @@ * Minimap-specific type definitions */ import type { LGraph } from '@/lib/litegraph/src/litegraph' +import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema' /** * Minimal interface for what the minimap needs from the canvas @@ -66,3 +67,50 @@ export type MinimapSettingsKey = | 'Comfy.Minimap.ShowGroups' | 'Comfy.Minimap.RenderBypassState' | 'Comfy.Minimap.RenderErrorState' + +/** + * Node data required for minimap rendering + */ +export interface MinimapNodeData { + id: NodeId + x: number + y: number + width: number + height: number + bgcolor?: string + mode?: number + hasErrors?: boolean +} + +/** + * Link data required for minimap rendering + */ +export interface MinimapLinkData { + sourceNode: MinimapNodeData + targetNode: MinimapNodeData + sourceSlot: number + targetSlot: number +} + +/** + * Group data required for minimap rendering + */ +export interface MinimapGroupData { + x: number + y: number + width: number + height: number + color?: string +} + +/** + * Interface for minimap data sources (Dependency Inversion Principle) + */ +export interface IMinimapDataSource { + getNodes(): MinimapNodeData[] + getLinks(): MinimapLinkData[] + getGroups(): MinimapGroupData[] + getBounds(): MinimapBounds + getNodeCount(): number + hasData(): boolean +} diff --git a/src/renderer/extensions/vueNodes/components/InputSlot.vue b/src/renderer/extensions/vueNodes/components/InputSlot.vue index b97fd30cc..ef38c0754 100644 --- a/src/renderer/extensions/vueNodes/components/InputSlot.vue +++ b/src/renderer/extensions/vueNodes/components/InputSlot.vue @@ -1,6 +1,6 @@ diff --git a/src/renderer/extensions/vueNodes/components/NodeHeader.spec.ts b/src/renderer/extensions/vueNodes/components/NodeHeader.spec.ts index 240a51071..775dd6ba6 100644 --- a/src/renderer/extensions/vueNodes/components/NodeHeader.spec.ts +++ b/src/renderer/extensions/vueNodes/components/NodeHeader.spec.ts @@ -1,12 +1,16 @@ import { mount } from '@vue/test-utils' -import { createPinia } from 'pinia' +import { createPinia, setActivePinia } from 'pinia' import PrimeVue from 'primevue/config' import InputText from 'primevue/inputtext' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { createI18n } from 'vue-i18n' import type { VueNodeData } from '@/composables/graph/useGraphNodeManager' import enMessages from '@/locales/en/main.json' +import { useSettingStore } from '@/platform/settings/settingStore' +import type { Settings } from '@/schemas/apiSchema' +import type { ComfyNodeDef } from '@/schemas/nodeDefSchema' +import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore' import NodeHeader from './NodeHeader.vue' @@ -24,19 +28,94 @@ const makeNodeData = (overrides: Partial = {}): VueNodeData => ({ ...overrides }) -const mountHeader = ( - props?: Partial['$props']> -) => { +const setupMockStores = () => { + const pinia = createPinia() + setActivePinia(pinia) + + const settingStore = useSettingStore() + const nodeDefStore = useNodeDefStore() + + // Mock tooltip delay setting + vi.spyOn(settingStore, 'get').mockImplementation( + (key: K): Settings[K] => { + switch (key) { + case 'Comfy.EnableTooltips': + return true as Settings[K] + case 'LiteGraph.Node.TooltipDelay': + return 500 as Settings[K] + default: + return undefined as Settings[K] + } + } + ) + + // Mock node definition store + const baseMockNodeDef: ComfyNodeDef = { + name: 'KSampler', + display_name: 'KSampler', + category: 'sampling', + python_module: 'test_module', + description: 'Advanced sampling node for diffusion models', + input: { + required: { + model: ['MODEL', {}], + positive: ['CONDITIONING', {}], + negative: ['CONDITIONING', {}] + }, + optional: {}, + hidden: {} + }, + output: ['LATENT'], + output_is_list: [false], + output_name: ['samples'], + output_node: false, + deprecated: false, + experimental: false + } + + const mockNodeDef = new ComfyNodeDefImpl(baseMockNodeDef) + + vi.spyOn(nodeDefStore, 'nodeDefsByName', 'get').mockReturnValue({ + KSampler: mockNodeDef + }) + + return { settingStore, nodeDefStore, pinia } +} + +const createMountConfig = () => { const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: enMessages } }) - return mount(NodeHeader, { + + const { pinia } = setupMockStores() + + return { global: { - plugins: [PrimeVue, i18n, createPinia()], - components: { InputText } - }, + plugins: [PrimeVue, i18n, pinia], + components: { InputText }, + directives: { + tooltip: { + mounted: vi.fn(), + updated: vi.fn(), + unmounted: vi.fn() + } + }, + provide: { + tooltipContainer: { value: document.createElement('div') } + } + } + } +} + +const mountHeader = ( + props?: Partial['$props']> +) => { + const config = createMountConfig() + + return mount(NodeHeader, { + ...config, props: { nodeData: makeNodeData(), readonly: false, @@ -126,4 +205,68 @@ describe('NodeHeader.vue', () => { const collapsedIcon = wrapper.get('i') expect(collapsedIcon.classes()).toContain('pi-chevron-right') }) + + describe('Tooltips', () => { + it('applies tooltip directive to node title with correct configuration', () => { + const wrapper = mountHeader({ + nodeData: makeNodeData({ type: 'KSampler' }) + }) + + const titleElement = wrapper.find('[data-testid="node-title"]') + expect(titleElement.exists()).toBe(true) + + // Check that v-tooltip directive was applied + const directive = wrapper.vm.$el.querySelector( + '[data-testid="node-title"]' + ) + expect(directive).toBeTruthy() + }) + + it('disables tooltip when in readonly mode', () => { + const wrapper = mountHeader({ + readonly: true, + nodeData: makeNodeData({ type: 'KSampler' }) + }) + + const titleElement = wrapper.find('[data-testid="node-title"]') + expect(titleElement.exists()).toBe(true) + }) + + it('disables tooltip when editing is active', async () => { + const wrapper = mountHeader({ + nodeData: makeNodeData({ type: 'KSampler' }) + }) + + // Enter edit mode + await wrapper.get('[data-testid="node-header-1"]').trigger('dblclick') + + // Tooltip should be disabled during editing + const titleElement = wrapper.find('[data-testid="node-title"]') + expect(titleElement.exists()).toBe(true) + }) + + it('creates tooltip configuration when component mounts', () => { + const wrapper = mountHeader({ + nodeData: makeNodeData({ type: 'KSampler' }) + }) + + // Verify tooltip directive is applied to the title element + const titleElement = wrapper.find('[data-testid="node-title"]') + expect(titleElement.exists()).toBe(true) + + // The tooltip composable should be initialized + expect(wrapper.vm).toBeDefined() + }) + + it('uses tooltip container from provide/inject', () => { + const wrapper = mountHeader({ + nodeData: makeNodeData({ type: 'KSampler' }) + }) + + expect(wrapper.exists()).toBe(true) + // Container should be provided through inject + const titleElement = wrapper.find('[data-testid="node-title"]') + expect(titleElement.exists()).toBe(true) + }) + }) }) diff --git a/src/renderer/extensions/vueNodes/components/NodeHeader.vue b/src/renderer/extensions/vueNodes/components/NodeHeader.vue index 286c7ee4b..40b8a7fe0 100644 --- a/src/renderer/extensions/vueNodes/components/NodeHeader.vue +++ b/src/renderer/extensions/vueNodes/components/NodeHeader.vue @@ -4,8 +4,8 @@
@@ -23,7 +23,11 @@ -
+
+ + +
+ + + +
diff --git a/src/renderer/extensions/vueNodes/components/NodeSlots.vue b/src/renderer/extensions/vueNodes/components/NodeSlots.vue index 68f247932..26187899d 100644 --- a/src/renderer/extensions/vueNodes/components/NodeSlots.vue +++ b/src/renderer/extensions/vueNodes/components/NodeSlots.vue @@ -8,7 +8,8 @@ v-for="(input, index) in filteredInputs" :key="`input-${index}`" :slot-data="input" - :node-id="nodeInfo?.id != null ? String(nodeInfo.id) : ''" + :node-type="nodeData?.type || ''" + :node-id="nodeData?.id != null ? String(nodeData.id) : ''" :index="getActualInputIndex(input, index)" :readonly="readonly" /> @@ -19,7 +20,8 @@ v-for="(output, index) in filteredOutputs" :key="`output-${index}`" :slot-data="output" - :node-id="nodeInfo?.id != null ? String(nodeInfo.id) : ''" + :node-type="nodeData?.type || ''" + :node-id="nodeData?.id != null ? String(nodeData.id) : ''" :index="index" :readonly="readonly" /> @@ -32,29 +34,24 @@ import { computed, onErrorCaptured, ref } from 'vue' import type { VueNodeData } from '@/composables/graph/useGraphNodeManager' import { useErrorHandling } from '@/composables/useErrorHandling' -import type { INodeSlot, LGraphNode } from '@/lib/litegraph/src/litegraph' -import type { LODLevel } from '@/renderer/extensions/vueNodes/lod/useLOD' +import type { INodeSlot } from '@/lib/litegraph/src/litegraph' import { isSlotObject } from '@/utils/typeGuardUtil' import InputSlot from './InputSlot.vue' import OutputSlot from './OutputSlot.vue' interface NodeSlotsProps { - node?: LGraphNode // For backwards compatibility - nodeData?: VueNodeData // New clean data structure + nodeData?: VueNodeData readonly?: boolean - lodLevel?: LODLevel } -const props = defineProps() - -const nodeInfo = computed(() => props.nodeData || props.node || null) +const { nodeData = null, readonly } = defineProps() // Filter out input slots that have corresponding widgets const filteredInputs = computed(() => { - if (!nodeInfo.value?.inputs) return [] + if (!nodeData?.inputs) return [] - return nodeInfo.value.inputs + return nodeData.inputs .filter((input) => { // Check if this slot has a widget property (indicating it has a corresponding widget) if (isSlotObject(input) && 'widget' in input && input.widget) { @@ -76,7 +73,7 @@ const filteredInputs = computed(() => { // Outputs don't have widgets, so we don't need to filter them const filteredOutputs = computed(() => { - const outputs = nodeInfo.value?.outputs || [] + const outputs = nodeData?.outputs || [] return outputs.map((output) => isSlotObject(output) ? output @@ -94,10 +91,10 @@ const getActualInputIndex = ( input: INodeSlot, filteredIndex: number ): number => { - if (!nodeInfo.value?.inputs) return filteredIndex + if (!nodeData?.inputs) return filteredIndex // Find the actual index in the unfiltered inputs array - const actualIndex = nodeInfo.value.inputs.findIndex((i) => i === input) + const actualIndex = nodeData.inputs.findIndex((i) => i === input) return actualIndex !== -1 ? actualIndex : filteredIndex } diff --git a/src/renderer/extensions/vueNodes/components/NodeWidgets.vue b/src/renderer/extensions/vueNodes/components/NodeWidgets.vue index 8479259bd..4645429da 100644 --- a/src/renderer/extensions/vueNodes/components/NodeWidgets.vue +++ b/src/renderer/extensions/vueNodes/components/NodeWidgets.vue @@ -12,9 +12,9 @@ : 'pointer-events-none' ) " - @pointerdown="handleWidgetPointerEvent" - @pointermove="handleWidgetPointerEvent" - @pointerup="handleWidgetPointerEvent" + @pointerdown.stop="handleWidgetPointerEvent" + @pointermove.stop="handleWidgetPointerEvent" + @pointerup.stop="handleWidgetPointerEvent" >
diff --git a/src/components/dialog/content/ManagerProgressDialogContent.test.ts b/src/workbench/extensions/manager/components/ManagerProgressDialogContent.test.ts similarity index 98% rename from src/components/dialog/content/ManagerProgressDialogContent.test.ts rename to src/workbench/extensions/manager/components/ManagerProgressDialogContent.test.ts index ba6b58bcf..5b31975a3 100644 --- a/src/components/dialog/content/ManagerProgressDialogContent.test.ts +++ b/src/workbench/extensions/manager/components/ManagerProgressDialogContent.test.ts @@ -29,7 +29,7 @@ const defaultMockTaskLogs = [ { taskName: 'Task 2', logs: ['Log 3', 'Log 4'] } ] -vi.mock('@/stores/comfyManagerStore', () => ({ +vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({ useComfyManagerStore: vi.fn(() => ({ taskLogs: [...defaultMockTaskLogs], succeededTasksLogs: [...defaultMockTaskLogs], diff --git a/src/components/dialog/content/ManagerProgressDialogContent.vue b/src/workbench/extensions/manager/components/ManagerProgressDialogContent.vue similarity index 98% rename from src/components/dialog/content/ManagerProgressDialogContent.vue rename to src/workbench/extensions/manager/components/ManagerProgressDialogContent.vue index 6aad68a91..613d1e2a7 100644 --- a/src/components/dialog/content/ManagerProgressDialogContent.vue +++ b/src/workbench/extensions/manager/components/ManagerProgressDialogContent.vue @@ -88,7 +88,7 @@ import { computed, onBeforeUnmount, onMounted, ref } from 'vue' import { useComfyManagerStore, useManagerProgressDialogStore -} from '@/stores/comfyManagerStore' +} from '@/workbench/extensions/manager/stores/comfyManagerStore' const comfyManagerStore = useComfyManagerStore() const progressDialogContent = useManagerProgressDialogStore() diff --git a/src/components/dialog/footer/ManagerProgressFooter.vue b/src/workbench/extensions/manager/components/ManagerProgressFooter.vue similarity index 97% rename from src/components/dialog/footer/ManagerProgressFooter.vue rename to src/workbench/extensions/manager/components/ManagerProgressFooter.vue index 57edcec24..392310721 100644 --- a/src/components/dialog/footer/ManagerProgressFooter.vue +++ b/src/workbench/extensions/manager/components/ManagerProgressFooter.vue @@ -78,13 +78,13 @@ import { useConflictDetection } from '@/composables/useConflictDetection' import { useSettingStore } from '@/platform/settings/settingStore' import { useWorkflowService } from '@/platform/workflow/core/services/workflowService' import { api } from '@/scripts/api' -import { useComfyManagerService } from '@/services/comfyManagerService' +import { useCommandStore } from '@/stores/commandStore' +import { useDialogStore } from '@/stores/dialogStore' +import { useComfyManagerService } from '@/workbench/extensions/manager/services/comfyManagerService' import { useComfyManagerStore, useManagerProgressDialogStore -} from '@/stores/comfyManagerStore' -import { useCommandStore } from '@/stores/commandStore' -import { useDialogStore } from '@/stores/dialogStore' +} from '@/workbench/extensions/manager/stores/comfyManagerStore' const { t } = useI18n() const dialogStore = useDialogStore() diff --git a/src/components/dialog/header/ManagerProgressHeader.vue b/src/workbench/extensions/manager/components/ManagerProgressHeader.vue similarity index 94% rename from src/components/dialog/header/ManagerProgressHeader.vue rename to src/workbench/extensions/manager/components/ManagerProgressHeader.vue index be61295a8..841119cd0 100644 --- a/src/components/dialog/header/ManagerProgressHeader.vue +++ b/src/workbench/extensions/manager/components/ManagerProgressHeader.vue @@ -24,7 +24,7 @@ import { useI18n } from 'vue-i18n' import { useComfyManagerStore, useManagerProgressDialogStore -} from '@/stores/comfyManagerStore' +} from '@/workbench/extensions/manager/stores/comfyManagerStore' const progressDialogContent = useManagerProgressDialogStore() const comfyManagerStore = useComfyManagerStore() diff --git a/src/components/dialog/content/manager/ManagerDialogContent.vue b/src/workbench/extensions/manager/components/manager/ManagerDialogContent.vue similarity index 94% rename from src/components/dialog/content/manager/ManagerDialogContent.vue rename to src/workbench/extensions/manager/components/manager/ManagerDialogContent.vue index ed6d93b5f..f6c290d30 100644 --- a/src/components/dialog/content/manager/ManagerDialogContent.vue +++ b/src/workbench/extensions/manager/components/manager/ManagerDialogContent.vue @@ -143,24 +143,24 @@ import IconButton from '@/components/button/IconButton.vue' import ContentDivider from '@/components/common/ContentDivider.vue' import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue' import VirtualGrid from '@/components/common/VirtualGrid.vue' -import ManagerNavSidebar from '@/components/dialog/content/manager/ManagerNavSidebar.vue' -import InfoPanel from '@/components/dialog/content/manager/infoPanel/InfoPanel.vue' -import InfoPanelMultiItem from '@/components/dialog/content/manager/infoPanel/InfoPanelMultiItem.vue' -import PackCard from '@/components/dialog/content/manager/packCard/PackCard.vue' -import RegistrySearchBar from '@/components/dialog/content/manager/registrySearchBar/RegistrySearchBar.vue' -import GridSkeleton from '@/components/dialog/content/manager/skeleton/GridSkeleton.vue' import { useResponsiveCollapse } from '@/composables/element/useResponsiveCollapse' -import { useManagerStatePersistence } from '@/composables/manager/useManagerStatePersistence' import { useInstalledPacks } from '@/composables/nodePack/useInstalledPacks' import { usePackUpdateStatus } from '@/composables/nodePack/usePackUpdateStatus' import { useWorkflowPacks } from '@/composables/nodePack/useWorkflowPacks' import { useConflictAcknowledgment } from '@/composables/useConflictAcknowledgment' import { useRegistrySearch } from '@/composables/useRegistrySearch' -import { useComfyManagerStore } from '@/stores/comfyManagerStore' import { useComfyRegistryStore } from '@/stores/comfyRegistryStore' -import type { TabItem } from '@/types/comfyManagerTypes' -import { ManagerTab } from '@/types/comfyManagerTypes' import type { components } from '@/types/comfyRegistryTypes' +import ManagerNavSidebar from '@/workbench/extensions/manager/components/manager/ManagerNavSidebar.vue' +import InfoPanel from '@/workbench/extensions/manager/components/manager/infoPanel/InfoPanel.vue' +import InfoPanelMultiItem from '@/workbench/extensions/manager/components/manager/infoPanel/InfoPanelMultiItem.vue' +import PackCard from '@/workbench/extensions/manager/components/manager/packCard/PackCard.vue' +import RegistrySearchBar from '@/workbench/extensions/manager/components/manager/registrySearchBar/RegistrySearchBar.vue' +import GridSkeleton from '@/workbench/extensions/manager/components/manager/skeleton/GridSkeleton.vue' +import { useManagerStatePersistence } from '@/workbench/extensions/manager/composables/useManagerStatePersistence' +import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore' +import type { TabItem } from '@/workbench/extensions/manager/types/comfyManagerTypes' +import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes' const { initialTab } = defineProps<{ initialTab?: ManagerTab diff --git a/src/workbench/extensions/manager/components/manager/ManagerHeader.test.ts b/src/workbench/extensions/manager/components/manager/ManagerHeader.test.ts new file mode 100644 index 000000000..11449f199 --- /dev/null +++ b/src/workbench/extensions/manager/components/manager/ManagerHeader.test.ts @@ -0,0 +1,45 @@ +import { mount } from '@vue/test-utils' +import { createPinia } from 'pinia' +import PrimeVue from 'primevue/config' +import { describe, expect, it } from 'vitest' +import { createI18n } from 'vue-i18n' + +import enMessages from '@/locales/en/main.json' with { type: 'json' } + +import ManagerHeader from './ManagerHeader.vue' + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { + en: enMessages + } +}) + +describe('ManagerHeader', () => { + const createWrapper = () => { + return mount(ManagerHeader, { + global: { + plugins: [createPinia(), PrimeVue, i18n] + } + }) + } + + it('renders the component title', () => { + const wrapper = createWrapper() + + expect(wrapper.find('h2').text()).toBe( + enMessages.manager.discoverCommunityContent + ) + }) + + it('has proper structure with flex container', () => { + const wrapper = createWrapper() + + const flexContainer = wrapper.find('.flex.items-center') + expect(flexContainer.exists()).toBe(true) + + const title = flexContainer.find('h2') + expect(title.exists()).toBe(true) + }) +}) diff --git a/src/workbench/extensions/manager/components/manager/ManagerHeader.vue b/src/workbench/extensions/manager/components/manager/ManagerHeader.vue new file mode 100644 index 000000000..054f1fbf4 --- /dev/null +++ b/src/workbench/extensions/manager/components/manager/ManagerHeader.vue @@ -0,0 +1,11 @@ + + + diff --git a/src/components/dialog/content/manager/ManagerNavSidebar.vue b/src/workbench/extensions/manager/components/manager/ManagerNavSidebar.vue similarity index 93% rename from src/components/dialog/content/manager/ManagerNavSidebar.vue rename to src/workbench/extensions/manager/components/manager/ManagerNavSidebar.vue index c84734a30..0e643b445 100644 --- a/src/components/dialog/content/manager/ManagerNavSidebar.vue +++ b/src/workbench/extensions/manager/components/manager/ManagerNavSidebar.vue @@ -32,7 +32,7 @@ import Listbox from 'primevue/listbox' import ScrollPanel from 'primevue/scrollpanel' import ContentDivider from '@/components/common/ContentDivider.vue' -import type { TabItem } from '@/types/comfyManagerTypes' +import type { TabItem } from '@/workbench/extensions/manager/types/comfyManagerTypes' defineProps<{ tabs: TabItem[] diff --git a/src/components/dialog/content/manager/NodeConflictDialogContent.vue b/src/workbench/extensions/manager/components/manager/NodeConflictDialogContent.vue similarity index 100% rename from src/components/dialog/content/manager/NodeConflictDialogContent.vue rename to src/workbench/extensions/manager/components/manager/NodeConflictDialogContent.vue diff --git a/src/components/dialog/content/manager/NodeConflictFooter.vue b/src/workbench/extensions/manager/components/manager/NodeConflictFooter.vue similarity index 100% rename from src/components/dialog/content/manager/NodeConflictFooter.vue rename to src/workbench/extensions/manager/components/manager/NodeConflictFooter.vue diff --git a/src/components/dialog/content/manager/NodeConflictHeader.vue b/src/workbench/extensions/manager/components/manager/NodeConflictHeader.vue similarity index 100% rename from src/components/dialog/content/manager/NodeConflictHeader.vue rename to src/workbench/extensions/manager/components/manager/NodeConflictHeader.vue diff --git a/src/components/dialog/content/manager/PackStatusMessage.vue b/src/workbench/extensions/manager/components/manager/PackStatusMessage.vue similarity index 100% rename from src/components/dialog/content/manager/PackStatusMessage.vue rename to src/workbench/extensions/manager/components/manager/PackStatusMessage.vue diff --git a/src/components/dialog/content/manager/PackVersionBadge.test.ts b/src/workbench/extensions/manager/components/manager/PackVersionBadge.test.ts similarity index 99% rename from src/components/dialog/content/manager/PackVersionBadge.test.ts rename to src/workbench/extensions/manager/components/manager/PackVersionBadge.test.ts index cf3427fd8..e4eca3016 100644 --- a/src/components/dialog/content/manager/PackVersionBadge.test.ts +++ b/src/workbench/extensions/manager/components/manager/PackVersionBadge.test.ts @@ -35,7 +35,7 @@ const mockInstalledPacks = { const mockIsPackEnabled = vi.fn(() => true) -vi.mock('@/stores/comfyManagerStore', () => ({ +vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({ useComfyManagerStore: vi.fn(() => ({ installedPacks: mockInstalledPacks, isPackInstalled: (id: string) => diff --git a/src/components/dialog/content/manager/PackVersionBadge.vue b/src/workbench/extensions/manager/components/manager/PackVersionBadge.vue similarity index 88% rename from src/components/dialog/content/manager/PackVersionBadge.vue rename to src/workbench/extensions/manager/components/manager/PackVersionBadge.vue index e0fc111ca..204b2a78e 100644 --- a/src/components/dialog/content/manager/PackVersionBadge.vue +++ b/src/workbench/extensions/manager/components/manager/PackVersionBadge.vue @@ -43,13 +43,13 @@