diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index 8decf07cd..06139b08a 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -7,3 +7,21 @@ c53f197de2a3e0fa66b16dedc65c131235c1c4b6 # Reorganize renderer components into domain-driven folder structure c8a83a9caede7bdb5f8598c5492b07d08c339d49 + +# Domain-driven design (DDD) refactors - September 2025 +# These commits reorganized the codebase into domain-driven architecture + +# [refactor] Improve renderer domain organization (#5552) +6349ceee6c0a57fc7992e85635def9b6e22eaeb2 + +# [refactor] Improve settings domain organization (#5550) +4c8c4a1ad4f53354f700a33ea1b95262aeda2719 + +# [refactor] Improve workflow domain organization (#5584) +ca312fd1eab540cc4ddc0e3d244d38b3858574f0 + +# [refactor] Move thumbnail functionality to renderer/core domain (#5586) +e3bb29ceb8174b8bbca9e48ec7d42cd540f40efa + +# [refactor] Improve updates/notifications domain organization (#5590) +27ab355f9c73415dc39f4d3f512b02308f847801 diff --git a/.github/workflows/publish-frontend-types.yaml b/.github/workflows/publish-frontend-types.yaml new file mode 100644 index 000000000..398f5e0a7 --- /dev/null +++ b/.github/workflows/publish-frontend-types.yaml @@ -0,0 +1,137 @@ +name: Publish Frontend Types + +on: + workflow_dispatch: + inputs: + version: + description: 'Version to publish (e.g., 1.26.7)' + required: true + type: string + dist_tag: + description: 'npm dist-tag to use' + required: true + default: latest + type: string + ref: + description: 'Git ref to checkout (commit SHA, tag, or branch)' + required: false + type: string + workflow_call: + inputs: + version: + required: true + type: string + dist_tag: + required: false + type: string + default: latest + ref: + required: false + type: string + +concurrency: + group: publish-frontend-types-${{ github.workflow }}-${{ inputs.version }}-${{ inputs.dist_tag }} + cancel-in-progress: false + +jobs: + publish_types_manual: + name: Publish @comfyorg/comfyui-frontend-types + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Validate inputs + shell: bash + run: | + set -euo pipefail + VERSION="${{ inputs.version }}" + SEMVER_REGEX='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(\+([0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*))?$' + if [[ ! "$VERSION" =~ $SEMVER_REGEX ]]; then + echo "::error title=Invalid version::Version '$VERSION' must follow semantic versioning (x.y.z[-suffix][+build])" >&2 + exit 1 + fi + + - name: Determine ref to checkout + id: resolve_ref + shell: bash + run: | + set -euo pipefail + REF="${{ inputs.ref }}" + VERSION="${{ inputs.version }}" + if [ -n "$REF" ]; then + if ! git check-ref-format --allow-onelevel "$REF"; then + echo "::error title=Invalid ref::Ref '$REF' fails git check-ref-format validation." >&2 + exit 1 + fi + echo "ref=$REF" >> "$GITHUB_OUTPUT" + else + echo "ref=refs/tags/v$VERSION" >> "$GITHUB_OUTPUT" + fi + + - name: Checkout repository + uses: actions/checkout@v5 + with: + ref: ${{ steps.resolve_ref.outputs.ref }} + fetch-depth: 1 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Setup Node.js + uses: actions/setup-node@v5 + with: + node-version: 'lts/*' + cache: 'pnpm' + registry-url: https://registry.npmjs.org + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build types + run: pnpm build:types + + - name: Verify version matches input + id: verify + shell: bash + run: | + PKG_VERSION=$(node -p "require('./package.json').version") + TYPES_PKG_VERSION=$(node -p "require('./dist/package.json').version") + if [ "$PKG_VERSION" != "${{ inputs.version }}" ]; then + echo "Error: package.json version $PKG_VERSION does not match input ${{ inputs.version }}" >&2 + exit 1 + fi + if [ "$TYPES_PKG_VERSION" != "${{ inputs.version }}" ]; then + echo "Error: dist/package.json version $TYPES_PKG_VERSION does not match input ${{ inputs.version }}" >&2 + exit 1 + fi + echo "version=${{ inputs.version }}" >> $GITHUB_OUTPUT + + - name: Check if version already on npm + id: check_npm + shell: bash + run: | + set -euo pipefail + NAME=$(node -p "require('./dist/package.json').name") + VER="${{ steps.verify.outputs.version }}" + STATUS=0 + OUTPUT=$(npm view "${NAME}@${VER}" --json 2>&1) || STATUS=$? + if [ "$STATUS" -eq 0 ]; then + echo "exists=true" >> "$GITHUB_OUTPUT" + echo "::warning title=Already published::${NAME}@${VER} already exists on npm. Skipping publish." + else + if echo "$OUTPUT" | grep -q "E404"; then + echo "exists=false" >> "$GITHUB_OUTPUT" + else + echo "::error title=Registry lookup failed::$OUTPUT" >&2 + exit "$STATUS" + fi + fi + + - name: Publish package + if: steps.check_npm.outputs.exists == 'false' + run: pnpm publish --access public --tag "${{ inputs.dist_tag }}" + working-directory: dist + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 8958ce147..c359e3da4 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -18,7 +18,7 @@ jobs: is_prerelease: ${{ steps.check_prerelease.outputs.is_prerelease }} steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Install pnpm uses: pnpm/action-setup@v4 with: @@ -73,7 +73,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Download dist artifact uses: actions/download-artifact@v4 with: @@ -98,7 +98,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Download dist artifact uses: actions/download-artifact@v4 with: @@ -126,34 +126,8 @@ jobs: publish_types: needs: build - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Install pnpm - uses: pnpm/action-setup@v4 - with: - version: 10 - - uses: actions/setup-node@v4 - with: - node-version: 'lts/*' - cache: 'pnpm' - registry-url: https://registry.npmjs.org - - - name: Cache tool outputs - uses: actions/cache@v4 - with: - path: | - .cache - tsconfig.tsbuildinfo - dist - key: types-tools-cache-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - types-tools-cache-${{ runner.os }}- - - - run: pnpm install --frozen-lockfile - - run: pnpm build:types - - name: Publish package - run: pnpm publish --access public - working-directory: dist - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + uses: ./.github/workflows/publish-frontend-types.yaml + with: + version: ${{ needs.build.outputs.version }} + ref: ${{ github.event.pull_request.merge_commit_sha }} + secrets: inherit diff --git a/.gitignore b/.gitignore index 100bcd13e..5a58d1b1a 100644 --- a/.gitignore +++ b/.gitignore @@ -51,7 +51,7 @@ tests-ui/workflows/examples /blob-report/ /playwright/.cache/ browser_tests/**/*-win32.png -browser-tests/local/ +browser_tests/local/ .env diff --git a/browser_tests/fixtures/ComfyPage.ts b/browser_tests/fixtures/ComfyPage.ts index c32dd3937..c9a8820f5 100644 --- a/browser_tests/fixtures/ComfyPage.ts +++ b/browser_tests/fixtures/ComfyPage.ts @@ -5,13 +5,14 @@ import dotenv from 'dotenv' import * as fs from 'fs' import type { LGraphNode } from '../../src/lib/litegraph/src/litegraph' -import type { NodeId } from '../../src/schemas/comfyWorkflowSchema' +import type { NodeId } from '../../src/platform/workflow/validation/schemas/workflowSchema' import type { KeyCombo } from '../../src/schemas/keyBindingSchema' import type { useWorkspaceStore } from '../../src/stores/workspaceStore' import { NodeBadgeMode } from '../../src/types/nodeSource' import { ComfyActionbar } from '../helpers/actionbar' import { ComfyTemplates } from '../helpers/templates' import { ComfyMouse } from './ComfyMouse' +import { VueNodeHelpers } from './VueNodeHelpers' import { ComfyNodeSearchBox } from './components/ComfyNodeSearchBox' import { SettingDialog } from './components/SettingDialog' import { @@ -144,6 +145,7 @@ export class ComfyPage { public readonly templates: ComfyTemplates public readonly settingDialog: SettingDialog public readonly confirmDialog: ConfirmDialog + public readonly vueNodes: VueNodeHelpers /** Worker index to test user ID */ public readonly userIds: string[] = [] @@ -172,6 +174,7 @@ export class ComfyPage { this.templates = new ComfyTemplates(page) this.settingDialog = new SettingDialog(page, this) this.confirmDialog = new ConfirmDialog(page) + this.vueNodes = new VueNodeHelpers(page) } convertLeafToContent(structure: FolderStructure): FolderStructure { @@ -1421,7 +1424,7 @@ export class ComfyPage { } async closeDialog() { - await this.page.locator('.p-dialog-close-button').click() + await this.page.locator('.p-dialog-close-button').click({ force: true }) await expect(this.page.locator('.p-dialog')).toBeHidden() } diff --git a/browser_tests/fixtures/VueNodeHelpers.ts b/browser_tests/fixtures/VueNodeHelpers.ts new file mode 100644 index 000000000..e3b3de542 --- /dev/null +++ b/browser_tests/fixtures/VueNodeHelpers.ts @@ -0,0 +1,110 @@ +/** + * Vue Node Test Helpers + */ +import type { Locator, Page } from '@playwright/test' + +export class VueNodeHelpers { + constructor(private page: Page) {} + + /** + * Get locator for all Vue node components in the DOM + */ + get nodes(): Locator { + return this.page.locator('[data-node-id]') + } + + /** + * Get locator for selected Vue node components (using visual selection indicators) + */ + get selectedNodes(): Locator { + return this.page.locator( + '[data-node-id].outline-black, [data-node-id].outline-white' + ) + } + + /** + * Get total count of Vue nodes in the DOM + */ + async getNodeCount(): Promise { + return await this.nodes.count() + } + + /** + * Get count of selected Vue nodes + */ + async getSelectedNodeCount(): Promise { + return await this.selectedNodes.count() + } + + /** + * Get all Vue node IDs currently in the DOM + */ + async getNodeIds(): Promise { + return await this.nodes.evaluateAll((nodes) => + nodes + .map((n) => n.getAttribute('data-node-id')) + .filter((id): id is string => id !== null) + ) + } + + /** + * Select a specific Vue node by ID + */ + async selectNode(nodeId: string): Promise { + await this.page.locator(`[data-node-id="${nodeId}"]`).click() + } + + /** + * Select multiple Vue nodes by IDs using Ctrl+click + */ + async selectNodes(nodeIds: string[]): Promise { + if (nodeIds.length === 0) return + + // Select first node normally + await this.selectNode(nodeIds[0]) + + // Add additional nodes with Ctrl+click + for (let i = 1; i < nodeIds.length; i++) { + await this.page.locator(`[data-node-id="${nodeIds[i]}"]`).click({ + modifiers: ['Control'] + }) + } + } + + /** + * Clear all selections by clicking empty space + */ + async clearSelection(): Promise { + await this.page.mouse.click(50, 50) + } + + /** + * Delete selected Vue nodes using Delete key + */ + async deleteSelected(): Promise { + await this.page.locator('#graph-canvas').focus() + await this.page.keyboard.press('Delete') + } + + /** + * Delete selected Vue nodes using Backspace key + */ + async deleteSelectedWithBackspace(): Promise { + await this.page.locator('#graph-canvas').focus() + await this.page.keyboard.press('Backspace') + } + + /** + * Wait for Vue nodes to be rendered + */ + async waitForNodes(expectedCount?: number): Promise { + if (expectedCount !== undefined) { + await this.page.waitForFunction( + (count) => document.querySelectorAll('[data-node-id]').length >= count, + expectedCount + ) + } else { + await this.page.waitForSelector('[data-node-id]') + } + } +} diff --git a/browser_tests/fixtures/utils/litegraphUtils.ts b/browser_tests/fixtures/utils/litegraphUtils.ts index c9bf88a91..4becc999c 100644 --- a/browser_tests/fixtures/utils/litegraphUtils.ts +++ b/browser_tests/fixtures/utils/litegraphUtils.ts @@ -1,6 +1,6 @@ import type { Page } from '@playwright/test' -import type { NodeId } from '../../../src/schemas/comfyWorkflowSchema' +import type { NodeId } from '../../../src/platform/workflow/validation/schemas/workflowSchema' import { ManageGroupNode } from '../../helpers/manageGroupNode' import type { ComfyPage } from '../ComfyPage' import type { Position, Size } from '../types' diff --git a/browser_tests/helpers/templates.ts b/browser_tests/helpers/templates.ts index d659e125a..0d2c9f31e 100644 --- a/browser_tests/helpers/templates.ts +++ b/browser_tests/helpers/templates.ts @@ -4,7 +4,7 @@ import path from 'path' import { TemplateInfo, WorkflowTemplates -} from '../../src/types/workflowTemplateTypes' +} from '../../src/platform/workflow/templates/types/template' export class ComfyTemplates { readonly content: Locator diff --git a/browser_tests/tests/extensionAPI.spec.ts b/browser_tests/tests/extensionAPI.spec.ts index 7711ccf3b..09a08384c 100644 --- a/browser_tests/tests/extensionAPI.spec.ts +++ b/browser_tests/tests/extensionAPI.spec.ts @@ -1,6 +1,6 @@ import { expect } from '@playwright/test' -import { SettingParams } from '../../src/types/settingTypes' +import { SettingParams } from '../../src/platform/settings/types' import { comfyPageFixture as test } from '../fixtures/ComfyPage' test.describe('Topbar commands', () => { @@ -247,7 +247,7 @@ test.describe('Topbar commands', () => { test.describe('Dialog', () => { test('Should allow showing a prompt dialog', async ({ comfyPage }) => { await comfyPage.page.evaluate(() => { - window['app'].extensionManager.dialog + void window['app'].extensionManager.dialog .prompt({ title: 'Test Prompt', message: 'Test Prompt Message' @@ -267,7 +267,7 @@ test.describe('Topbar commands', () => { comfyPage }) => { await comfyPage.page.evaluate(() => { - window['app'].extensionManager.dialog + void window['app'].extensionManager.dialog .confirm({ title: 'Test Confirm', message: 'Test Confirm Message' @@ -284,7 +284,7 @@ test.describe('Topbar commands', () => { test('Should allow dismissing a dialog', async ({ comfyPage }) => { await comfyPage.page.evaluate(() => { window['value'] = 'foo' - window['app'].extensionManager.dialog + void window['app'].extensionManager.dialog .confirm({ title: 'Test Confirm', message: 'Test Confirm Message' diff --git a/browser_tests/tests/nodeHelp.spec.ts b/browser_tests/tests/nodeHelp.spec.ts index 68ce7b8d5..764849286 100644 --- a/browser_tests/tests/nodeHelp.spec.ts +++ b/browser_tests/tests/nodeHelp.spec.ts @@ -46,7 +46,7 @@ test.describe('Node Help', () => { // Click the help button in the selection toolbox const helpButton = comfyPage.selectionToolbox.locator( - 'button:has(.pi-question-circle)' + 'button[data-testid="info-button"]' ) await expect(helpButton).toBeVisible() await helpButton.click() @@ -164,7 +164,7 @@ test.describe('Node Help', () => { // Click help button const helpButton = comfyPage.page.locator( - '.selection-toolbox button:has(.pi-question-circle)' + '.selection-toolbox button[data-testid="info-button"]' ) await helpButton.click() @@ -194,7 +194,7 @@ test.describe('Node Help', () => { // Click help button const helpButton = comfyPage.page.locator( - '.selection-toolbox button:has(.pi-question-circle)' + '.selection-toolbox button[data-testid="info-button"]' ) await helpButton.click() @@ -228,7 +228,7 @@ test.describe('Node Help', () => { await selectNodeWithPan(comfyPage, ksamplerNodes[0]) const helpButton = comfyPage.page.locator( - '.selection-toolbox button:has(.pi-question-circle)' + '.selection-toolbox button[data-testid="info-button"]' ) await helpButton.click() @@ -276,7 +276,7 @@ test.describe('Node Help', () => { await selectNodeWithPan(comfyPage, ksamplerNodes[0]) const helpButton = comfyPage.page.locator( - '.selection-toolbox button:has(.pi-question-circle)' + '.selection-toolbox button[data-testid="info-button"]' ) await helpButton.click() @@ -348,7 +348,7 @@ This is documentation for a custom node. } const helpButton = comfyPage.page.locator( - '.selection-toolbox button:has(.pi-question-circle)' + '.selection-toolbox button[data-testid="info-button"]' ) if (await helpButton.isVisible()) { await helpButton.click() @@ -389,7 +389,7 @@ This is documentation for a custom node. await selectNodeWithPan(comfyPage, ksamplerNodes[0]) const helpButton = comfyPage.page.locator( - '.selection-toolbox button:has(.pi-question-circle)' + '.selection-toolbox button[data-testid="info-button"]' ) await helpButton.click() @@ -456,7 +456,7 @@ This is English documentation. await selectNodeWithPan(comfyPage, ksamplerNodes[0]) const helpButton = comfyPage.page.locator( - '.selection-toolbox button:has(.pi-question-circle)' + '.selection-toolbox button[data-testid="info-button"]' ) await helpButton.click() @@ -479,7 +479,7 @@ This is English documentation. await selectNodeWithPan(comfyPage, ksamplerNodes[0]) const helpButton = comfyPage.page.locator( - '.selection-toolbox button:has(.pi-question-circle)' + '.selection-toolbox button[data-testid="info-button"]' ) await helpButton.click() @@ -522,7 +522,7 @@ This is English documentation. await selectNodeWithPan(comfyPage, ksamplerNodes[0]) const helpButton = comfyPage.page.locator( - '.selection-toolbox button:has(.pi-question-circle)' + '.selection-toolbox button[data-testid="info-button"]' ) await helpButton.click() @@ -538,7 +538,7 @@ This is English documentation. // Click help button again const helpButton2 = comfyPage.page.locator( - '.selection-toolbox button:has(.pi-question-circle)' + '.selection-toolbox button[data-testid="info-button"]' ) await helpButton2.click() diff --git a/browser_tests/tests/remoteWidgets.spec.ts b/browser_tests/tests/remoteWidgets.spec.ts index 4a390af96..05bb578df 100644 --- a/browser_tests/tests/remoteWidgets.spec.ts +++ b/browser_tests/tests/remoteWidgets.spec.ts @@ -190,7 +190,9 @@ test.describe('Remote COMBO Widget', () => { await comfyPage.page.keyboard.press('Control+A') await expect( - comfyPage.page.locator('.selection-toolbox .pi-refresh') + comfyPage.page.locator( + '.selection-toolbox button[data-testid="refresh-button"]' + ) ).toBeVisible() }) diff --git a/browser_tests/tests/rightClickMenu.spec.ts-snapshots/add-node-node-added-chromium-linux.png b/browser_tests/tests/rightClickMenu.spec.ts-snapshots/add-node-node-added-chromium-linux.png index 9443182e3..2755d74c5 100644 Binary files a/browser_tests/tests/rightClickMenu.spec.ts-snapshots/add-node-node-added-chromium-linux.png and b/browser_tests/tests/rightClickMenu.spec.ts-snapshots/add-node-node-added-chromium-linux.png differ diff --git a/browser_tests/tests/selectionToolbox.spec.ts-snapshots/selection-toolbox-multiple-nodes-border-chromium-linux.png b/browser_tests/tests/selectionToolbox.spec.ts-snapshots/selection-toolbox-multiple-nodes-border-chromium-linux.png index 7aa22906b..96f6507e1 100644 Binary files a/browser_tests/tests/selectionToolbox.spec.ts-snapshots/selection-toolbox-multiple-nodes-border-chromium-linux.png and b/browser_tests/tests/selectionToolbox.spec.ts-snapshots/selection-toolbox-multiple-nodes-border-chromium-linux.png differ diff --git a/browser_tests/tests/selectionToolbox.spec.ts-snapshots/selection-toolbox-multiple-selections-border-chromium-linux.png b/browser_tests/tests/selectionToolbox.spec.ts-snapshots/selection-toolbox-multiple-selections-border-chromium-linux.png index 41bb283d9..af92221f3 100644 Binary files a/browser_tests/tests/selectionToolbox.spec.ts-snapshots/selection-toolbox-multiple-selections-border-chromium-linux.png and b/browser_tests/tests/selectionToolbox.spec.ts-snapshots/selection-toolbox-multiple-selections-border-chromium-linux.png differ diff --git a/browser_tests/tests/selectionToolbox.spec.ts-snapshots/selection-toolbox-single-node-no-border-chromium-linux.png b/browser_tests/tests/selectionToolbox.spec.ts-snapshots/selection-toolbox-single-node-no-border-chromium-linux.png index a9d9bafce..f9b9b012c 100644 Binary files a/browser_tests/tests/selectionToolbox.spec.ts-snapshots/selection-toolbox-single-node-no-border-chromium-linux.png and b/browser_tests/tests/selectionToolbox.spec.ts-snapshots/selection-toolbox-single-node-no-border-chromium-linux.png differ diff --git a/browser_tests/tests/selectionToolboxSubmenus.spec.ts b/browser_tests/tests/selectionToolboxSubmenus.spec.ts new file mode 100644 index 000000000..a7311c15a --- /dev/null +++ b/browser_tests/tests/selectionToolboxSubmenus.spec.ts @@ -0,0 +1,177 @@ +import { expect } from '@playwright/test' + +import { comfyPageFixture as test } from '../fixtures/ComfyPage' + +test.describe('Selection Toolbox - More Options Submenus', () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.Canvas.SelectionToolbox', true) + await comfyPage.loadWorkflow('nodes/single_ksampler') + await comfyPage.nextFrame() + await comfyPage.selectNodes(['KSampler']) + await comfyPage.nextFrame() + }) + + const openMoreOptions = async (comfyPage: any) => { + const ksamplerNodes = await comfyPage.getNodeRefsByTitle('KSampler') + if (ksamplerNodes.length === 0) { + throw new Error('No KSampler nodes found') + } + + // Drag the KSampler to the center of the screen + const nodePos = await ksamplerNodes[0].getPosition() + const viewportSize = comfyPage.page.viewportSize() + const centerX = viewportSize.width / 3 + const centerY = viewportSize.height / 2 + await comfyPage.dragAndDrop( + { x: nodePos.x, y: nodePos.y }, + { x: centerX, y: centerY } + ) + await comfyPage.nextFrame() + + await ksamplerNodes[0].click('title') + await comfyPage.nextFrame() + await comfyPage.page.waitForTimeout(500) + + await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible({ + timeout: 5000 + }) + + const moreOptionsBtn = comfyPage.page.locator( + '[data-testid="more-options-button"]' + ) + await expect(moreOptionsBtn).toBeVisible({ timeout: 3000 }) + + await comfyPage.page.click('[data-testid="more-options-button"]') + + await comfyPage.nextFrame() + + const menuOptionsVisible = await comfyPage.page + .getByText('Rename') + .isVisible({ timeout: 2000 }) + .catch(() => false) + if (menuOptionsVisible) { + return + } + + await moreOptionsBtn.click({ force: true }) + await comfyPage.nextFrame() + await comfyPage.page.waitForTimeout(2000) + + const menuOptionsVisibleAfterClick = await comfyPage.page + .getByText('Rename') + .isVisible({ timeout: 2000 }) + .catch(() => false) + if (menuOptionsVisibleAfterClick) { + return + } + + throw new Error('Could not open More Options menu - popover not showing') + } + + test('opens Node Info from More Options menu', async ({ comfyPage }) => { + await openMoreOptions(comfyPage) + const nodeInfoButton = comfyPage.page.getByText('Node Info', { + exact: true + }) + await expect(nodeInfoButton).toBeVisible() + await nodeInfoButton.click() + await comfyPage.nextFrame() + }) + + test('changes node shape via Shape submenu', async ({ comfyPage }) => { + const nodeRef = (await comfyPage.getNodeRefsByTitle('KSampler'))[0] + const initialShape = await nodeRef.getProperty('shape') + + await openMoreOptions(comfyPage) + await comfyPage.page.getByText('Shape', { exact: true }).click() + await expect(comfyPage.page.getByText('Box', { exact: true })).toBeVisible({ + timeout: 5000 + }) + await comfyPage.page.getByText('Box', { exact: true }).click() + await comfyPage.nextFrame() + + const newShape = await nodeRef.getProperty('shape') + expect(newShape).not.toBe(initialShape) + expect(newShape).toBe(1) + }) + + test('changes node color via Color submenu swatch', async ({ comfyPage }) => { + const nodeRef = (await comfyPage.getNodeRefsByTitle('KSampler'))[0] + const initialColor = await nodeRef.getProperty('color') + + await openMoreOptions(comfyPage) + await comfyPage.page.getByText('Color', { exact: true }).click() + const blueSwatch = comfyPage.page.locator('[title="Blue"]') + await expect(blueSwatch.first()).toBeVisible({ timeout: 5000 }) + await blueSwatch.first().click() + await comfyPage.nextFrame() + + const newColor = await nodeRef.getProperty('color') + expect(newColor).toBe('#223') + if (initialColor) { + expect(newColor).not.toBe(initialColor) + } + }) + + test('renames a node using Rename action', async ({ comfyPage }) => { + const nodeRef = (await comfyPage.getNodeRefsByTitle('KSampler'))[0] + await openMoreOptions(comfyPage) + await comfyPage.page + .getByText('Rename', { exact: true }) + .click({ force: true }) + const input = comfyPage.page.locator( + '.group-title-editor.node-title-editor .editable-text input' + ) + await expect(input).toBeVisible() + await input.fill('RenamedNode') + await input.press('Enter') + await comfyPage.nextFrame() + const newTitle = await nodeRef.getProperty('title') + expect(newTitle).toBe('RenamedNode') + }) + + test('closes More Options menu when clicking outside', async ({ + comfyPage + }) => { + await openMoreOptions(comfyPage) + await expect( + comfyPage.page.getByText('Rename', { exact: true }) + ).toBeVisible({ timeout: 5000 }) + + await comfyPage.page + .locator('#graph-canvas') + .click({ position: { x: 0, y: 50 }, force: true }) + await comfyPage.nextFrame() + await expect( + comfyPage.page.getByText('Rename', { exact: true }) + ).not.toBeVisible() + }) + + test('closes More Options menu when clicking the button again (toggle)', async ({ + comfyPage + }) => { + await openMoreOptions(comfyPage) + await expect( + comfyPage.page.getByText('Rename', { exact: true }) + ).toBeVisible({ timeout: 5000 }) + + await comfyPage.page.evaluate(() => { + const btn = document.querySelector('[data-testid="more-options-button"]') + if (btn) { + const event = new MouseEvent('click', { + bubbles: true, + cancelable: true, + view: window, + detail: 1 + }) + btn.dispatchEvent(event) + } + }) + await comfyPage.nextFrame() + await comfyPage.page.waitForTimeout(500) + + await expect( + comfyPage.page.getByText('Rename', { exact: true }) + ).not.toBeVisible() + }) +}) diff --git a/browser_tests/tests/vueNodes/deleteKeyInteraction.spec.ts b/browser_tests/tests/vueNodes/deleteKeyInteraction.spec.ts new file mode 100644 index 000000000..a00d93eb0 --- /dev/null +++ b/browser_tests/tests/vueNodes/deleteKeyInteraction.spec.ts @@ -0,0 +1,141 @@ +import { expect } from '@playwright/test' + +import { comfyPageFixture as test } from '../../fixtures/ComfyPage' + +test.describe('Vue Nodes - Delete Key Interaction', () => { + test.beforeEach(async ({ comfyPage }) => { + // Enable Vue nodes rendering + await comfyPage.setSetting('Comfy.VueNodes.Enabled', true) + await comfyPage.setSetting('Comfy.Graph.CanvasMenu', false) + await comfyPage.setup() + }) + + test('Can select all and delete Vue nodes with Delete key', async ({ + comfyPage + }) => { + await comfyPage.vueNodes.waitForNodes() + + // Get initial Vue node count + const initialNodeCount = await comfyPage.vueNodes.getNodeCount() + expect(initialNodeCount).toBeGreaterThan(0) + + // Select all Vue nodes + await comfyPage.ctrlA() + + // Verify all Vue nodes are selected + const selectedCount = await comfyPage.vueNodes.getSelectedNodeCount() + expect(selectedCount).toBe(initialNodeCount) + + // Delete with Delete key + await comfyPage.vueNodes.deleteSelected() + + // Verify all Vue nodes were deleted + const finalNodeCount = await comfyPage.vueNodes.getNodeCount() + expect(finalNodeCount).toBe(0) + }) + + test('Can select specific Vue node and delete it', async ({ comfyPage }) => { + await comfyPage.vueNodes.waitForNodes() + + // Get initial Vue node count + const initialNodeCount = await comfyPage.vueNodes.getNodeCount() + expect(initialNodeCount).toBeGreaterThan(0) + + // Get first Vue node ID and select it + const nodeIds = await comfyPage.vueNodes.getNodeIds() + await comfyPage.vueNodes.selectNode(nodeIds[0]) + + // Verify selection + const selectedCount = await comfyPage.vueNodes.getSelectedNodeCount() + expect(selectedCount).toBe(1) + + // Delete with Delete key + await comfyPage.vueNodes.deleteSelected() + + // Verify one Vue node was deleted + const finalNodeCount = await comfyPage.vueNodes.getNodeCount() + expect(finalNodeCount).toBe(initialNodeCount - 1) + }) + + test('Can select and delete Vue node with Backspace key', async ({ + comfyPage + }) => { + await comfyPage.vueNodes.waitForNodes() + + const initialNodeCount = await comfyPage.vueNodes.getNodeCount() + + // Select first Vue node + const nodeIds = await comfyPage.vueNodes.getNodeIds() + await comfyPage.vueNodes.selectNode(nodeIds[0]) + + // Delete with Backspace key instead of Delete + await comfyPage.vueNodes.deleteSelectedWithBackspace() + + // Verify Vue node was deleted + const finalNodeCount = await comfyPage.vueNodes.getNodeCount() + expect(finalNodeCount).toBe(initialNodeCount - 1) + }) + + test('Delete key does not delete node when typing in Vue node widgets', async ({ + comfyPage + }) => { + const initialNodeCount = await comfyPage.getGraphNodesCount() + + // Find a text input widget in a Vue node + const textWidget = comfyPage.page + .locator('input[type="text"], textarea') + .first() + + // Click on text widget to focus it + await textWidget.click() + await textWidget.fill('test text') + + // Press Delete while focused on widget - should delete text, not node + await textWidget.press('Delete') + + // Node count should remain the same + const finalNodeCount = await comfyPage.getGraphNodesCount() + expect(finalNodeCount).toBe(initialNodeCount) + }) + + test('Delete key does not delete node when nothing is selected', async ({ + comfyPage + }) => { + await comfyPage.vueNodes.waitForNodes() + + // Ensure no Vue nodes are selected + await comfyPage.vueNodes.clearSelection() + const selectedCount = await comfyPage.vueNodes.getSelectedNodeCount() + expect(selectedCount).toBe(0) + + // Press Delete key - should not crash and should handle gracefully + await comfyPage.page.keyboard.press('Delete') + + // Vue node count should remain the same + const nodeCount = await comfyPage.vueNodes.getNodeCount() + expect(nodeCount).toBeGreaterThan(0) + }) + + test('Can multi-select with Ctrl+click and delete multiple Vue nodes', async ({ + comfyPage + }) => { + await comfyPage.vueNodes.waitForNodes() + const initialNodeCount = await comfyPage.vueNodes.getNodeCount() + + // Multi-select first two Vue nodes using Ctrl+click + const nodeIds = await comfyPage.vueNodes.getNodeIds() + const nodesToSelect = nodeIds.slice(0, 2) + await comfyPage.vueNodes.selectNodes(nodesToSelect) + + // Verify expected nodes are selected + const selectedCount = await comfyPage.vueNodes.getSelectedNodeCount() + expect(selectedCount).toBe(nodesToSelect.length) + + // Delete selected Vue nodes + await comfyPage.vueNodes.deleteSelected() + + // Verify expected nodes were deleted + const finalNodeCount = await comfyPage.vueNodes.getNodeCount() + expect(finalNodeCount).toBe(initialNodeCount - nodesToSelect.length) + }) +}) diff --git a/browser_tests/tests/widget.spec.ts b/browser_tests/tests/widget.spec.ts index b23faabfc..728b5d028 100644 --- a/browser_tests/tests/widget.spec.ts +++ b/browser_tests/tests/widget.spec.ts @@ -264,7 +264,13 @@ test.describe('Animated image widget', () => { expect(filename).toContain('animated_webp.webp') }) - test('Can preview saved animated webp image', async ({ comfyPage }) => { + // FIXME: This test keeps flip-flopping because it relies on animated webp timing, + // which is inherently unreliable in CI environments. The test asset is an animated + // webp with 2 frames, and the test depends on animation frame timing to verify that + // animated webp images are properly displayed (as opposed to being treated as static webp). + // While the underlying functionality works (animated webp are correctly distinguished + // from static webp), the test is flaky due to timing dependencies with webp animation frames. + test.fixme('Can preview saved animated webp image', async ({ comfyPage }) => { await comfyPage.loadWorkflow('widgets/save_animated_webp') // Get position of the load animated webp node diff --git a/browser_tests/tests/widget.spec.ts-snapshots/animated-image-preview-saved-webp-chromium-linux.png b/browser_tests/tests/widget.spec.ts-snapshots/animated-image-preview-saved-webp-chromium-linux.png deleted file mode 100644 index 3f3d831bb..000000000 Binary files a/browser_tests/tests/widget.spec.ts-snapshots/animated-image-preview-saved-webp-chromium-linux.png and /dev/null differ diff --git a/package.json b/package.json index 66efdbce3..1fec05c6c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@comfyorg/comfyui-frontend", "private": true, - "version": "1.27.3", + "version": "1.27.4", "type": "module", "repository": "https://github.com/Comfy-Org/ComfyUI_frontend", "homepage": "https://comfy.org", @@ -14,9 +14,9 @@ "build:types": "nx build --config vite.types.config.mts && node scripts/prepare-types.js", "zipdist": "node scripts/zipdist.js", "typecheck": "vue-tsc --noEmit", - "format": "prettier --write './**/*.{js,ts,tsx,vue,mts}' --cache", + "format": "prettier --write './**/*.{js,ts,tsx,vue,mts}' --cache --list-different", "format:check": "prettier --check './**/*.{js,ts,tsx,vue,mts}' --cache", - "format:no-cache": "prettier --write './**/*.{js,ts,tsx,vue,mts}'", + "format:no-cache": "prettier --write './**/*.{js,ts,tsx,vue,mts}' --list-different", "format:check:no-cache": "prettier --check './**/*.{js,ts,tsx,vue,mts}'", "test:browser": "npx nx e2e", "test:unit": "nx run test tests-ui/tests", @@ -101,7 +101,7 @@ "dependencies": { "@alloc/quick-lru": "^5.2.0", "@atlaskit/pragmatic-drag-and-drop": "^1.3.1", - "@comfyorg/comfyui-electron-types": "^0.4.72", + "@comfyorg/comfyui-electron-types": "0.4.73-0", "@iconify/json": "^2.2.380", "@primeuix/forms": "0.0.2", "@primeuix/styled": "0.3.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ce404afc4..acc01cc0c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,8 +15,8 @@ importers: specifier: ^1.3.1 version: 1.3.1 '@comfyorg/comfyui-electron-types': - specifier: ^0.4.72 - version: 0.4.72 + specifier: 0.4.73-0 + version: 0.4.73-0 '@iconify/json': specifier: ^2.2.380 version: 2.2.380 @@ -986,8 +986,8 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} - '@comfyorg/comfyui-electron-types@0.4.72': - resolution: {integrity: sha512-Ecf0XYOKDqqIcnjSWL8GHLo6MOsuwqs0I1QgWc3Hv+BZm+qUE4vzOXCyhfFoTIGHLZFTwe37gnygPPKFzMu00Q==} + '@comfyorg/comfyui-electron-types@0.4.73-0': + resolution: {integrity: sha512-WlItGJQx9ZWShNG9wypx3kq+19pSig/U+s5sD2SAeEcMph4u8A/TS+lnRgdKhT58VT1uD7cMcj2SJpfdBPNWvw==} '@csstools/color-helpers@5.1.0': resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} @@ -7502,7 +7502,7 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} - '@comfyorg/comfyui-electron-types@0.4.72': {} + '@comfyorg/comfyui-electron-types@0.4.73-0': {} '@csstools/color-helpers@5.1.0': {} diff --git a/public/fonts/inter-latin-italic.woff2 b/public/fonts/inter-latin-italic.woff2 new file mode 100644 index 000000000..39eb63673 Binary files /dev/null and b/public/fonts/inter-latin-italic.woff2 differ diff --git a/public/fonts/inter-latin-normal.woff2 b/public/fonts/inter-latin-normal.woff2 new file mode 100644 index 000000000..b0d0e2e5c Binary files /dev/null and b/public/fonts/inter-latin-normal.woff2 differ diff --git a/scripts/collect-i18n-general.ts b/scripts/collect-i18n-general.ts index 63d97d530..f0b6dde0c 100644 --- a/scripts/collect-i18n-general.ts +++ b/scripts/collect-i18n-general.ts @@ -3,8 +3,8 @@ import * as fs from 'fs' import { comfyPageFixture as test } from '../browser_tests/fixtures/ComfyPage' import { CORE_MENU_COMMANDS } from '../src/constants/coreMenuCommands' import { SERVER_CONFIG_ITEMS } from '../src/constants/serverConfig' +import type { FormItem, SettingParams } from '../src/platform/settings/types' import type { ComfyCommandImpl } from '../src/stores/commandStore' -import type { FormItem, SettingParams } from '../src/types/settingTypes' import { formatCamelCase, normalizeI18nKey } from '../src/utils/formatUtil' const localePath = './src/locales/en/main.json' diff --git a/scripts/generate-json-schema.ts b/scripts/generate-json-schema.ts index 76aa41b2e..1a2056626 100644 --- a/scripts/generate-json-schema.ts +++ b/scripts/generate-json-schema.ts @@ -5,7 +5,7 @@ import { zodToJsonSchema } from 'zod-to-json-schema' import { zComfyWorkflow, zComfyWorkflow1 -} from '../src/schemas/comfyWorkflowSchema' +} from '../src/platform/workflow/validation/schemas/workflowSchema' import { zComfyNodeDef as zComfyNodeDefV2 } from '../src/schemas/nodeDef/nodeDefSchemaV2' import { zComfyNodeDef as zComfyNodeDefV1 } from '../src/schemas/nodeDefSchema' diff --git a/src/assets/css/fonts.css b/src/assets/css/fonts.css new file mode 100644 index 000000000..cea388ee7 --- /dev/null +++ b/src/assets/css/fonts.css @@ -0,0 +1,17 @@ +/* Inter Font Family */ + +@font-face { + font-family: 'Inter'; + src: url('/fonts/inter-latin-normal.woff2') format('woff2'); + font-weight: 100 900; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Inter'; + src: url('/fonts/inter-latin-italic.woff2') format('woff2'); + font-weight: 100 900; + font-style: italic; + font-display: swap; +} diff --git a/src/assets/css/style.css b/src/assets/css/style.css index 9c49e1704..747c3d1f6 100644 --- a/src/assets/css/style.css +++ b/src/assets/css/style.css @@ -1,5 +1,6 @@ @layer theme, base, primevue, components, utilities; +@import './fonts.css'; @import 'tailwindcss/theme' layer(theme); @import 'tailwindcss/utilities' layer(utilities); @import 'tw-animate-css'; @@ -52,15 +53,18 @@ --text-xxs: 0.625rem; --text-xxs--line-height: calc(1 / 0.625); + /* Font Families */ + --font-inter: 'Inter', sans-serif; + /* Palette Colors */ - --color-charcoal-100: #171718; - --color-charcoal-200: #202121; - --color-charcoal-300: #262729; - --color-charcoal-400: #2d2e32; - --color-charcoal-500: #313235; - --color-charcoal-600: #3c3d42; - --color-charcoal-700: #494a50; - --color-charcoal-800: #55565e; + --color-charcoal-100: #55565e; + --color-charcoal-200: #494a50; + --color-charcoal-300: #3c3d42; + --color-charcoal-400: #313235; + --color-charcoal-500: #2d2e32; + --color-charcoal-600: #262729; + --color-charcoal-700: #202121; + --color-charcoal-800: #171718; --color-stone-100: #444444; --color-stone-200: #828282; @@ -99,11 +103,12 @@ --color-danger-100: #c02323; --color-danger-200: #d62952; + --color-bypass: #6a246a; --color-error: #962a2a; - --color-blue-selection: rgb( from var(--color-blue-100) r g b / 0.3); - --color-node-hover-100: rgb( from var(--color-charcoal-800) r g b/ 0.15); - --color-node-hover-200: rgb(from var(--color-charcoal-800) r g b/ 0.1); + --color-blue-selection: rgb(from var(--color-blue-100) r g b / 0.3); + --color-node-hover-100: rgb(from var(--color-charcoal-100) r g b/ 0.15); + --color-node-hover-200: rgb(from var(--color-charcoal-100) r g b/ 0.1); --color-modal-tag: rgb(from var(--color-gray-400) r g b/ 0.4); /* PrimeVue pulled colors */ @@ -116,10 +121,10 @@ } @theme inline { - --color-node-component-surface: var(--color-charcoal-300); + --color-node-component-surface: var(--color-charcoal-600); --color-node-component-surface-highlight: var(--color-slate-100); - --color-node-component-surface-hovered: var(--color-charcoal-500); - --color-node-component-surface-selected: var(--color-charcoal-700); + --color-node-component-surface-hovered: var(--color-charcoal-400); + --color-node-component-surface-selected: var(--color-charcoal-200); --color-node-stroke: var(--color-stone-100); } @@ -131,7 +136,7 @@ @utility scrollbar-hide { scrollbar-width: none; - &::-webkit-scrollbar { + &::-webkit-scrollbar { width: 1px; } &::-webkit-scrollbar-thumb { @@ -961,9 +966,7 @@ audio.comfy-audio.empty-audio-widget { /* Uses default styling - no overrides needed */ } -/* Smooth transitions between LOD levels */ .lg-node { - transition: min-height 0.2s ease; /* Disable text selection on all nodes */ user-select: none; -webkit-user-select: none; diff --git a/src/assets/icons/custom/mask.svg b/src/assets/icons/custom/mask.svg new file mode 100644 index 000000000..1e1a6d97c --- /dev/null +++ b/src/assets/icons/custom/mask.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/base/common/downloadUtil.ts b/src/base/common/downloadUtil.ts new file mode 100644 index 000000000..307a3e35b --- /dev/null +++ b/src/base/common/downloadUtil.ts @@ -0,0 +1,41 @@ +/** + * Utility functions for downloading files + */ + +// Constants +const DEFAULT_DOWNLOAD_FILENAME = 'download.png' + +/** + * Download a file from a URL by creating a temporary anchor element + * @param url - The URL of the file to download (must be a valid URL string) + * @param filename - Optional filename override (will use URL filename or default if not provided) + * @throws {Error} If the URL is invalid or empty + */ +export const downloadFile = (url: string, filename?: string): void => { + if (!url || typeof url !== 'string' || url.trim().length === 0) { + throw new Error('Invalid URL provided for download') + } + const link = document.createElement('a') + link.href = url + link.download = + filename || extractFilenameFromUrl(url) || DEFAULT_DOWNLOAD_FILENAME + + // Trigger download + document.body.appendChild(link) + link.click() + document.body.removeChild(link) +} + +/** + * Extract filename from a URL's query parameters + * @param url - The URL to extract filename from + * @returns The extracted filename or null if not found + */ +const extractFilenameFromUrl = (url: string): string | null => { + try { + const urlObj = new URL(url, window.location.origin) + return urlObj.searchParams.get('filename') + } catch { + return null + } +} diff --git a/src/components/LiteGraphCanvasSplitterOverlay.vue b/src/components/LiteGraphCanvasSplitterOverlay.vue index bd5070250..6e3cd5842 100644 --- a/src/components/LiteGraphCanvasSplitterOverlay.vue +++ b/src/components/LiteGraphCanvasSplitterOverlay.vue @@ -50,7 +50,7 @@ import Splitter from 'primevue/splitter' import SplitterPanel from 'primevue/splitterpanel' import { computed } from 'vue' -import { useSettingStore } from '@/stores/settingStore' +import { useSettingStore } from '@/platform/settings/settingStore' import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore' import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore' diff --git a/src/components/MenuHamburger.vue b/src/components/MenuHamburger.vue index 58c725763..b46c27e27 100644 --- a/src/components/MenuHamburger.vue +++ b/src/components/MenuHamburger.vue @@ -23,8 +23,8 @@ import Button from 'primevue/button' import { CSSProperties, computed, watchEffect } from 'vue' +import { useSettingStore } from '@/platform/settings/settingStore' import { app } from '@/scripts/app' -import { useSettingStore } from '@/stores/settingStore' import { useWorkspaceStore } from '@/stores/workspaceStore' import { showNativeSystemMenu } from '@/utils/envUtil' diff --git a/src/components/actionbar/BatchCountEdit.vue b/src/components/actionbar/BatchCountEdit.vue index 281b886df..603ca6067 100644 --- a/src/components/actionbar/BatchCountEdit.vue +++ b/src/components/actionbar/BatchCountEdit.vue @@ -37,8 +37,8 @@ import { storeToRefs } from 'pinia' import InputNumber from 'primevue/inputnumber' import { computed } from 'vue' +import { useSettingStore } from '@/platform/settings/settingStore' import { useQueueSettingsStore } from '@/stores/queueStore' -import { useSettingStore } from '@/stores/settingStore' const queueSettingsStore = useQueueSettingsStore() const { batchCount } = storeToRefs(queueSettingsStore) diff --git a/src/components/actionbar/ComfyActionbar.vue b/src/components/actionbar/ComfyActionbar.vue index 44bd579a6..589815dc8 100644 --- a/src/components/actionbar/ComfyActionbar.vue +++ b/src/components/actionbar/ComfyActionbar.vue @@ -24,7 +24,7 @@ import { clamp } from 'es-toolkit/compat' import Panel from 'primevue/panel' import { Ref, computed, inject, nextTick, onMounted, ref, watch } from 'vue' -import { useSettingStore } from '@/stores/settingStore' +import { useSettingStore } from '@/platform/settings/settingStore' import ComfyQueueButton from './ComfyQueueButton.vue' diff --git a/src/components/bottomPanel/tabs/terminal/BaseTerminal.vue b/src/components/bottomPanel/tabs/terminal/BaseTerminal.vue index 62741a4e5..52cb75a7f 100644 --- a/src/components/bottomPanel/tabs/terminal/BaseTerminal.vue +++ b/src/components/bottomPanel/tabs/terminal/BaseTerminal.vue @@ -3,13 +3,37 @@
+
diff --git a/src/components/graph/selectionToolbox/ExtensionCommandButton.vue b/src/components/graph/selectionToolbox/ExtensionCommandButton.vue index 3dc84f710..92cd09e38 100644 --- a/src/components/graph/selectionToolbox/ExtensionCommandButton.vue +++ b/src/components/graph/selectionToolbox/ExtensionCommandButton.vue @@ -7,6 +7,7 @@ }" severity="secondary" text + icon-class="w-4 h-4" :icon="typeof command.icon === 'function' ? command.icon() : command.icon" @click="() => commandStore.execute(command.id)" /> diff --git a/src/components/graph/selectionToolbox/FrameNodes.vue b/src/components/graph/selectionToolbox/FrameNodes.vue new file mode 100644 index 000000000..6d800a16f --- /dev/null +++ b/src/components/graph/selectionToolbox/FrameNodes.vue @@ -0,0 +1,22 @@ + + + diff --git a/src/components/graph/selectionToolbox/HelpButton.vue b/src/components/graph/selectionToolbox/HelpButton.vue deleted file mode 100644 index e77701bd4..000000000 --- a/src/components/graph/selectionToolbox/HelpButton.vue +++ /dev/null @@ -1,49 +0,0 @@ - - - diff --git a/src/components/graph/selectionToolbox/InfoButton.spec.ts b/src/components/graph/selectionToolbox/InfoButton.spec.ts new file mode 100644 index 000000000..da2a13831 --- /dev/null +++ b/src/components/graph/selectionToolbox/InfoButton.spec.ts @@ -0,0 +1,149 @@ +import { mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import PrimeVue from 'primevue/config' +import Tooltip from 'primevue/tooltip' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createI18n } from 'vue-i18n' + +import InfoButton from '@/components/graph/selectionToolbox/InfoButton.vue' +// NOTE: The component import must come after mocks so they take effect. +import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' +import { useNodeDefStore } from '@/stores/nodeDefStore' + +const mockLGraphNode = { + type: 'TestNode', + title: 'Test Node' +} + +vi.mock('@/utils/litegraphUtil', () => ({ + isLGraphNode: vi.fn(() => true) +})) + +vi.mock('@/composables/sidebarTabs/useNodeLibrarySidebarTab', () => ({ + useNodeLibrarySidebarTab: () => ({ + id: 'node-library' + }) +})) + +const openHelpMock = vi.fn() +const closeHelpMock = vi.fn() +const nodeHelpState: { currentHelpNode: any } = { currentHelpNode: null } +vi.mock('@/stores/workspace/nodeHelpStore', () => ({ + useNodeHelpStore: () => ({ + openHelp: (def: any) => { + nodeHelpState.currentHelpNode = def + openHelpMock(def) + }, + closeHelp: () => { + nodeHelpState.currentHelpNode = null + closeHelpMock() + }, + get currentHelpNode() { + return nodeHelpState.currentHelpNode + }, + get isHelpOpen() { + return nodeHelpState.currentHelpNode !== null + } + }) +})) + +const toggleSidebarTabMock = vi.fn((id: string) => { + sidebarState.activeSidebarTabId = + sidebarState.activeSidebarTabId === id ? null : id +}) +const sidebarState: { activeSidebarTabId: string | null } = { + activeSidebarTabId: 'other-tab' +} +vi.mock('@/stores/workspace/sidebarTabStore', () => ({ + useSidebarTabStore: () => ({ + get activeSidebarTabId() { + return sidebarState.activeSidebarTabId + }, + toggleSidebarTab: toggleSidebarTabMock + }) +})) + +describe('InfoButton', () => { + let canvasStore: ReturnType + let nodeDefStore: ReturnType + + const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { + en: { + g: { + info: 'Node Info' + } + } + } + }) + + beforeEach(() => { + setActivePinia(createPinia()) + canvasStore = useCanvasStore() + nodeDefStore = useNodeDefStore() + + vi.clearAllMocks() + }) + + const mountComponent = () => { + return mount(InfoButton, { + global: { + plugins: [i18n, PrimeVue], + directives: { tooltip: Tooltip }, + stubs: { + 'i-lucide:info': true, + Button: { + template: + '', + props: ['severity', 'text', 'class'], + emits: ['click'] + } + } + } + }) + } + + it('should handle click without errors', async () => { + const mockNodeDef = { + nodePath: 'test/node', + display_name: 'Test Node' + } + canvasStore.selectedItems = [mockLGraphNode] as any + vi.spyOn(nodeDefStore, 'fromLGraphNode').mockReturnValue(mockNodeDef as any) + const wrapper = mountComponent() + const button = wrapper.find('button') + await button.trigger('click') + expect(button.exists()).toBe(true) + }) + + it('should have correct CSS classes', () => { + const mockNodeDef = { + nodePath: 'test/node', + display_name: 'Test Node' + } + canvasStore.selectedItems = [mockLGraphNode] as any + vi.spyOn(nodeDefStore, 'fromLGraphNode').mockReturnValue(mockNodeDef as any) + + const wrapper = mountComponent() + const button = wrapper.find('button') + + expect(button.classes()).toContain('help-button') + expect(button.attributes('severity')).toBe('secondary') + }) + + it('should have correct tooltip', () => { + const mockNodeDef = { + nodePath: 'test/node', + display_name: 'Test Node' + } + canvasStore.selectedItems = [mockLGraphNode] as any + vi.spyOn(nodeDefStore, 'fromLGraphNode').mockReturnValue(mockNodeDef as any) + + const wrapper = mountComponent() + const button = wrapper.find('button') + + expect(button.exists()).toBe(true) + }) +}) diff --git a/src/components/graph/selectionToolbox/InfoButton.vue b/src/components/graph/selectionToolbox/InfoButton.vue new file mode 100644 index 000000000..3fd159d89 --- /dev/null +++ b/src/components/graph/selectionToolbox/InfoButton.vue @@ -0,0 +1,22 @@ + + + diff --git a/src/components/graph/selectionToolbox/Load3DViewerButton.vue b/src/components/graph/selectionToolbox/Load3DViewerButton.vue index b207e5018..5187f0c02 100644 --- a/src/components/graph/selectionToolbox/Load3DViewerButton.vue +++ b/src/components/graph/selectionToolbox/Load3DViewerButton.vue @@ -1,6 +1,5 @@ diff --git a/src/components/graph/selectionToolbox/MoreOptions.vue b/src/components/graph/selectionToolbox/MoreOptions.vue new file mode 100644 index 000000000..f40a49b60 --- /dev/null +++ b/src/components/graph/selectionToolbox/MoreOptions.vue @@ -0,0 +1,316 @@ + + + diff --git a/src/components/graph/selectionToolbox/PinButton.vue b/src/components/graph/selectionToolbox/PinButton.vue deleted file mode 100644 index 86598339b..000000000 --- a/src/components/graph/selectionToolbox/PinButton.vue +++ /dev/null @@ -1,25 +0,0 @@ - - - diff --git a/src/components/graph/selectionToolbox/RefreshSelectionButton.vue b/src/components/graph/selectionToolbox/RefreshSelectionButton.vue index 786fe511f..0da7364a0 100644 --- a/src/components/graph/selectionToolbox/RefreshSelectionButton.vue +++ b/src/components/graph/selectionToolbox/RefreshSelectionButton.vue @@ -1,17 +1,22 @@ diff --git a/src/components/graph/selectionToolbox/SaveToSubgraphLibrary.vue b/src/components/graph/selectionToolbox/SaveToSubgraphLibrary.vue index 8fa1b94d6..7f76d2eab 100644 --- a/src/components/graph/selectionToolbox/SaveToSubgraphLibrary.vue +++ b/src/components/graph/selectionToolbox/SaveToSubgraphLibrary.vue @@ -21,8 +21,8 @@ import { computed } from 'vue' import { useI18n } from 'vue-i18n' import { SubgraphNode } from '@/lib/litegraph/src/litegraph' +import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import { useCommandStore } from '@/stores/commandStore' -import { useCanvasStore } from '@/stores/graphStore' const { t } = useI18n() const commandStore = useCommandStore() diff --git a/src/components/graph/selectionToolbox/SubmenuPopover.vue b/src/components/graph/selectionToolbox/SubmenuPopover.vue new file mode 100644 index 000000000..056f0f90b --- /dev/null +++ b/src/components/graph/selectionToolbox/SubmenuPopover.vue @@ -0,0 +1,127 @@ + + + diff --git a/src/components/graph/selectionToolbox/VerticalDivider.vue b/src/components/graph/selectionToolbox/VerticalDivider.vue new file mode 100644 index 000000000..dc6876a3e --- /dev/null +++ b/src/components/graph/selectionToolbox/VerticalDivider.vue @@ -0,0 +1,3 @@ + diff --git a/src/components/graph/widgets/DomWidget.vue b/src/components/graph/widgets/DomWidget.vue index 11cfafa1c..305af0621 100644 --- a/src/components/graph/widgets/DomWidget.vue +++ b/src/components/graph/widgets/DomWidget.vue @@ -23,10 +23,10 @@ import { CSSProperties, computed, nextTick, onMounted, ref, watch } from 'vue' import { useAbsolutePosition } from '@/composables/element/useAbsolutePosition' import { useDomClipping } from '@/composables/element/useDomClipping' +import { useSettingStore } from '@/platform/settings/settingStore' +import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import { isComponentWidget, isDOMWidget } from '@/scripts/domWidget' import { DomWidgetState } from '@/stores/domWidgetStore' -import { useCanvasStore } from '@/stores/graphStore' -import { useSettingStore } from '@/stores/settingStore' const { widgetState } = defineProps<{ widgetState: DomWidgetState diff --git a/src/components/helpcenter/HelpCenterMenuContent.vue b/src/components/helpcenter/HelpCenterMenuContent.vue index c91589407..f90922e39 100644 --- a/src/components/helpcenter/HelpCenterMenuContent.vue +++ b/src/components/helpcenter/HelpCenterMenuContent.vue @@ -143,10 +143,10 @@ import { useI18n } from 'vue-i18n' import PuzzleIcon from '@/components/icons/PuzzleIcon.vue' import { useConflictAcknowledgment } from '@/composables/useConflictAcknowledgment' import { useManagerState } from '@/composables/useManagerState' -import { type ReleaseNote } from '@/services/releaseService' +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 { useReleaseStore } from '@/stores/releaseStore' -import { useSettingStore } from '@/stores/settingStore' import { ManagerTab } from '@/types/comfyManagerTypes' import { electronAPI, isElectron } from '@/utils/envUtil' import { formatVersionAnchor } from '@/utils/formatUtil' diff --git a/src/components/input/MultiSelect.accessibility.stories.ts b/src/components/input/MultiSelect.accessibility.stories.ts new file mode 100644 index 000000000..5df8fe5a7 --- /dev/null +++ b/src/components/input/MultiSelect.accessibility.stories.ts @@ -0,0 +1,380 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import type { MultiSelectProps } from 'primevue/multiselect' +import { ref } from 'vue' + +import MultiSelect from './MultiSelect.vue' +import { type SelectOption } from './types' + +// Combine our component props with PrimeVue MultiSelect props +interface ExtendedProps extends Partial { + // Our custom props + label?: string + showSearchBox?: boolean + showSelectedCount?: boolean + showClearButton?: boolean + searchPlaceholder?: string + listMaxHeight?: string + popoverMinWidth?: string + popoverMaxWidth?: string + // Override modelValue type to match our Option type + modelValue?: SelectOption[] +} + +const meta: Meta = { + title: 'Components/Input/MultiSelect/Accessibility', + component: MultiSelect, + tags: ['autodocs'], + parameters: { + docs: { + description: { + component: ` +# MultiSelect Accessibility Guide + +This MultiSelect component provides full keyboard accessibility and screen reader support following WCAG 2.1 AA guidelines. + +## Keyboard Navigation + +- **Tab** - Focus the trigger button +- **Enter/Space** - Open/close dropdown when focused +- **Arrow Up/Down** - Navigate through options when dropdown is open +- **Enter/Space** - Select/deselect options when navigating +- **Escape** - Close dropdown + +## Screen Reader Support + +- Uses \`role="combobox"\` to identify as dropdown +- \`aria-haspopup="listbox"\` indicates popup contains list +- \`aria-expanded\` shows dropdown state +- \`aria-label\` provides accessible name with i18n fallback +- Selected count announced to screen readers + +## Testing Instructions + +1. **Tab Navigation**: Use Tab key to focus the component +2. **Keyboard Opening**: Press Enter or Space to open dropdown +3. **Option Navigation**: Use Arrow keys to navigate options +4. **Selection**: Press Enter/Space to select options +5. **Closing**: Press Escape to close dropdown +6. **Screen Reader**: Test with screen reader software + +Try these stories with keyboard-only navigation! + ` + } + } + }, + argTypes: { + label: { + control: 'text', + description: 'Label for the trigger button' + }, + showSearchBox: { + control: 'boolean', + description: 'Show search box in dropdown header' + }, + showSelectedCount: { + control: 'boolean', + description: 'Show selected count in dropdown header' + }, + showClearButton: { + control: 'boolean', + description: 'Show clear all button in dropdown header' + } + } +} + +export default meta +type Story = StoryObj + +const frameworkOptions = [ + { name: 'React', value: 'react' }, + { name: 'Vue', value: 'vue' }, + { name: 'Angular', value: 'angular' }, + { name: 'Svelte', value: 'svelte' }, + { name: 'TypeScript', value: 'typescript' }, + { name: 'JavaScript', value: 'javascript' } +] + +export const KeyboardNavigationDemo: Story = { + render: (args) => ({ + components: { MultiSelect }, + setup() { + const selectedFrameworks = ref([]) + const searchQuery = ref('') + + return { + args: { + ...args, + options: frameworkOptions, + modelValue: selectedFrameworks, + 'onUpdate:modelValue': (value: SelectOption[]) => { + selectedFrameworks.value = value + }, + 'onUpdate:searchQuery': (value: string) => { + searchQuery.value = value + } + }, + selectedFrameworks, + searchQuery + } + }, + template: ` +
+
+

🎯 Keyboard Navigation Test

+

+ Use your keyboard to navigate this MultiSelect: +

+
    +
  1. Tab to focus the dropdown
  2. +
  3. Enter/Space to open dropdown
  4. +
  5. Arrow Up/Down to navigate options
  6. +
  7. Enter/Space to select options
  8. +
  9. Escape to close dropdown
  10. +
+
+ +
+ + +

+ Selected: {{ selectedFrameworks.map(f => f.name).join(', ') || 'None' }} +

+
+
+ ` + }), + args: { + label: 'Choose Frameworks', + showSearchBox: true, + showSelectedCount: true, + showClearButton: true + } +} + +export const ScreenReaderFriendly: Story = { + render: (args) => ({ + components: { MultiSelect }, + setup() { + const selectedColors = ref([]) + const selectedSizes = ref([]) + + const colorOptions = [ + { name: 'Red', value: 'red' }, + { name: 'Blue', value: 'blue' }, + { name: 'Green', value: 'green' }, + { name: 'Yellow', value: 'yellow' } + ] + + const sizeOptions = [ + { name: 'Small', value: 'sm' }, + { name: 'Medium', value: 'md' }, + { name: 'Large', value: 'lg' }, + { name: 'Extra Large', value: 'xl' } + ] + + return { + selectedColors, + selectedSizes, + colorOptions, + sizeOptions, + args + } + }, + template: ` +
+
+

♿ Screen Reader Test

+

+ These dropdowns have proper ARIA attributes and labels for screen readers: +

+
    +
  • role="combobox" identifies as dropdown
  • +
  • aria-haspopup="listbox" indicates popup type
  • +
  • aria-expanded shows open/closed state
  • +
  • aria-label provides accessible name
  • +
  • Selection count announced to assistive technology
  • +
+
+ +
+
+ + +

+ {{ selectedColors.length }} color(s) selected +

+
+ +
+ + +

+ {{ selectedSizes.length }} size(s) selected +

+
+
+
+ ` + }) +} + +export const FocusManagement: Story = { + render: (args) => ({ + components: { MultiSelect }, + setup() { + const selectedItems = ref([]) + const focusTestOptions = [ + { name: 'Option A', value: 'a' }, + { name: 'Option B', value: 'b' }, + { name: 'Option C', value: 'c' } + ] + + return { + selectedItems, + focusTestOptions, + args + } + }, + template: ` +
+
+

🎯 Focus Management Test

+

+ Test focus behavior with multiple form elements: +

+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ +
+ Test: Tab through all elements and verify focus rings are visible and logical. +
+
+ ` + }) +} + +export const AccessibilityChecklist: Story = { + render: () => ({ + template: ` +
+
+

♿ MultiSelect Accessibility Checklist

+ +
+
+

✅ Implemented Features

+
    +
  • + + Keyboard Navigation: Tab, Enter, Space, Arrow keys, Escape +
  • +
  • + + ARIA Attributes: role, aria-haspopup, aria-expanded, aria-label +
  • +
  • + + Focus Management: Visible focus rings and logical tab order +
  • +
  • + + Internationalization: Translatable aria-label fallbacks +
  • +
  • + + Screen Reader Support: Proper announcements and state +
  • +
  • + + Color Contrast: Meets WCAG AA requirements +
  • +
+
+ +
+

📋 Testing Guidelines

+
    +
  1. Keyboard Only: Navigate using only keyboard
  2. +
  3. Screen Reader: Test with NVDA, JAWS, or VoiceOver
  4. +
  5. Focus Visible: Ensure focus rings are always visible
  6. +
  7. Tab Order: Verify logical progression
  8. +
  9. Announcements: Check state changes are announced
  10. +
  11. Escape Behavior: Escape always closes dropdown
  12. +
+
+
+ +
+

🎯 Quick Test

+

+ Close your eyes, use only the keyboard, and try to select multiple options from any dropdown above. + If you can successfully navigate and make selections, the accessibility implementation is working! +

+
+
+
+ ` + }) +} diff --git a/src/components/input/MultiSelect.stories.ts b/src/components/input/MultiSelect.stories.ts index 1ae58db8e..45a1bdbdb 100644 --- a/src/components/input/MultiSelect.stories.ts +++ b/src/components/input/MultiSelect.stories.ts @@ -3,6 +3,7 @@ import type { MultiSelectProps } from 'primevue/multiselect' import { ref } from 'vue' import MultiSelect from './MultiSelect.vue' +import { type SelectOption } from './types' // Combine our component props with PrimeVue MultiSelect props // Since we use v-bind="$attrs", all PrimeVue props are available @@ -17,7 +18,7 @@ interface ExtendedProps extends Partial { popoverMinWidth?: string popoverMaxWidth?: string // Override modelValue type to match our Option type - modelValue?: Array<{ name: string; value: string }> + modelValue?: SelectOption[] } const meta: Meta = { diff --git a/src/components/input/MultiSelect.vue b/src/components/input/MultiSelect.vue index afafa96d8..16ed1db67 100644 --- a/src/components/input/MultiSelect.vue +++ b/src/components/input/MultiSelect.vue @@ -14,6 +14,11 @@ unstyled :max-selected-labels="0" :pt="pt" + :aria-label="label || t('g.multiSelectDropdown')" + role="combobox" + :aria-expanded="false" + aria-haspopup="listbox" + :tabindex="0" >