Compare commits
32 Commits
bl/telemet
...
uy/node-se
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5cf647d71e | ||
|
|
f8b5780623 | ||
|
|
56f846f7aa | ||
|
|
9c9ff0882c | ||
|
|
61a08bc3cc | ||
|
|
bff7669e17 | ||
|
|
ad83b8ee7a | ||
|
|
5b9c773475 | ||
|
|
b9e943ef78 | ||
|
|
a5dbf06fe7 | ||
|
|
c7b1e51361 | ||
|
|
1bd296c48b | ||
|
|
2d61fd9dc7 | ||
|
|
5dfd975e82 | ||
|
|
c32218af38 | ||
|
|
52d4adbafd | ||
|
|
379c2a2ed9 | ||
|
|
8a0dd485be | ||
|
|
b1b4e88d84 | ||
|
|
bad34123f2 | ||
|
|
d4e9d2f306 | ||
|
|
c9ef980ad1 | ||
|
|
f2b4b3e2fd | ||
|
|
8bdafffb00 | ||
|
|
76136708ec | ||
|
|
0df2b05790 | ||
|
|
c36da042d0 | ||
|
|
75553fc214 | ||
|
|
7438f004c1 | ||
|
|
06dda1fb38 | ||
|
|
cdde1248d4 | ||
|
|
5535e93ef3 |
142
.github/workflows/publish-desktop-bridge-types.yaml
vendored
@@ -1,142 +0,0 @@
|
||||
name: Publish Desktop Bridge Types
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version to publish (e.g., 0.1.2)'
|
||||
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
|
||||
secrets:
|
||||
NPM_TOKEN:
|
||||
required: true
|
||||
|
||||
concurrency:
|
||||
group: publish-desktop-bridge-types-${{ github.workflow }}-${{ inputs.version }}-${{ inputs.dist_tag }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
publish_desktop_bridge_types:
|
||||
name: Publish @comfyorg/comfyui-desktop-bridge-types
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Validate inputs
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
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
|
||||
env:
|
||||
REF: ${{ inputs.ref }}
|
||||
DEFAULT_REF: ${{ github.ref_name }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "$REF" ]; then
|
||||
REF="$DEFAULT_REF"
|
||||
fi
|
||||
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"
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ steps.resolve_ref.outputs.ref }}
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile --ignore-scripts
|
||||
env:
|
||||
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1'
|
||||
|
||||
- name: Verify package
|
||||
id: pkg
|
||||
env:
|
||||
INPUT_VERSION: ${{ inputs.version }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PACKAGE_JSON=packages/comfyui-desktop-bridge-types/package.json
|
||||
NAME=$(node -p "require('./${PACKAGE_JSON}').name")
|
||||
VERSION=$(node -p "require('./${PACKAGE_JSON}').version")
|
||||
if [ "$VERSION" != "$INPUT_VERSION" ]; then
|
||||
echo "::error title=Version mismatch::${PACKAGE_JSON} version $VERSION does not match input $INPUT_VERSION" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "name=$NAME" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Check if version already on npm
|
||||
id: check_npm
|
||||
env:
|
||||
NAME: ${{ steps.pkg.outputs.name }}
|
||||
VER: ${{ steps.pkg.outputs.version }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
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'
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
DIST_TAG: ${{ inputs.dist_tag }}
|
||||
run: pnpm publish --access public --tag "$DIST_TAG" --no-git-checks --ignore-scripts
|
||||
working-directory: packages/comfyui-desktop-bridge-types
|
||||
@@ -1,10 +1,14 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
export class ContextMenu {
|
||||
public readonly primeVueMenu: Locator
|
||||
public readonly litegraphMenu: Locator
|
||||
public readonly litegraphContextMenu: Locator
|
||||
public readonly linkReleaseMenu: Locator
|
||||
public readonly linkReleaseMenuSearch: Locator
|
||||
public readonly menuItems: Locator
|
||||
protected readonly anyMenu: Locator
|
||||
|
||||
@@ -12,6 +16,10 @@ export class ContextMenu {
|
||||
this.primeVueMenu = page.locator('.p-contextmenu, .p-menu')
|
||||
this.litegraphMenu = page.locator('.litemenu')
|
||||
this.litegraphContextMenu = page.locator('.litecontextmenu')
|
||||
this.linkReleaseMenu = page.getByTestId(TestIds.linkReleaseMenu.root)
|
||||
this.linkReleaseMenuSearch = page.getByTestId(
|
||||
TestIds.linkReleaseMenu.search
|
||||
)
|
||||
this.menuItems = page.locator('.p-menuitem, .litemenu-entry')
|
||||
this.anyMenu = this.primeVueMenu
|
||||
.or(this.litegraphMenu)
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
*/
|
||||
|
||||
export const TestIds = {
|
||||
linkReleaseMenu: {
|
||||
root: 'link-release-context-menu',
|
||||
search: 'link-release-search'
|
||||
},
|
||||
sidebar: {
|
||||
toolbar: 'side-toolbar',
|
||||
nodeLibrary: 'node-library-tree',
|
||||
|
||||
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 99 KiB |
@@ -21,7 +21,7 @@ test.describe('Link & node interaction settings', { tag: '@canvas' }, () => {
|
||||
await expect(comfyPage.searchBoxV2.input).toBeVisible()
|
||||
})
|
||||
|
||||
test('"context menu" opens litegraph connection menu on link release', async ({
|
||||
test('"context menu" opens the link-release context menu on link release', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
@@ -29,7 +29,7 @@ test.describe('Link & node interaction settings', { tag: '@canvas' }, () => {
|
||||
'context menu'
|
||||
)
|
||||
await comfyPage.canvasOps.disconnectEdge()
|
||||
await expect(comfyPage.contextMenu.litegraphContextMenu).toBeVisible()
|
||||
await expect(comfyPage.contextMenu.linkReleaseMenu).toBeVisible()
|
||||
})
|
||||
|
||||
test('"no action" suppresses both search box and context menu', async ({
|
||||
@@ -41,7 +41,7 @@ test.describe('Link & node interaction settings', { tag: '@canvas' }, () => {
|
||||
)
|
||||
await comfyPage.canvasOps.disconnectEdge()
|
||||
await expect(comfyPage.searchBoxV2.input).toBeHidden()
|
||||
await expect(comfyPage.contextMenu.litegraphContextMenu).toBeHidden()
|
||||
await expect(comfyPage.contextMenu.linkReleaseMenu).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 324 KiB After Width: | Height: | Size: 325 KiB |
103
browser_tests/tests/maskEditorLoadSave.spec.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { maskEditorTest as test } from '@e2e/fixtures/helpers/MaskEditorHelper'
|
||||
|
||||
interface UploadResponse {
|
||||
name: string
|
||||
subfolder: string
|
||||
type: 'input' | 'output' | 'temp'
|
||||
}
|
||||
|
||||
const IMAGE_CANVAS_INDEX = 0
|
||||
const MASK_CANVAS_INDEX = 2
|
||||
|
||||
const successResponse = (name: string): UploadResponse => ({
|
||||
name,
|
||||
subfolder: 'clipspace',
|
||||
type: 'input'
|
||||
})
|
||||
|
||||
const fulfillJson = (body: UploadResponse) => ({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
test.describe('Mask Editor load/save', { tag: '@vue-nodes' }, () => {
|
||||
test('Save with drawn mask uploads non-empty mask data', async ({
|
||||
comfyPage,
|
||||
maskEditor
|
||||
}) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
await maskEditor.drawStrokeAndExpectPixels(dialog)
|
||||
|
||||
let observedContentType = ''
|
||||
let observedBodyLength = 0
|
||||
|
||||
await comfyPage.page.route('**/upload/mask', async (route) => {
|
||||
const request = route.request()
|
||||
observedContentType = (await request.headerValue('content-type')) ?? ''
|
||||
observedBodyLength = request.postDataBuffer()?.byteLength ?? 0
|
||||
await route.fulfill(
|
||||
fulfillJson(successResponse('clipspace-mask-123.png'))
|
||||
)
|
||||
})
|
||||
|
||||
await comfyPage.page.route('**/upload/image', (route) =>
|
||||
route.fulfill(fulfillJson(successResponse('clipspace-painted-123.png')))
|
||||
)
|
||||
|
||||
await dialog.getByRole('button', { name: 'Save' }).click()
|
||||
await expect(dialog).toBeHidden()
|
||||
expect(observedContentType).toContain('multipart/form-data')
|
||||
expect(observedBodyLength).toBeGreaterThan(256)
|
||||
})
|
||||
|
||||
test('Canvas dimensions match the loaded image', async ({ maskEditor }) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
|
||||
const imageDimensions =
|
||||
await maskEditor.getCanvasPixelData(IMAGE_CANVAS_INDEX)
|
||||
const maskDimensions =
|
||||
await maskEditor.getCanvasPixelData(MASK_CANVAS_INDEX)
|
||||
|
||||
expect(imageDimensions).not.toBeNull()
|
||||
expect(maskDimensions).not.toBeNull()
|
||||
expect(imageDimensions?.totalPixels).toBe(64 * 64)
|
||||
expect(maskDimensions?.totalPixels).toBe(64 * 64)
|
||||
|
||||
await expect(dialog).toBeVisible()
|
||||
})
|
||||
|
||||
test('Save failure on partial upload keeps dialog open', async ({
|
||||
comfyPage,
|
||||
maskEditor
|
||||
}) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
await maskEditor.drawStrokeAndExpectPixels(dialog)
|
||||
|
||||
// The saver uploads sequentially: mask layer first, then image layers.
|
||||
// Let the mask upload succeed and the image upload fail to exercise both
|
||||
// endpoints and verify the dialog stays open after a partial failure.
|
||||
let maskUploadHit = false
|
||||
let imageUploadHit = false
|
||||
await comfyPage.page.route('**/upload/mask', (route) => {
|
||||
maskUploadHit = true
|
||||
return route.fulfill(
|
||||
fulfillJson(successResponse('clipspace-mask-999.png'))
|
||||
)
|
||||
})
|
||||
await comfyPage.page.route('**/upload/image', (route) => {
|
||||
imageUploadHit = true
|
||||
return route.fulfill({ status: 500 })
|
||||
})
|
||||
|
||||
const saveButton = dialog.getByRole('button', { name: 'Save' })
|
||||
await saveButton.click()
|
||||
|
||||
await expect.poll(() => maskUploadHit).toBe(true)
|
||||
await expect.poll(() => imageUploadHit).toBe(true)
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(saveButton).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 25 KiB |
@@ -296,12 +296,7 @@ test.describe('Release context menu', { tag: '@node' }, () => {
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.canvasOps.disconnectEdge()
|
||||
const contextMenu = comfyPage.page.locator('.litecontextmenu')
|
||||
// Wait for context menu with correct title (slot name | slot type)
|
||||
// The title shows the output slot name and type from the disconnected link
|
||||
await expect(contextMenu.locator('.litemenu-title')).toContainText(
|
||||
'CLIP | CLIP'
|
||||
)
|
||||
await expect(comfyPage.contextMenu.linkReleaseMenu).toBeVisible()
|
||||
await comfyPage.page.mouse.move(10, 10)
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
@@ -313,14 +308,20 @@ test.describe('Release context menu', { tag: '@node' }, () => {
|
||||
test(
|
||||
'Can search and add node from context menu',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage, comfyMouse }) => {
|
||||
async ({ comfyPage }) => {
|
||||
const initialNodeCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
await comfyPage.canvasOps.disconnectEdge()
|
||||
await comfyMouse.move({ x: 10, y: 10 })
|
||||
await comfyPage.contextMenu.clickMenuItem('Search')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.searchBox.fillAndSelectFirstNode('CLIP Prompt')
|
||||
await waitForSearchInsertion(comfyPage, initialNodeCount)
|
||||
await expect(comfyPage.contextMenu.linkReleaseMenu).toBeVisible()
|
||||
|
||||
await comfyPage.contextMenu.linkReleaseMenuSearch.fill('CLIP Prompt')
|
||||
await expect(
|
||||
comfyPage.contextMenu.linkReleaseMenu.getByRole('menuitem').first()
|
||||
).toBeVisible()
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
|
||||
.toBe(initialNodeCount + 1)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'link-context-menu-search.png'
|
||||
)
|
||||
@@ -343,8 +344,7 @@ test.describe('Release context menu', { tag: '@node' }, () => {
|
||||
await comfyPage.canvasOps.disconnectEdge()
|
||||
// Context menu should appear, search box should not
|
||||
await expect(comfyPage.searchBox.input).toHaveCount(0)
|
||||
const contextMenu = comfyPage.page.locator('.litecontextmenu')
|
||||
await expect(contextMenu).toBeVisible()
|
||||
await expect(comfyPage.contextMenu.linkReleaseMenu).toBeVisible()
|
||||
})
|
||||
|
||||
test('Explicit setting overrides versioned defaults', async ({
|
||||
@@ -366,7 +366,6 @@ test.describe('Release context menu', { tag: '@node' }, () => {
|
||||
await comfyPage.canvasOps.disconnectEdge()
|
||||
// Context menu should appear due to explicit setting, not search box
|
||||
await expect(comfyPage.searchBox.input).toHaveCount(0)
|
||||
const contextMenu = comfyPage.page.locator('.litecontextmenu')
|
||||
await expect(contextMenu).toBeVisible()
|
||||
await expect(comfyPage.contextMenu.linkReleaseMenu).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
@@ -8,6 +8,7 @@ import {
|
||||
getPromotedWidgetNames,
|
||||
getPromotedWidgetCountByName
|
||||
} from '@e2e/fixtures/utils/promotedWidgets'
|
||||
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
const wstest = mergeTests(test, webSocketFixture)
|
||||
|
||||
@@ -139,6 +140,46 @@ test.describe('Vue Nodes Image Preview', { tag: '@vue-nodes' }, () => {
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
wstest(
|
||||
'Displays previews inside subgraphs received while workflow inactive',
|
||||
async ({ comfyPage, getWebSocket }) => {
|
||||
const execution = new ExecutionHelper(comfyPage, await getWebSocket())
|
||||
const previewLocator = comfyPage.vueNodes.getNodeByTitle('Preview Image')
|
||||
const previewImage = new VueNodeFixture(previewLocator)
|
||||
const subgraphLocator = comfyPage.vueNodes.getNodeByTitle('New Subgraph')
|
||||
const subgraphNode = new VueNodeFixture(subgraphLocator)
|
||||
|
||||
await test.step('Add node', async () => {
|
||||
await comfyPage.menu.topbar.newWorkflowButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.searchBoxV2.addNode('Preview Image')
|
||||
await expect(previewImage.root).toBeVisible()
|
||||
})
|
||||
|
||||
await test.step('Create subgraph', async () => {
|
||||
await previewImage.title.click()
|
||||
await comfyPage.page.keyboard.press('Control+Shift+e')
|
||||
await expect(subgraphNode.root).toBeVisible()
|
||||
})
|
||||
|
||||
await test.step('Inject Previews from different tab', async () => {
|
||||
const jobId = await execution.run()
|
||||
await comfyPage.menu.topbar.getTab(0).click()
|
||||
await comfyPage.vueNodes.waitForNodes(7)
|
||||
|
||||
const images = [{ filename: 'example.png', type: 'input' }]
|
||||
execution.executed(jobId, '2:1', { images })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.menu.topbar.getTab(1).click()
|
||||
await comfyPage.vueNodes.waitForNodes(1)
|
||||
})
|
||||
|
||||
await expect(subgraphNode.imagePreview.locator('img')).toHaveCount(1)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
async function countColumns(locator: Locator) {
|
||||
|
||||
@@ -20,10 +20,13 @@
|
||||
"size:report": "node scripts/size-report.js",
|
||||
"collect-i18n": "pnpm exec playwright test --config=playwright.i18n.config.ts",
|
||||
"dev:cloud": "cross-env DEV_SERVER_COMFYUI_URL='https://testcloud.comfy.org/' vite --config vite.config.mts",
|
||||
"dev:cloud:prod": "cross-env DEV_SERVER_COMFYUI_URL='https://cloud.comfy.org/' vite --config vite.config.mts",
|
||||
"vercel:prebuilt": "node scripts/vercel-prebuilt.mjs",
|
||||
"dev:desktop": "pnpm --filter @comfyorg/desktop-ui run dev",
|
||||
"dev:electron": "cross-env DISTRIBUTION=desktop vite --config vite.electron.config.mts",
|
||||
"dev:no-vue": "cross-env DISABLE_VUE_PLUGINS=true vite --config vite.config.mts",
|
||||
"dev": "vite --config vite.config.mts",
|
||||
"dev:backend": "bash scripts/start-comfyui-backend.sh",
|
||||
"devtools:pycheck": "python3 -m compileall -q tools/devtools",
|
||||
"format:check": "oxfmt --check",
|
||||
"format": "oxfmt --write",
|
||||
@@ -46,6 +49,7 @@
|
||||
"stylelint:fix": "stylelint --cache --fix '{apps,packages,src}/**/*.{css,vue}'",
|
||||
"stylelint": "stylelint --cache '{apps,packages,src}/**/*.{css,vue}'",
|
||||
"test:browser": "pnpm exec playwright test",
|
||||
"playwright:install": "pnpm exec playwright install chromium --with-deps",
|
||||
"test:browser:coverage": "cross-env COLLECT_COVERAGE=true pnpm test:browser",
|
||||
"test:browser:local": "cross-env PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173 pnpm test:browser",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
@@ -59,7 +63,6 @@
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "catalog:",
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
|
||||
"@comfyorg/comfyui-desktop-bridge-types": "workspace:*",
|
||||
"@comfyorg/comfyui-electron-types": "catalog:",
|
||||
"@comfyorg/design-system": "workspace:*",
|
||||
"@comfyorg/fbx-exporter-three": "^1.0.1",
|
||||
@@ -207,7 +210,7 @@
|
||||
"zod-to-json-schema": "catalog:"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=25",
|
||||
"node": ">=25 <26",
|
||||
"pnpm": ">=11.3"
|
||||
},
|
||||
"packageManager": "pnpm@11.3.0"
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
export interface ComfyDownloadProgress {
|
||||
url: string
|
||||
filename: string
|
||||
directory?: string
|
||||
progress: number
|
||||
receivedBytes?: number
|
||||
totalBytes?: number
|
||||
speedBytesPerSec?: number
|
||||
etaSeconds?: number
|
||||
status:
|
||||
| 'pending'
|
||||
| 'downloading'
|
||||
| 'paused'
|
||||
| 'completed'
|
||||
| 'error'
|
||||
| 'cancelled'
|
||||
error?: string
|
||||
isImage?: boolean
|
||||
}
|
||||
|
||||
export interface TerminalRestore {
|
||||
buffer: string[]
|
||||
size: { cols: number; rows: number }
|
||||
exited: boolean
|
||||
}
|
||||
|
||||
export interface LogsRestore {
|
||||
installationId: string
|
||||
buffer: string[]
|
||||
}
|
||||
|
||||
export interface LogsOutputMsg {
|
||||
installationId: string
|
||||
text: string
|
||||
}
|
||||
|
||||
export type ComfyDesktop2TelemetryValue = string | number | boolean | null
|
||||
export type ComfyDesktop2TelemetryProperties = Record<
|
||||
string,
|
||||
ComfyDesktop2TelemetryValue | ComfyDesktop2TelemetryValue[]
|
||||
>
|
||||
|
||||
export interface ComfyDesktop2TerminalBridge {
|
||||
subscribe(installationId?: string): Promise<TerminalRestore>
|
||||
unsubscribe(installationId?: string): Promise<void>
|
||||
write(data: string, installationId?: string): Promise<void>
|
||||
resize(cols: number, rows: number, installationId?: string): Promise<void>
|
||||
restart(installationId?: string): Promise<TerminalRestore>
|
||||
openPopout(): Promise<void>
|
||||
onOutput(callback: (data: string) => void): () => void
|
||||
onExited(callback: () => void): () => void
|
||||
}
|
||||
|
||||
export interface ComfyDesktop2LogsBridge {
|
||||
subscribe(installationId?: string): Promise<LogsRestore>
|
||||
unsubscribe(installationId?: string): Promise<void>
|
||||
openPopout(): Promise<void>
|
||||
onOutput(callback: (msg: LogsOutputMsg) => void): () => void
|
||||
}
|
||||
|
||||
export interface ComfyDesktop2TelemetryBridge {
|
||||
capture(event: string, properties?: ComfyDesktop2TelemetryProperties): void
|
||||
}
|
||||
|
||||
export interface ComfyDesktop2Bridge {
|
||||
isRemote(): boolean
|
||||
downloadModel?: (
|
||||
url: string,
|
||||
filename: string,
|
||||
directory: string
|
||||
) => Promise<boolean>
|
||||
downloadAsset?: (
|
||||
url: string,
|
||||
filename: string,
|
||||
authToken?: string
|
||||
) => Promise<boolean>
|
||||
pauseDownload?: (url: string) => Promise<boolean>
|
||||
resumeDownload?: (url: string) => Promise<boolean>
|
||||
cancelDownload?: (url: string) => Promise<boolean>
|
||||
onDownloadProgress?: (
|
||||
callback: (data: ComfyDownloadProgress) => void
|
||||
) => () => void
|
||||
reportTheme?: (bg: string, text: string) => void
|
||||
Terminal?: ComfyDesktop2TerminalBridge
|
||||
Logs?: ComfyDesktop2LogsBridge
|
||||
Telemetry?: ComfyDesktop2TelemetryBridge
|
||||
}
|
||||
|
||||
export type ComfyDesktop2BridgeImplementation = {
|
||||
[K in keyof ComfyDesktop2Bridge]-?: NonNullable<ComfyDesktop2Bridge[K]>
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from './comfyDesktopBridge.js'
|
||||
@@ -1 +0,0 @@
|
||||
export {}
|
||||
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-desktop-bridge-types",
|
||||
"version": "0.1.2",
|
||||
"description": "TypeScript definitions for the Comfy Desktop hosted frontend bridge",
|
||||
"homepage": "https://comfy.org",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
"name": "Comfy Org",
|
||||
"email": "support@comfy.org",
|
||||
"url": "https://www.comfy.org"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/Comfy-Org/ComfyUI_frontend.git"
|
||||
},
|
||||
"files": [
|
||||
"comfyDesktopBridge.d.ts",
|
||||
"index.d.ts",
|
||||
"index.js"
|
||||
],
|
||||
"type": "module",
|
||||
"main": "./index.js",
|
||||
"types": "./index.d.ts",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
13
pnpm-lock.yaml
generated
@@ -426,9 +426,6 @@ importers:
|
||||
'@atlaskit/pragmatic-drag-and-drop':
|
||||
specifier: ^1.3.1
|
||||
version: 1.3.1
|
||||
'@comfyorg/comfyui-desktop-bridge-types':
|
||||
specifier: workspace:*
|
||||
version: link:packages/comfyui-desktop-bridge-types
|
||||
'@comfyorg/comfyui-electron-types':
|
||||
specifier: 'catalog:'
|
||||
version: 0.6.2
|
||||
@@ -1000,8 +997,6 @@ importers:
|
||||
specifier: 'catalog:'
|
||||
version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/coverage-v8@4.0.16(vitest@4.1.8))(@vitest/ui@4.0.16(vitest@4.1.8))(happy-dom@20.9.0)(jsdom@27.4.0)(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
|
||||
|
||||
packages/comfyui-desktop-bridge-types: {}
|
||||
|
||||
packages/design-system:
|
||||
dependencies:
|
||||
'@iconify-json/lucide':
|
||||
@@ -8645,8 +8640,8 @@ packages:
|
||||
vue-component-type-helpers@3.3.2:
|
||||
resolution: {integrity: sha512-l4Z2Y34m7nFMlx8vrslJaVtXxUpzgDMSESC7TakG/c5kwjYT/do+E0NcT2/vWDzaoIhsShg/2OKwX7Q4nbzC0g==}
|
||||
|
||||
vue-component-type-helpers@3.3.5:
|
||||
resolution: {integrity: sha512-Fe1jyPJoUGpJOYKOri44jduR7My4yYINOMJISuMAbmrs+L5LbIDUc8NTWZYY3EJLK0yPLuCmcd5zoCsE4k2/KA==}
|
||||
vue-component-type-helpers@3.3.4:
|
||||
resolution: {integrity: sha512-joip1uZTaQR0nD23N400gIdJ7xY+WiiiMA/BCKz842gvGBknqDQAzklUvDEhqFvvrhQY8S2ZANBMu4X70VMFGw==}
|
||||
|
||||
vue-demi@0.14.10:
|
||||
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
|
||||
@@ -11328,7 +11323,7 @@ snapshots:
|
||||
storybook: 10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
type-fest: 2.19.0
|
||||
vue: 3.5.34(typescript@5.9.3)
|
||||
vue-component-type-helpers: 3.3.5
|
||||
vue-component-type-helpers: 3.3.4
|
||||
|
||||
'@swc/helpers@0.5.21':
|
||||
dependencies:
|
||||
@@ -17474,7 +17469,7 @@ snapshots:
|
||||
|
||||
vue-component-type-helpers@3.3.2: {}
|
||||
|
||||
vue-component-type-helpers@3.3.5: {}
|
||||
vue-component-type-helpers@3.3.4: {}
|
||||
|
||||
vue-demi@0.14.10(vue@3.5.34(typescript@5.9.3)):
|
||||
dependencies:
|
||||
|
||||
@@ -164,7 +164,7 @@ overrides:
|
||||
vite: 'catalog:'
|
||||
'@tiptap/pm': 2.27.2
|
||||
'@types/eslint': '-'
|
||||
# Security overrides
|
||||
#Security overrides
|
||||
lodash: ^4.18.0
|
||||
yaml: ^2.8.3
|
||||
minimatch@^9.0.0: ^9.0.7
|
||||
|
||||
@@ -2,12 +2,6 @@ import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
const mainPackage = JSON.parse(fs.readFileSync('./package.json', 'utf8'))
|
||||
const desktopBridgeTypesPackage = JSON.parse(
|
||||
fs.readFileSync(
|
||||
'./packages/comfyui-desktop-bridge-types/package.json',
|
||||
'utf8'
|
||||
)
|
||||
)
|
||||
|
||||
// Create the types-only package.json
|
||||
const typesPackage = {
|
||||
@@ -22,9 +16,7 @@ const typesPackage = {
|
||||
homepage: mainPackage.homepage,
|
||||
description: `TypeScript definitions for ${mainPackage.name}`,
|
||||
license: mainPackage.license,
|
||||
dependencies: {
|
||||
'@comfyorg/comfyui-desktop-bridge-types': desktopBridgeTypesPackage.version
|
||||
},
|
||||
dependencies: {},
|
||||
peerDependencies: {
|
||||
vue: mainPackage.dependencies.vue,
|
||||
zod: mainPackage.dependencies.zod
|
||||
@@ -42,3 +34,5 @@ fs.writeFileSync(
|
||||
path.join(distDir, 'package.json'),
|
||||
JSON.stringify(typesPackage, null, 2)
|
||||
)
|
||||
|
||||
console.log('Types package.json have been prepared in the dist directory')
|
||||
|
||||
67
scripts/vercel-prebuilt.mjs
Normal file
@@ -0,0 +1,67 @@
|
||||
import { cpSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { execSync } from 'node:child_process'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const root = new URL('..', import.meta.url).pathname
|
||||
const outputDir = join(root, '.vercel/output')
|
||||
const staticDir = join(outputDir, 'static')
|
||||
|
||||
const cloudOrigin = 'https://cloud.comfy.org'
|
||||
|
||||
rmSync(outputDir, { recursive: true, force: true })
|
||||
mkdirSync(staticDir, { recursive: true })
|
||||
|
||||
execSync('pnpm build:cloud', {
|
||||
cwd: root,
|
||||
stdio: 'inherit',
|
||||
env: {
|
||||
...process.env,
|
||||
DISTRIBUTION: 'cloud',
|
||||
USE_PROD_CONFIG: 'true',
|
||||
ALGOLIA_APP_ID: process.env.ALGOLIA_APP_ID ?? '4E0RO38HS8',
|
||||
ALGOLIA_API_KEY:
|
||||
process.env.ALGOLIA_API_KEY ?? '684d998c36b67a9a9fce8fc2d8860579'
|
||||
}
|
||||
})
|
||||
|
||||
cpSync(join(root, 'dist'), staticDir, { recursive: true })
|
||||
|
||||
const config = {
|
||||
version: 3,
|
||||
routes: [
|
||||
{
|
||||
src: '/api/(.*)',
|
||||
dest: `${cloudOrigin}/api/$1`
|
||||
},
|
||||
{
|
||||
src: '/internal/(.*)',
|
||||
dest: `${cloudOrigin}/internal/$1`
|
||||
},
|
||||
{
|
||||
src: '/extensions/(.*)',
|
||||
dest: `${cloudOrigin}/extensions/$1`
|
||||
},
|
||||
{
|
||||
src: '/workflow_templates/(.*)',
|
||||
dest: `${cloudOrigin}/workflow_templates/$1`
|
||||
},
|
||||
{
|
||||
src: '/oauth/(.*)',
|
||||
dest: `${cloudOrigin}/oauth/$1`
|
||||
},
|
||||
{
|
||||
handle: 'filesystem'
|
||||
},
|
||||
{
|
||||
src: '/(.*)',
|
||||
dest: '/index.html'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
writeFileSync(
|
||||
join(outputDir, 'config.json'),
|
||||
`${JSON.stringify(config, null, 2)}\n`
|
||||
)
|
||||
|
||||
console.log('Prebuilt output ready at .vercel/output')
|
||||
@@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { AppMode } from '@/utils/appMode'
|
||||
import type { AppMode } from '@/composables/useAppMode'
|
||||
|
||||
import BuilderFooterToolbar from '@/components/builder/BuilderFooterToolbar.vue'
|
||||
|
||||
|
||||
71
src/components/rightSidePanel/errors/ErrorCardSection.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<section :class="cn('group flex min-w-0 flex-col py-2', className)">
|
||||
<div class="flex min-h-8 w-full items-center gap-2 px-3">
|
||||
<button
|
||||
type="button"
|
||||
class="focus-visible:ring-ring flex min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-sm border-0 bg-transparent p-0 text-left outline-none focus-visible:ring-1"
|
||||
:aria-expanded="!collapse"
|
||||
:aria-controls="bodyId"
|
||||
@click="collapse = !collapse"
|
||||
>
|
||||
<span
|
||||
class="flex h-4 min-w-4 shrink-0 items-center justify-center rounded-full bg-destructive-background-hover px-1 text-2xs/none font-semibold text-white tabular-nums"
|
||||
>
|
||||
{{ count }}
|
||||
</span>
|
||||
<span class="min-w-0 flex-1 truncate text-sm text-base-foreground">
|
||||
{{ title }}
|
||||
</span>
|
||||
</button>
|
||||
<slot name="actions" />
|
||||
<button
|
||||
type="button"
|
||||
class="focus-visible:ring-ring flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-sm border-0 bg-transparent p-0 outline-none focus-visible:ring-1"
|
||||
:aria-expanded="!collapse"
|
||||
:aria-controls="bodyId"
|
||||
:aria-label="
|
||||
collapse ? t('rightSidePanel.expand') : t('rightSidePanel.collapse')
|
||||
"
|
||||
@click="collapse = !collapse"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
:class="
|
||||
cn(
|
||||
'icon-[lucide--chevron-up] size-4 text-muted-foreground transition-transform group-hover:text-base-foreground',
|
||||
collapse && '-rotate-180'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<TransitionCollapse>
|
||||
<div v-if="!collapse" :id="bodyId">
|
||||
<slot />
|
||||
</div>
|
||||
</TransitionCollapse>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useId } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
|
||||
|
||||
const {
|
||||
title,
|
||||
count,
|
||||
class: className
|
||||
} = defineProps<{
|
||||
title: string
|
||||
count: number
|
||||
class?: string
|
||||
}>()
|
||||
|
||||
const collapse = defineModel<boolean>('collapse', { default: false })
|
||||
|
||||
const bodyId = useId()
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
@@ -1,29 +1,31 @@
|
||||
<template>
|
||||
<div class="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden">
|
||||
<div
|
||||
v-if="card.nodeId && !compact"
|
||||
class="flex flex-wrap items-center gap-2 py-2"
|
||||
class="flex min-h-8 flex-wrap items-center gap-2"
|
||||
>
|
||||
<button
|
||||
v-if="hasRuntimeError && (card.nodeTitle || card.title)"
|
||||
type="button"
|
||||
class="m-0 min-w-0 flex-1 cursor-pointer appearance-none truncate border-0 bg-transparent p-0 text-left text-sm font-medium text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none"
|
||||
@click="handleLocateNode"
|
||||
>
|
||||
{{ card.nodeTitle || card.title }}
|
||||
</button>
|
||||
<span
|
||||
v-else-if="card.nodeTitle || card.title"
|
||||
class="flex-1 truncate text-sm font-medium text-muted-foreground"
|
||||
>
|
||||
{{ card.nodeTitle || card.title }}
|
||||
<span class="flex min-w-0 flex-1">
|
||||
<button
|
||||
v-if="hasRuntimeError && (card.nodeTitle || card.title)"
|
||||
type="button"
|
||||
class="focus-visible:ring-ring m-0 max-w-full min-w-0 cursor-pointer appearance-none truncate rounded-sm border-0 bg-transparent p-0 text-left text-xs font-normal text-base-foreground outline-none focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
|
||||
@click="handleLocateNode"
|
||||
>
|
||||
{{ card.nodeTitle || card.title }}
|
||||
</button>
|
||||
<span
|
||||
v-else-if="card.nodeTitle || card.title"
|
||||
class="max-w-full min-w-0 truncate text-xs font-normal text-base-foreground"
|
||||
>
|
||||
{{ card.nodeTitle || card.title }}
|
||||
</span>
|
||||
</span>
|
||||
<div class="flex shrink-0 items-center">
|
||||
<Button
|
||||
v-if="card.isSubgraphNode"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="h-8 shrink-0 rounded-lg text-sm"
|
||||
class="shrink-0 focus-visible:ring-inset"
|
||||
@click.stop="handleEnterSubgraph"
|
||||
>
|
||||
{{ t('rightSidePanel.enterSubgraph') }}
|
||||
@@ -34,7 +36,7 @@
|
||||
size="icon-sm"
|
||||
:class="
|
||||
cn(
|
||||
'size-8 shrink-0 text-muted-foreground hover:text-base-foreground',
|
||||
'size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset',
|
||||
runtimeDetailsExpanded &&
|
||||
'bg-secondary-background-selected text-base-foreground hover:bg-secondary-background-selected'
|
||||
)
|
||||
@@ -49,7 +51,7 @@
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
|
||||
:aria-label="t('rightSidePanel.locateNode')"
|
||||
@click.stop="handleLocateNode"
|
||||
>
|
||||
@@ -59,29 +61,29 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex min-h-0 flex-1 flex-col space-y-4 divide-y divide-interface-stroke/20"
|
||||
class="flex min-h-0 flex-1 flex-col space-y-2 divide-y divide-interface-stroke/20"
|
||||
>
|
||||
<div
|
||||
v-for="(error, idx) in card.errors"
|
||||
:key="idx"
|
||||
class="flex min-h-0 flex-col gap-3"
|
||||
class="flex min-h-0 flex-col gap-1"
|
||||
>
|
||||
<p
|
||||
v-if="getInlineMessage(error)"
|
||||
class="m-0 max-h-[4lh] overflow-y-auto px-0.5 text-sm/relaxed wrap-break-word whitespace-pre-wrap"
|
||||
class="m-0 max-h-[4lh] overflow-y-auto px-0.5 text-xs/relaxed wrap-break-word whitespace-pre-wrap"
|
||||
>
|
||||
{{ getInlineMessage(error) }}
|
||||
</p>
|
||||
|
||||
<ul
|
||||
v-if="getInlineItemLabel(error)"
|
||||
class="m-0 list-disc space-y-1 pl-5 text-sm/relaxed text-muted-foreground marker:text-muted-foreground"
|
||||
class="m-0 list-disc space-y-1 pl-5 text-xs/relaxed text-muted-foreground marker:text-muted-foreground"
|
||||
>
|
||||
<li class="min-w-0 wrap-break-word">
|
||||
<button
|
||||
v-if="card.nodeId"
|
||||
type="button"
|
||||
class="m-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none"
|
||||
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
|
||||
@click="handleLocateNode"
|
||||
>
|
||||
{{ getInlineItemLabel(error) }}
|
||||
@@ -96,13 +98,13 @@
|
||||
v-if="!error.isRuntimeError && getInlineDetails(error, idx)"
|
||||
:class="
|
||||
cn(
|
||||
'overflow-y-auto rounded-lg border border-interface-stroke/30 bg-secondary-background p-2.5',
|
||||
'overflow-y-auto rounded-lg bg-base-foreground/5 p-3',
|
||||
'max-h-[6lh]'
|
||||
)
|
||||
"
|
||||
>
|
||||
<p
|
||||
class="m-0 font-mono text-xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
class="m-0 text-xs/normal wrap-break-word whitespace-pre-wrap text-base-foreground/50"
|
||||
>
|
||||
{{ getInlineDetails(error, idx) }}
|
||||
</p>
|
||||
@@ -115,60 +117,61 @@
|
||||
role="region"
|
||||
data-testid="runtime-error-panel"
|
||||
:aria-label="t('rightSidePanel.errorLog')"
|
||||
class="flex min-h-0 flex-col gap-3"
|
||||
class="flex min-h-0 flex-col gap-1"
|
||||
>
|
||||
<div
|
||||
v-if="getInlineDetails(error, idx)"
|
||||
class="overflow-hidden rounded-lg border border-interface-stroke/30 bg-secondary-background"
|
||||
class="flex flex-col gap-3 rounded-lg bg-base-foreground/5 p-3"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between gap-2 px-3 pt-3 pb-2"
|
||||
>
|
||||
<span
|
||||
class="text-xs font-semibold tracking-wide text-base-foreground uppercase"
|
||||
>
|
||||
{{ t('rightSidePanel.errorLog') }}
|
||||
</span>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-7 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
:aria-label="t('g.copy')"
|
||||
data-testid="error-card-copy"
|
||||
@click="handleCopyError(idx)"
|
||||
>
|
||||
<i class="icon-[lucide--copy] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="max-h-[15lh] overflow-y-auto px-3 pb-3">
|
||||
<p
|
||||
class="m-0 font-mono text-xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
>
|
||||
{{ getInlineDetails(error, idx) }}
|
||||
</p>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between gap-1 py-1">
|
||||
<span
|
||||
class="text-xs font-semibold text-base-foreground uppercase"
|
||||
>
|
||||
{{ t('rightSidePanel.errorLog') }}
|
||||
</span>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-7 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
|
||||
:aria-label="t('g.copy')"
|
||||
data-testid="error-card-copy"
|
||||
@click="handleCopyError(idx)"
|
||||
>
|
||||
<i class="icon-[lucide--copy] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="max-h-[15lh] overflow-y-auto">
|
||||
<p
|
||||
class="m-0 text-xs/normal wrap-break-word whitespace-pre-wrap text-base-foreground/50"
|
||||
>
|
||||
{{ getInlineDetails(error, idx) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mx-3 mt-1 h-px bg-base-foreground/20" />
|
||||
<div class="mx-3 flex items-center justify-between gap-2 py-2">
|
||||
<div aria-hidden="true" class="h-px w-full bg-interface-stroke" />
|
||||
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<Button
|
||||
v-tooltip.top="t('rightSidePanel.getHelpTooltip')"
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
class="h-8 justify-start gap-1 rounded-lg px-0 text-sm hover:bg-transparent hover:text-base-foreground"
|
||||
class="justify-start gap-1 px-0 text-xs hover:bg-transparent hover:text-base-foreground focus-visible:ring-inset"
|
||||
@click="handleGetHelp"
|
||||
>
|
||||
<i class="icon-[lucide--external-link] size-3.5" />
|
||||
<i class="icon-[lucide--external-link] size-4" />
|
||||
{{ t('g.getHelpAction') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-tooltip.top="t('rightSidePanel.findOnGithubTooltip')"
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
class="h-8 justify-end gap-1 rounded-lg px-0 text-sm hover:bg-transparent hover:text-base-foreground"
|
||||
class="justify-end gap-1 px-0 text-xs hover:bg-transparent hover:text-base-foreground focus-visible:ring-inset"
|
||||
data-testid="error-card-find-on-github"
|
||||
@click="handleCheckGithub(error)"
|
||||
>
|
||||
<i class="icon-[lucide--github] size-3.5" />
|
||||
<i class="icon-[lucide--github] size-4" />
|
||||
{{ t('g.findOnGithub') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div data-testid="missing-node-card" class="px-4 pb-2">
|
||||
<div data-testid="missing-node-card" class="px-3">
|
||||
<!-- Core node version warning (OSS only) -->
|
||||
<div
|
||||
v-if="!isCloud && hasMissingCoreNodes"
|
||||
@@ -56,7 +56,7 @@
|
||||
>
|
||||
</template>
|
||||
</i18n-t>
|
||||
<div class="flex flex-col gap-1 overflow-hidden py-2">
|
||||
<div class="flex flex-col gap-1 overflow-hidden">
|
||||
<MissingPackGroupRow
|
||||
v-for="group in missingPackGroups"
|
||||
:key="group.packId ?? '__unknown__'"
|
||||
@@ -75,7 +75,7 @@
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
:disabled="isRestarting"
|
||||
class="mt-2 h-8 w-full min-w-0 rounded-lg text-sm"
|
||||
class="mt-2 h-8 w-full min-w-0 rounded-md text-xs"
|
||||
@click="applyChanges()"
|
||||
>
|
||||
<DotSpinner v-if="isRestarting" duration="1s" :size="12" />
|
||||
|
||||
@@ -12,17 +12,17 @@
|
||||
: t('rightSidePanel.missingNodePacks.expand')
|
||||
"
|
||||
:aria-expanded="expanded"
|
||||
:class="
|
||||
cn(
|
||||
'h-8 w-4 shrink-0 p-0 transition-transform duration-200 hover:bg-transparent',
|
||||
expanded && 'rotate-90'
|
||||
)
|
||||
"
|
||||
class="h-8 w-4 shrink-0 p-0 hover:bg-transparent focus-visible:ring-inset"
|
||||
@click="toggleExpand"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--chevron-right] size-4 text-muted-foreground"
|
||||
:class="
|
||||
cn(
|
||||
'icon-[lucide--chevron-right] size-4 text-muted-foreground transition-transform duration-200',
|
||||
expanded && 'rotate-90'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</Button>
|
||||
<i
|
||||
@@ -64,7 +64,7 @@
|
||||
</button>
|
||||
<span
|
||||
v-else
|
||||
class="min-w-0 truncate text-sm/relaxed font-normal"
|
||||
class="min-w-0 truncate text-xs/relaxed font-normal"
|
||||
:class="
|
||||
isUnknownPack ? 'text-warning-background' : 'text-base-foreground'
|
||||
"
|
||||
@@ -80,7 +80,7 @@
|
||||
v-if="showInfoButton && group.packId !== null"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-7 shrink-0 text-muted-foreground hover:bg-transparent hover:text-base-foreground"
|
||||
class="size-6 shrink-0 text-muted-foreground hover:bg-transparent hover:text-base-foreground focus-visible:ring-inset"
|
||||
:aria-label="t('rightSidePanel.missingNodePacks.viewInManager')"
|
||||
@click="emit('openManagerInfo', group.packId ?? '')"
|
||||
>
|
||||
@@ -89,7 +89,7 @@
|
||||
<span
|
||||
v-if="showNodeCount"
|
||||
data-testid="missing-node-pack-count"
|
||||
class="flex size-6 shrink-0 items-center justify-center rounded-md bg-secondary-background-selected text-xs font-bold text-muted-foreground"
|
||||
class="flex h-4 min-w-4 shrink-0 items-center justify-center rounded-sm bg-secondary-background-hover px-1 text-2xs font-semibold text-base-foreground"
|
||||
>
|
||||
{{ group.nodeTypes.length }}
|
||||
</span>
|
||||
@@ -99,7 +99,7 @@
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="h-8 shrink-0 rounded-lg text-sm"
|
||||
class="shrink-0 focus-visible:ring-inset"
|
||||
:disabled="isPackInstalled || isInstalling"
|
||||
@click="handlePackInstallClick"
|
||||
>
|
||||
@@ -122,10 +122,10 @@
|
||||
</div>
|
||||
<div
|
||||
v-else-if="showLoadingAction"
|
||||
class="ml-auto flex h-8 shrink-0 cursor-not-allowed items-center justify-center overflow-hidden rounded-lg bg-secondary-background px-2 py-1 text-sm opacity-60 select-none"
|
||||
class="ml-auto flex h-6 shrink-0 cursor-not-allowed items-center justify-center overflow-hidden rounded-sm bg-secondary-background px-2 py-1 text-xs opacity-60 select-none"
|
||||
>
|
||||
<DotSpinner duration="1s" :size="12" class="mr-1.5 shrink-0" />
|
||||
<span class="text-foreground min-w-0 truncate text-sm">
|
||||
<span class="text-foreground min-w-0 truncate text-xs">
|
||||
{{ t('g.loading') }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -133,7 +133,7 @@
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="h-8 shrink-0 rounded-lg text-sm"
|
||||
class="shrink-0 focus-visible:ring-inset"
|
||||
@click="
|
||||
openManager({
|
||||
initialTab: ManagerTab.All,
|
||||
@@ -150,7 +150,7 @@
|
||||
v-if="primaryLocatableNodeType"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
|
||||
:aria-label="t('rightSidePanel.locateNode')"
|
||||
@click="handleLocateNode(primaryLocatableNodeType)"
|
||||
>
|
||||
@@ -163,7 +163,7 @@
|
||||
v-if="showNodeTypeList"
|
||||
:class="
|
||||
cn(
|
||||
'm-0 list-none space-y-1 p-0',
|
||||
'm-0 list-none p-0',
|
||||
(hasMultipleNodeTypes || isUnknownPack) && 'pl-5'
|
||||
)
|
||||
"
|
||||
@@ -190,7 +190,7 @@
|
||||
</button>
|
||||
<span
|
||||
v-else
|
||||
class="text-sm/relaxed wrap-break-word text-muted-foreground"
|
||||
class="text-xs/relaxed wrap-break-word text-muted-foreground"
|
||||
>
|
||||
{{ getLabel(nodeType) }}
|
||||
</span>
|
||||
@@ -199,7 +199,7 @@
|
||||
v-if="isLocatableNodeType(nodeType)"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
|
||||
:aria-label="t('rightSidePanel.locateNode')"
|
||||
@click="handleLocateNode(nodeType)"
|
||||
>
|
||||
@@ -241,7 +241,7 @@ const { t } = useI18n()
|
||||
const expandedOverride = ref<boolean | null>(null)
|
||||
|
||||
const packTextButtonClass =
|
||||
'm-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word outline-none focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none'
|
||||
'm-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-xs/relaxed font-normal wrap-break-word outline-none focus:outline-none rounded-sm focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-inset focus-visible:outline-none'
|
||||
|
||||
const { missingNodePacks, isLoading } = useMissingNodes()
|
||||
const comfyManagerStore = useComfyManagerStore()
|
||||
|
||||
@@ -78,6 +78,10 @@ describe('TabErrors.vue', () => {
|
||||
rightSidePanel: {
|
||||
noErrors: 'No errors',
|
||||
noneSearchDesc: 'No results found',
|
||||
errorsDetected: 'Error detected | Errors detected',
|
||||
resolveBeforeRun: 'Resolve before running the workflow',
|
||||
expand: 'Expand',
|
||||
collapse: 'Collapse',
|
||||
errorHelp: 'Error help',
|
||||
errorLog: 'Error log',
|
||||
findOnGithubTooltip: 'Search GitHub issues',
|
||||
@@ -118,9 +122,6 @@ describe('TabErrors.vue', () => {
|
||||
template:
|
||||
'<input @input="$emit(\'update:modelValue\', $event.target.value)" />'
|
||||
},
|
||||
PropertiesAccordionItem: {
|
||||
template: '<div><slot name="label" /><slot /></div>'
|
||||
},
|
||||
Button: {
|
||||
template: '<button v-bind="$attrs"><slot /></button>'
|
||||
}
|
||||
@@ -211,7 +212,13 @@ describe('TabErrors.vue', () => {
|
||||
})
|
||||
|
||||
expect(screen.getByText('Missing connection')).toBeInTheDocument()
|
||||
expect(screen.getByText('(3)')).toBeInTheDocument()
|
||||
expect(
|
||||
within(screen.getByTestId('error-group-execution')).getByText('3')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(screen.getByTestId('errors-summary-hero')).getByText('3')
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('Errors detected')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getAllByText(
|
||||
'Required input slots have no connection feeding them.'
|
||||
@@ -404,7 +411,7 @@ describe('TabErrors.vue', () => {
|
||||
})
|
||||
const missingModelStore = useMissingModelStore()
|
||||
|
||||
expect(screen.getByText('Missing Models (1)')).toBeInTheDocument()
|
||||
expect(screen.getByText('Missing Models')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByTestId('missing-model-actions')
|
||||
).not.toBeInTheDocument()
|
||||
@@ -414,6 +421,40 @@ describe('TabErrors.vue', () => {
|
||||
expect(missingModelStore.refreshMissingModels).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('counts missing models per file when several share one directory', () => {
|
||||
renderComponent({
|
||||
missingModel: {
|
||||
missingModelCandidates: [
|
||||
{
|
||||
nodeId: '1',
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
name: 'model-a.safetensors',
|
||||
directory: 'checkpoints',
|
||||
isMissing: true,
|
||||
isAssetSupported: true
|
||||
},
|
||||
{
|
||||
nodeId: '2',
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
name: 'model-b.safetensors',
|
||||
directory: 'checkpoints',
|
||||
isMissing: true,
|
||||
isAssetSupported: true
|
||||
}
|
||||
] satisfies MissingModelCandidate[]
|
||||
}
|
||||
})
|
||||
|
||||
expect(
|
||||
within(screen.getByTestId('error-group-missing-model')).getByText('2')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(screen.getByTestId('errors-summary-hero')).getByText('2')
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders missing model display message below the section title', () => {
|
||||
const missingModel = {
|
||||
nodeId: '1',
|
||||
@@ -431,7 +472,7 @@ describe('TabErrors.vue', () => {
|
||||
}
|
||||
})
|
||||
|
||||
expect(screen.getByText('Missing Models (1)')).toBeInTheDocument()
|
||||
expect(screen.getByText('Missing Models')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('Download a model, or open the node to replace it.')
|
||||
).toBeInTheDocument()
|
||||
@@ -453,7 +494,7 @@ describe('TabErrors.vue', () => {
|
||||
}
|
||||
})
|
||||
|
||||
expect(screen.getByText('Missing Inputs (1)')).toBeInTheDocument()
|
||||
expect(screen.getByText('Missing Inputs')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('A required media input has no file selected.')
|
||||
).toBeInTheDocument()
|
||||
@@ -495,6 +536,12 @@ describe('TabErrors.vue', () => {
|
||||
})
|
||||
|
||||
expect(screen.getAllByTestId('missing-media-row')).toHaveLength(2)
|
||||
expect(
|
||||
within(screen.getByTestId('error-group-missing-media')).getByText('2')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(screen.getByTestId('errors-summary-hero')).getByText('2')
|
||||
).toBeInTheDocument()
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Second Loader - image' })
|
||||
@@ -526,7 +573,7 @@ describe('TabErrors.vue', () => {
|
||||
}
|
||||
})
|
||||
|
||||
expect(screen.getByText('Swap Nodes (1)')).toBeInTheDocument()
|
||||
expect(screen.getByText('Swap Nodes')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('Some nodes can be replaced with alternatives')
|
||||
).toBeInTheDocument()
|
||||
|
||||
@@ -11,49 +11,62 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1 overflow-y-auto" aria-live="polite">
|
||||
<TransitionGroup tag="div" name="list-scale" class="relative">
|
||||
<div
|
||||
class="min-w-0 flex-1 overflow-y-auto bg-interface-panel-surface p-3"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div
|
||||
v-if="filteredGroups.length === 0"
|
||||
class="px-1 pt-5 pb-15 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{
|
||||
searchQuery.trim()
|
||||
? t('rightSidePanel.noneSearchDesc')
|
||||
: t('rightSidePanel.noErrors')
|
||||
}}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="overflow-hidden rounded-lg border border-secondary-background"
|
||||
>
|
||||
<!-- Errors summary hero -->
|
||||
<div
|
||||
v-if="filteredGroups.length === 0"
|
||||
key="empty"
|
||||
class="px-4 pt-5 pb-15 text-center text-sm text-muted-foreground"
|
||||
data-testid="errors-summary-hero"
|
||||
class="flex items-center gap-2 bg-base-foreground/5 p-2"
|
||||
>
|
||||
{{
|
||||
searchQuery.trim()
|
||||
? t('rightSidePanel.noneSearchDesc')
|
||||
: t('rightSidePanel.noErrors')
|
||||
}}
|
||||
<span
|
||||
class="flex h-12 min-w-9 shrink-0 items-center justify-center px-1 text-[2rem]/none font-extrabold text-destructive-background-hover tabular-nums"
|
||||
>
|
||||
{{ totalErrorCount }}
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="h-9 w-px shrink-0 bg-interface-stroke"
|
||||
/>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1 px-2">
|
||||
<span class="text-xs/tight font-semibold text-base-foreground">
|
||||
{{ t('rightSidePanel.errorsDetected', totalErrorCount) }}
|
||||
</span>
|
||||
<span class="text-xs/tight text-muted-foreground">
|
||||
{{ t('rightSidePanel.resolveBeforeRun') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Group by Class Type -->
|
||||
<PropertiesAccordionItem
|
||||
v-for="group in filteredGroups"
|
||||
:key="group.groupKey"
|
||||
:data-testid="'error-group-' + group.type.replaceAll('_', '-')"
|
||||
:collapse="isSectionCollapsed(group.groupKey) && !isSearching"
|
||||
class="border-b border-interface-stroke"
|
||||
:size="getGroupSize(group)"
|
||||
@update:collapse="setSectionCollapsed(group.groupKey, $event)"
|
||||
>
|
||||
<template #label>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<i
|
||||
class="icon-[lucide--octagon-alert] size-4 shrink-0 text-destructive-background-hover"
|
||||
/>
|
||||
<span class="truncate text-destructive-background-hover">
|
||||
{{ group.displayTitle }}
|
||||
</span>
|
||||
<span
|
||||
v-if="
|
||||
group.type === 'execution' &&
|
||||
getExecutionGroupCount(group) > 1
|
||||
"
|
||||
class="text-destructive-background-hover"
|
||||
>
|
||||
({{ getExecutionGroupCount(group) }})
|
||||
</span>
|
||||
</span>
|
||||
<TransitionGroup tag="div" name="list-scale" class="relative">
|
||||
<ErrorCardSection
|
||||
v-for="group in filteredGroups"
|
||||
:key="group.groupKey"
|
||||
:data-testid="'error-group-' + group.type.replaceAll('_', '-')"
|
||||
:title="group.displayTitle"
|
||||
:count="getGroupCount(group)"
|
||||
:collapse="isSectionCollapsed(group.groupKey) && !isSearching"
|
||||
class="border-t border-secondary-background first:border-t-0"
|
||||
@update:collapse="setSectionCollapsed(group.groupKey, $event)"
|
||||
>
|
||||
<template #actions>
|
||||
<Button
|
||||
v-if="
|
||||
group.type === 'missing_node' &&
|
||||
@@ -62,7 +75,7 @@
|
||||
"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="mr-2 h-8 shrink-0 rounded-lg text-sm"
|
||||
class="shrink-0"
|
||||
:disabled="isInstallingAll"
|
||||
@click.stop="installAll"
|
||||
>
|
||||
@@ -83,7 +96,7 @@
|
||||
"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="mr-2 h-8 shrink-0 rounded-lg text-sm"
|
||||
class="shrink-0"
|
||||
@click.stop="handleReplaceAll()"
|
||||
>
|
||||
{{ t('nodeReplacement.replaceAll', 'Replace All') }}
|
||||
@@ -96,7 +109,7 @@
|
||||
data-testid="missing-model-header-refresh"
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
class="mr-2 shrink-0 rounded-lg hover:bg-transparent hover:text-base-foreground"
|
||||
class="shrink-0 rounded-lg hover:bg-transparent hover:text-base-foreground"
|
||||
:aria-label="t('rightSidePanel.missingModels.refresh')"
|
||||
:aria-busy="missingModelStore.isRefreshingMissingModels"
|
||||
:aria-disabled="missingModelStore.isRefreshingMissingModels"
|
||||
@@ -129,140 +142,142 @@
|
||||
: ''
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-if="group.displayMessage"
|
||||
data-testid="error-group-display-message"
|
||||
class="px-4 pt-1 pb-3"
|
||||
>
|
||||
<p
|
||||
class="m-0 text-sm/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
<div
|
||||
v-if="group.displayMessage"
|
||||
data-testid="error-group-display-message"
|
||||
class="px-3 py-1"
|
||||
>
|
||||
{{ group.displayMessage }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Missing Node Packs -->
|
||||
<MissingNodeCard
|
||||
v-if="group.type === 'missing_node'"
|
||||
:show-info-button="shouldShowManagerButtons"
|
||||
:missing-pack-groups="missingPackGroups"
|
||||
@locate-node="handleLocateMissingNode"
|
||||
@open-manager-info="handleOpenManagerInfo"
|
||||
/>
|
||||
|
||||
<!-- Swap Nodes -->
|
||||
<SwapNodesCard
|
||||
v-if="group.type === 'swap_nodes'"
|
||||
:swap-node-groups="swapNodeGroups"
|
||||
@locate-node="handleLocateMissingNode"
|
||||
@replace="handleReplaceGroup"
|
||||
/>
|
||||
|
||||
<!-- Execution Errors -->
|
||||
<div v-if="isExecutionItemListGroup(group)" class="px-4">
|
||||
<ul class="m-0 list-none space-y-1 p-0">
|
||||
<li
|
||||
v-for="item in getExecutionItemList(group)"
|
||||
:key="item.key"
|
||||
class="min-w-0"
|
||||
<p
|
||||
class="m-0 text-xs/normal wrap-break-word whitespace-pre-wrap text-base-foreground/50"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span class="flex min-w-0 flex-1 items-center gap-1">
|
||||
<button
|
||||
v-tooltip.top="{
|
||||
value: item.displayDetails || undefined,
|
||||
showDelay: 300
|
||||
}"
|
||||
type="button"
|
||||
class="m-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none"
|
||||
@click="handleLocateNode(item.nodeId)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
{{ group.displayMessage }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Missing Node Packs -->
|
||||
<MissingNodeCard
|
||||
v-if="group.type === 'missing_node'"
|
||||
:show-info-button="shouldShowManagerButtons"
|
||||
:missing-pack-groups="missingPackGroups"
|
||||
@locate-node="handleLocateMissingNode"
|
||||
@open-manager-info="handleOpenManagerInfo"
|
||||
/>
|
||||
|
||||
<!-- Swap Nodes -->
|
||||
<SwapNodesCard
|
||||
v-if="group.type === 'swap_nodes'"
|
||||
:swap-node-groups="swapNodeGroups"
|
||||
@locate-node="handleLocateMissingNode"
|
||||
@replace="handleReplaceGroup"
|
||||
/>
|
||||
|
||||
<!-- Execution Errors -->
|
||||
<div v-if="isExecutionItemListGroup(group)" class="px-3">
|
||||
<ul class="m-0 list-none space-y-1 p-0">
|
||||
<li
|
||||
v-for="item in getExecutionItemList(group)"
|
||||
:key="item.key"
|
||||
class="min-w-0"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span class="flex min-w-0 flex-1 items-center gap-1">
|
||||
<button
|
||||
v-tooltip.top="{
|
||||
value: item.displayDetails || undefined,
|
||||
showDelay: 300
|
||||
}"
|
||||
type="button"
|
||||
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-xs/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
|
||||
@click="handleLocateNode(item.nodeId)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
<Button
|
||||
v-if="item.displayDetails"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:class="
|
||||
cn(
|
||||
'size-6 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset',
|
||||
isExecutionItemDetailExpanded(item.key) &&
|
||||
'bg-secondary-background-selected text-base-foreground hover:bg-secondary-background-selected'
|
||||
)
|
||||
"
|
||||
:aria-label="
|
||||
t('rightSidePanel.infoFor', { item: item.label })
|
||||
"
|
||||
:aria-controls="getExecutionItemDetailId(item.key)"
|
||||
:aria-expanded="isExecutionItemDetailExpanded(item.key)"
|
||||
@click.stop="toggleExecutionItemDetail(item.key)"
|
||||
>
|
||||
<i class="icon-[lucide--info] size-3.5" />
|
||||
</Button>
|
||||
</span>
|
||||
<Button
|
||||
v-if="item.displayDetails"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:class="
|
||||
cn(
|
||||
'size-6 shrink-0 text-muted-foreground hover:text-base-foreground',
|
||||
isExecutionItemDetailExpanded(item.key) &&
|
||||
'bg-secondary-background-selected text-base-foreground hover:bg-secondary-background-selected'
|
||||
)
|
||||
"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
|
||||
:aria-label="
|
||||
t('rightSidePanel.infoFor', { item: item.label })
|
||||
t('rightSidePanel.locateNodeFor', { item: item.label })
|
||||
"
|
||||
:aria-controls="getExecutionItemDetailId(item.key)"
|
||||
:aria-expanded="isExecutionItemDetailExpanded(item.key)"
|
||||
@click.stop="toggleExecutionItemDetail(item.key)"
|
||||
@click.stop="handleLocateNode(item.nodeId)"
|
||||
>
|
||||
<i class="icon-[lucide--info] size-3.5" />
|
||||
<i class="icon-[lucide--locate] size-4" />
|
||||
</Button>
|
||||
</span>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
:aria-label="
|
||||
t('rightSidePanel.locateNodeFor', { item: item.label })
|
||||
"
|
||||
@click.stop="handleLocateNode(item.nodeId)"
|
||||
>
|
||||
<i class="icon-[lucide--locate] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<TransitionCollapse>
|
||||
<p
|
||||
v-if="
|
||||
item.displayDetails &&
|
||||
isExecutionItemDetailExpanded(item.key)
|
||||
"
|
||||
:id="getExecutionItemDetailId(item.key)"
|
||||
class="m-0 mt-0.5 pr-10 text-2xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
>
|
||||
{{ item.displayDetails }}
|
||||
</p>
|
||||
</TransitionCollapse>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-else-if="group.type === 'execution'" class="space-y-3 px-4">
|
||||
<ErrorNodeCard
|
||||
v-for="card in group.cards"
|
||||
:key="card.id"
|
||||
:card="card"
|
||||
:compact="isSingleNodeSelected"
|
||||
@locate-node="handleLocateNode"
|
||||
@enter-subgraph="handleEnterSubgraph"
|
||||
@copy-to-clipboard="copyToClipboard"
|
||||
</div>
|
||||
<TransitionCollapse>
|
||||
<p
|
||||
v-if="
|
||||
item.displayDetails &&
|
||||
isExecutionItemDetailExpanded(item.key)
|
||||
"
|
||||
:id="getExecutionItemDetailId(item.key)"
|
||||
class="m-0 mt-0.5 pr-10 text-2xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
>
|
||||
{{ item.displayDetails }}
|
||||
</p>
|
||||
</TransitionCollapse>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-else-if="group.type === 'execution'" class="space-y-3 px-3">
|
||||
<ErrorNodeCard
|
||||
v-for="card in group.cards"
|
||||
:key="card.id"
|
||||
:card="card"
|
||||
:compact="isSingleNodeSelected"
|
||||
@locate-node="handleLocateNode"
|
||||
@enter-subgraph="handleEnterSubgraph"
|
||||
@copy-to-clipboard="copyToClipboard"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Missing Models -->
|
||||
<MissingModelCard
|
||||
v-if="group.type === 'missing_model'"
|
||||
:missing-model-groups="missingModelGroups"
|
||||
@locate-model="handleLocateAssetNode"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Missing Models -->
|
||||
<MissingModelCard
|
||||
v-if="group.type === 'missing_model'"
|
||||
:missing-model-groups="missingModelGroups"
|
||||
@locate-model="handleLocateAssetNode"
|
||||
/>
|
||||
|
||||
<!-- Missing Media -->
|
||||
<MissingMediaCard
|
||||
v-if="group.type === 'missing_media'"
|
||||
:missing-media-groups="missingMediaGroups"
|
||||
@locate-node="handleLocateAssetNode"
|
||||
/>
|
||||
</PropertiesAccordionItem>
|
||||
</TransitionGroup>
|
||||
<!-- Missing Media -->
|
||||
<MissingMediaCard
|
||||
v-if="group.type === 'missing_media'"
|
||||
:missing-media-groups="missingMediaGroups"
|
||||
@locate-node="handleLocateAssetNode"
|
||||
/>
|
||||
</ErrorCardSection>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ErrorPanelSurveyCta v-if="ErrorPanelSurveyCta" />
|
||||
|
||||
<!-- Fixed Footer: Help Links -->
|
||||
<div class="min-w-0 shrink-0 border-t border-interface-stroke p-4">
|
||||
<div
|
||||
class="min-w-0 shrink-0 border-t border-interface-stroke bg-interface-panel-surface p-4"
|
||||
>
|
||||
<i18n-t
|
||||
keypath="rightSidePanel.errorHelp"
|
||||
tag="p"
|
||||
@@ -304,15 +319,16 @@ import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
|
||||
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
||||
|
||||
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
|
||||
import CollapseToggleButton from '../layout/CollapseToggleButton.vue'
|
||||
import TransitionCollapse from '../layout/TransitionCollapse.vue'
|
||||
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
|
||||
import ErrorCardSection from './ErrorCardSection.vue'
|
||||
import ErrorNodeCard from './ErrorNodeCard.vue'
|
||||
import MissingNodeCard from './MissingNodeCard.vue'
|
||||
import SwapNodesCard from '@/platform/nodeReplacement/components/SwapNodesCard.vue'
|
||||
import MissingModelCard from '@/platform/missingModel/components/MissingModelCard.vue'
|
||||
import MissingMediaCard from '@/platform/missingMedia/components/MissingMediaCard.vue'
|
||||
import { countMissingMediaReferences } from '@/platform/missingMedia/missingMediaGrouping'
|
||||
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import DotSpinner from '@/components/common/DotSpinner.vue'
|
||||
@@ -356,16 +372,6 @@ const searchQuery = ref('')
|
||||
const expandedExecutionItemDetailKeys = ref(new Set<string>())
|
||||
const isSearching = computed(() => searchQuery.value.trim() !== '')
|
||||
|
||||
const fullSizeGroupTypes = new Set([
|
||||
'missing_node',
|
||||
'swap_nodes',
|
||||
'missing_model',
|
||||
'missing_media'
|
||||
])
|
||||
function getGroupSize(group: ErrorGroup) {
|
||||
return fullSizeGroupTypes.has(group.type) ? 'lg' : 'default'
|
||||
}
|
||||
|
||||
function isExecutionItemListGroup(group: ErrorGroup) {
|
||||
return (
|
||||
group.type === 'execution' &&
|
||||
@@ -452,6 +458,28 @@ const {
|
||||
swapNodeGroups
|
||||
} = useErrorGroups(searchQuery)
|
||||
|
||||
function getGroupCount(group: ErrorGroup): number {
|
||||
switch (group.type) {
|
||||
case 'execution':
|
||||
return getExecutionGroupCount(group)
|
||||
case 'missing_node':
|
||||
return missingPackGroups.value.length
|
||||
case 'swap_nodes':
|
||||
return swapNodeGroups.value.length
|
||||
case 'missing_model':
|
||||
return missingModelGroups.value.reduce(
|
||||
(total, modelGroup) => total + modelGroup.models.length,
|
||||
0
|
||||
)
|
||||
case 'missing_media':
|
||||
return countMissingMediaReferences(missingMediaGroups.value)
|
||||
}
|
||||
}
|
||||
|
||||
const totalErrorCount = computed(() =>
|
||||
filteredGroups.value.reduce((sum, group) => sum + getGroupCount(group), 0)
|
||||
)
|
||||
|
||||
const showMissingModelHeaderRefresh = computed(
|
||||
() => !isCloud && missingModelGroups.value.length > 0
|
||||
)
|
||||
|
||||
@@ -334,7 +334,7 @@ describe('useErrorGroups', () => {
|
||||
)
|
||||
expect(missingGroup).toBeDefined()
|
||||
expect(missingGroup?.groupKey).toBe('missing_node')
|
||||
expect(missingGroup?.displayTitle).toBe('Missing Node Packs (1)')
|
||||
expect(missingGroup?.displayTitle).toBe('Missing Node Packs')
|
||||
expect(missingGroup?.displayMessage).toBe(
|
||||
'Install missing packs to use this workflow.'
|
||||
)
|
||||
@@ -982,7 +982,7 @@ describe('useErrorGroups', () => {
|
||||
)
|
||||
expect(modelGroup).toBeDefined()
|
||||
expect(modelGroup?.groupKey).toBe('missing_model')
|
||||
expect(modelGroup?.displayTitle).toBe('Missing Models (1)')
|
||||
expect(modelGroup?.displayTitle).toBe('Missing Models')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1098,7 +1098,7 @@ describe('useErrorGroups', () => {
|
||||
const missingMediaGroup = groups.allErrorGroups.value.find(
|
||||
(group) => group.type === 'missing_media'
|
||||
)
|
||||
expect(missingMediaGroup?.displayTitle).toBe('Missing Inputs (2)')
|
||||
expect(missingMediaGroup?.displayTitle).toBe('Missing Inputs')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
180
src/components/searchbox/LinkReleaseContextMenu.test.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
|
||||
import LinkReleaseContextMenu from './LinkReleaseContextMenu.vue'
|
||||
import type {
|
||||
LinkReleaseNodeCategory,
|
||||
LinkReleaseSearchResultGroup
|
||||
} from './linkReleaseMenuModel'
|
||||
|
||||
const { groups } = vi.hoisted(() => ({
|
||||
groups: {
|
||||
suggestions: [] as ComfyNodeDefImpl[],
|
||||
categories: [] as LinkReleaseNodeCategory[],
|
||||
searchResultGroups: [] as LinkReleaseSearchResultGroup[]
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./linkReleaseMenuModel', () => ({
|
||||
getLinkReleaseHeaderLabel: () => '',
|
||||
getLinkReleaseSuggestions: () => groups.suggestions,
|
||||
buildLinkReleaseNodeCategories: () => groups.categories,
|
||||
groupLinkReleaseSearchResults: () => groups.searchResultGroups,
|
||||
searchLinkReleaseNodes: () =>
|
||||
groups.searchResultGroups.flatMap((group) =>
|
||||
group.nodes.map((node) => ({ category: group.category, node }))
|
||||
),
|
||||
filterNodesByName: () => []
|
||||
}))
|
||||
|
||||
const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: {} } })
|
||||
|
||||
const stubs = {
|
||||
DropdownMenuRoot: { template: '<div><slot /></div>' },
|
||||
DropdownMenuTrigger: { template: '<div><slot /></div>' },
|
||||
DropdownMenuPortal: { template: '<div><slot /></div>' },
|
||||
DropdownMenuContent: { template: '<div role="menu"><slot /></div>' },
|
||||
DropdownMenuLabel: { template: '<div><slot /></div>' },
|
||||
DropdownMenuItem: {
|
||||
emits: ['select'],
|
||||
template:
|
||||
'<div role="menuitem" tabindex="-1" @click="$emit(\'select\')"><slot /></div>'
|
||||
},
|
||||
DropdownMenuSeparator: { template: '<hr data-testid="menu-separator" />' },
|
||||
LinkReleaseNodeSubmenu: { template: '<div data-testid="submenu" />' },
|
||||
MiddleTruncate: { template: '<span>{{ text }}</span>', props: ['text'] }
|
||||
}
|
||||
|
||||
function suggestion(name: string): ComfyNodeDefImpl {
|
||||
return { name, display_name: name } as ComfyNodeDefImpl
|
||||
}
|
||||
|
||||
function nodeCategory(
|
||||
key: 'comfy' | 'extensions' | 'partner',
|
||||
labelKey: string = key
|
||||
): LinkReleaseNodeCategory {
|
||||
return { key, labelKey, icon: '', nodes: [suggestion('Node')] }
|
||||
}
|
||||
|
||||
function renderMenu() {
|
||||
return render(LinkReleaseContextMenu, {
|
||||
props: { context: null },
|
||||
global: { plugins: [i18n, createTestingPinia()], stubs }
|
||||
})
|
||||
}
|
||||
|
||||
describe('LinkReleaseContextMenu group divider', () => {
|
||||
beforeEach(() => {
|
||||
groups.suggestions = []
|
||||
groups.categories = []
|
||||
groups.searchResultGroups = []
|
||||
})
|
||||
|
||||
it('renders a divider between the suggestions and categories groups', () => {
|
||||
groups.suggestions = [suggestion('KSampler')]
|
||||
groups.categories = [nodeCategory('comfy')]
|
||||
renderMenu()
|
||||
|
||||
expect(screen.getAllByTestId('menu-separator')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('omits the group divider when only one group is present', () => {
|
||||
groups.suggestions = []
|
||||
groups.categories = [nodeCategory('comfy')]
|
||||
renderMenu()
|
||||
|
||||
expect(screen.getAllByTestId('menu-separator')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('renders a divider between search result groups', async () => {
|
||||
groups.suggestions = []
|
||||
groups.categories = []
|
||||
groups.searchResultGroups = [
|
||||
{
|
||||
category: nodeCategory('extensions', 'contextMenu.Extensions'),
|
||||
nodes: [suggestion('Ext Node')]
|
||||
},
|
||||
{
|
||||
category: nodeCategory('partner', 'contextMenu.Partner Nodes'),
|
||||
nodes: [suggestion('Partner Node')]
|
||||
}
|
||||
]
|
||||
renderMenu()
|
||||
|
||||
await userEvent.type(screen.getByRole('textbox'), 'na')
|
||||
|
||||
expect(screen.getAllByTestId('menu-separator')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('LinkReleaseContextMenu selection', () => {
|
||||
beforeEach(() => {
|
||||
groups.suggestions = []
|
||||
groups.categories = []
|
||||
groups.searchResultGroups = []
|
||||
})
|
||||
|
||||
function renderMenuWith(handlers: Record<string, unknown>) {
|
||||
return render(LinkReleaseContextMenu, {
|
||||
props: { context: null, ...handlers },
|
||||
global: { plugins: [i18n, createTestingPinia()], stubs }
|
||||
})
|
||||
}
|
||||
|
||||
it('emits selectNode when a suggestion is chosen', async () => {
|
||||
groups.suggestions = [suggestion('KSampler')]
|
||||
const onSelectNode = vi.fn()
|
||||
renderMenuWith({ onSelectNode })
|
||||
|
||||
await userEvent.click(screen.getByText('KSampler'))
|
||||
|
||||
expect(onSelectNode).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'KSampler' })
|
||||
)
|
||||
})
|
||||
|
||||
it('emits addReroute when the reroute item is chosen', async () => {
|
||||
groups.suggestions = [suggestion('KSampler')]
|
||||
const onAddReroute = vi.fn()
|
||||
renderMenuWith({ onAddReroute })
|
||||
|
||||
await userEvent.click(screen.getByText('contextMenu.Add Reroute'))
|
||||
|
||||
expect(onAddReroute).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('selects the first search result on Enter in the search field', async () => {
|
||||
groups.searchResultGroups = [
|
||||
{
|
||||
category: nodeCategory('comfy', 'contextMenu.Comfy Nodes'),
|
||||
nodes: [suggestion('Found Node')]
|
||||
}
|
||||
]
|
||||
const onSelectNode = vi.fn()
|
||||
renderMenuWith({ onSelectNode })
|
||||
|
||||
const search = screen.getByRole('textbox')
|
||||
await userEvent.type(search, 'fo')
|
||||
await userEvent.keyboard('{Enter}')
|
||||
|
||||
expect(onSelectNode).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'Found Node' })
|
||||
)
|
||||
})
|
||||
|
||||
it('moves focus to the first item on ArrowDown from the search', async () => {
|
||||
groups.suggestions = [suggestion('KSampler')]
|
||||
renderMenu()
|
||||
|
||||
const search = screen.getByRole('textbox')
|
||||
search.focus()
|
||||
await userEvent.keyboard('{ArrowDown}')
|
||||
|
||||
expect(screen.getAllByRole('menuitem')[0]).toHaveFocus()
|
||||
})
|
||||
})
|
||||
384
src/components/searchbox/LinkReleaseContextMenu.vue
Normal file
@@ -0,0 +1,384 @@
|
||||
<template>
|
||||
<DropdownMenuRoot :open="open" :modal="false" @update:open="onOpenChange">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none fixed size-0"
|
||||
:style="{ left: `${position.x}px`, top: `${position.y}px` }"
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
:side-offset="SIDE_OFFSET"
|
||||
:collision-padding="VIEWPORT_MARGIN"
|
||||
:avoid-collisions="false"
|
||||
:class="contentClass"
|
||||
:style="menuMaxHeight ? { maxHeight: `${menuMaxHeight}px` } : undefined"
|
||||
data-testid="link-release-context-menu"
|
||||
@open-auto-focus.prevent="focusSearch"
|
||||
@close-auto-focus.prevent
|
||||
@entry-focus="onEntryFocus"
|
||||
@keydown.capture="redirectTypingToSearch"
|
||||
>
|
||||
<DropdownMenuLabel
|
||||
v-if="headerLabel"
|
||||
class="flex shrink-0 items-center gap-2 p-2 text-xs font-medium text-muted-foreground uppercase"
|
||||
>
|
||||
<span class="flex size-4 shrink-0 items-center justify-center">
|
||||
<span
|
||||
class="size-4 rounded-full"
|
||||
:style="{ backgroundColor: slotColor }"
|
||||
/>
|
||||
</span>
|
||||
<span class="truncate">{{ headerLabel }}</span>
|
||||
</DropdownMenuLabel>
|
||||
<div data-search-field class="shrink-0 p-0.5">
|
||||
<div
|
||||
class="flex h-9 items-center gap-2 rounded-lg bg-secondary-background px-2"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--search] size-4 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<input
|
||||
ref="searchInput"
|
||||
v-model="query"
|
||||
type="text"
|
||||
data-testid="link-release-search"
|
||||
:placeholder="t('contextMenu.Search')"
|
||||
class="size-full min-w-0 appearance-none border-none bg-transparent text-sm text-base-foreground outline-none placeholder:text-muted-foreground"
|
||||
@keydown="onRootSearchKeydown"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenuSeparator
|
||||
class="-mx-1 my-1 h-px shrink-0 bg-border-subtle"
|
||||
/>
|
||||
|
||||
<div :class="scrollClass">
|
||||
<template v-if="trimmedQuery">
|
||||
<template
|
||||
v-for="(group, groupIndex) in searchResultGroups"
|
||||
:key="group.category.key"
|
||||
>
|
||||
<DropdownMenuSeparator
|
||||
v-if="groupIndex > 0"
|
||||
class="-mx-1 my-1 h-px shrink-0 bg-border-subtle"
|
||||
/>
|
||||
<DropdownMenuItem
|
||||
v-for="node in group.nodes"
|
||||
:key="node.name"
|
||||
:class="itemClass"
|
||||
@select="selectNode(node)"
|
||||
>
|
||||
<i
|
||||
:class="cn(group.category.icon, 'size-4 shrink-0 opacity-80')"
|
||||
/>
|
||||
<span class="flex min-w-0 flex-1 items-center gap-1">
|
||||
<span class="shrink-0 text-muted-foreground">
|
||||
{{ t(group.category.labelKey) }}:
|
||||
</span>
|
||||
<MiddleTruncate
|
||||
:text="node.display_name"
|
||||
class="min-w-0 flex-1"
|
||||
/>
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</template>
|
||||
<div
|
||||
v-if="searchResults.length === 0"
|
||||
class="p-2 text-sm text-muted-foreground"
|
||||
>
|
||||
{{ t('g.noResults') }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<template v-if="suggestions.length">
|
||||
<DropdownMenuLabel
|
||||
class="block truncate p-2 text-xs font-medium text-muted-foreground uppercase"
|
||||
>
|
||||
{{ t('contextMenu.Most Relevant') }}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
v-for="nodeDef in suggestions"
|
||||
:key="nodeDef.name"
|
||||
:class="itemClass"
|
||||
@select="selectNode(nodeDef)"
|
||||
>
|
||||
<MiddleTruncate
|
||||
:text="nodeDef.display_name"
|
||||
class="min-w-0 flex-1"
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
</template>
|
||||
|
||||
<DropdownMenuSeparator
|
||||
v-if="suggestions.length && categories.length"
|
||||
class="-mx-1 my-1 h-px shrink-0 bg-border-subtle"
|
||||
/>
|
||||
|
||||
<template v-if="categories.length">
|
||||
<DropdownMenuLabel
|
||||
class="block truncate p-2 text-xs font-medium text-muted-foreground uppercase"
|
||||
>
|
||||
{{ t('contextMenu.Compatible Nodes') }}
|
||||
</DropdownMenuLabel>
|
||||
<LinkReleaseNodeSubmenu
|
||||
v-for="category in categories"
|
||||
:key="category.key"
|
||||
:category
|
||||
:item-class="itemClass"
|
||||
:content-class="submenuContentClass"
|
||||
:scroll-class="submenuScrollClass"
|
||||
@select="selectNode"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<template v-if="!trimmedQuery">
|
||||
<DropdownMenuSeparator
|
||||
class="-mx-1 my-1 h-px shrink-0 bg-border-subtle"
|
||||
/>
|
||||
<DropdownMenuItem
|
||||
:class="cn(itemClass, 'shrink-0')"
|
||||
@select="addReroute"
|
||||
>
|
||||
<i class="icon-[lucide--git-fork] size-4 shrink-0 opacity-80" />
|
||||
<span class="flex-1 truncate">
|
||||
{{ t('contextMenu.Add Reroute') }}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</template>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRoot,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from 'reka-ui'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { getSlotColor } from '@/constants/slotColors'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
|
||||
import LinkReleaseNodeSubmenu from './LinkReleaseNodeSubmenu.vue'
|
||||
import MiddleTruncate from './MiddleTruncate.vue'
|
||||
import {
|
||||
buildLinkReleaseNodeCategories,
|
||||
computeContextMenuTop,
|
||||
estimateLinkReleaseMenuHeight,
|
||||
getLinkReleaseHeaderLabel,
|
||||
getLinkReleaseSuggestions,
|
||||
groupLinkReleaseSearchResults,
|
||||
searchLinkReleaseNodes
|
||||
} from './linkReleaseMenuModel'
|
||||
import type {
|
||||
LinkReleaseContext,
|
||||
LinkReleaseNodeMatch
|
||||
} from './linkReleaseMenuModel'
|
||||
|
||||
const { context } = defineProps<{ context: LinkReleaseContext | null }>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
selectNode: [nodeDef: ComfyNodeDefImpl]
|
||||
addReroute: []
|
||||
dismiss: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
|
||||
const open = ref(false)
|
||||
const position = ref({ x: 0, y: 0 })
|
||||
const searchInput = ref<HTMLInputElement>()
|
||||
const query = ref('')
|
||||
const menuMaxHeight = ref<number>()
|
||||
let actionTaken = false
|
||||
|
||||
const VIEWPORT_MARGIN = 8
|
||||
const SIDE_OFFSET = 4
|
||||
const MENU_WIDTH = 384
|
||||
|
||||
const contentClass =
|
||||
'z-1700 flex min-w-[260px] max-w-sm flex-col overflow-hidden rounded-lg border border-interface-menu-stroke bg-interface-menu-surface p-1 shadow-interface'
|
||||
const scrollClass =
|
||||
'flex-1 min-h-0 overflow-y-auto overflow-x-hidden scrollbar-custom'
|
||||
const submenuContentClass =
|
||||
'z-1700 flex w-sm flex-col overflow-hidden rounded-lg border border-interface-menu-stroke bg-interface-menu-surface p-1 shadow-interface'
|
||||
const submenuScrollClass =
|
||||
'flex-1 min-h-0 overflow-y-auto overflow-x-hidden scrollbar-custom'
|
||||
const itemClass =
|
||||
'flex cursor-pointer items-center gap-2 rounded-lg px-2 py-2 text-sm text-base-foreground outline-none select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-highlighted:bg-interface-menu-component-surface-hovered'
|
||||
|
||||
const headerLabel = computed(() =>
|
||||
context ? getLinkReleaseHeaderLabel(context) : ''
|
||||
)
|
||||
|
||||
const slotColor = computed(() => getSlotColor(context?.dataType?.split(',')[0]))
|
||||
|
||||
const trimmedQuery = computed(() => query.value.trim())
|
||||
|
||||
const typeFilter = computed(() => {
|
||||
if (!context) return null
|
||||
const svc = nodeDefStore.nodeSearchService
|
||||
return {
|
||||
filterDef: context.isFromOutput
|
||||
? svc.inputTypeFilter
|
||||
: svc.outputTypeFilter,
|
||||
value: context.dataType
|
||||
}
|
||||
})
|
||||
|
||||
const compatibleNodes = computed<ComfyNodeDefImpl[]>(() => {
|
||||
if (!typeFilter.value) return []
|
||||
return nodeDefStore.nodeSearchService.searchNode('', [typeFilter.value], {
|
||||
limit: 500
|
||||
})
|
||||
})
|
||||
|
||||
const defaultNodeDefs = computed<ComfyNodeDefImpl[]>(() => {
|
||||
if (!context?.dataType) return []
|
||||
const table = context.isFromOutput
|
||||
? LiteGraph.slot_types_default_out
|
||||
: LiteGraph.slot_types_default_in
|
||||
const types = table?.[context.dataType] ?? []
|
||||
return types
|
||||
.map((type) => nodeDefStore.allNodeDefsByName[type])
|
||||
.filter((nodeDef): nodeDef is ComfyNodeDefImpl => Boolean(nodeDef))
|
||||
})
|
||||
|
||||
const suggestions = computed(() =>
|
||||
getLinkReleaseSuggestions(defaultNodeDefs.value)
|
||||
)
|
||||
const categories = computed(() =>
|
||||
buildLinkReleaseNodeCategories(compatibleNodes.value)
|
||||
)
|
||||
|
||||
const searchResultGroups = computed(() =>
|
||||
groupLinkReleaseSearchResults(categories.value, trimmedQuery.value)
|
||||
)
|
||||
const searchResults = computed<LinkReleaseNodeMatch[]>(() =>
|
||||
searchLinkReleaseNodes(categories.value, trimmedQuery.value)
|
||||
)
|
||||
|
||||
function selectNode(nodeDef: ComfyNodeDefImpl) {
|
||||
actionTaken = true
|
||||
emit('selectNode', nodeDef)
|
||||
hide()
|
||||
}
|
||||
|
||||
function addReroute() {
|
||||
actionTaken = true
|
||||
emit('addReroute')
|
||||
hide()
|
||||
}
|
||||
|
||||
function focusSearch() {
|
||||
searchInput.value?.focus()
|
||||
}
|
||||
|
||||
function isPrintableKey(event: KeyboardEvent) {
|
||||
return (
|
||||
event.key.length === 1 &&
|
||||
event.key !== ' ' &&
|
||||
!event.ctrlKey &&
|
||||
!event.metaKey &&
|
||||
!event.altKey
|
||||
)
|
||||
}
|
||||
|
||||
// When the keyboard focus is on a menu item, funnel printable keystrokes into
|
||||
// the search field instead of letting Reka run its item type-ahead.
|
||||
function redirectTypingToSearch(event: KeyboardEvent) {
|
||||
if (event.target === searchInput.value || !isPrintableKey(event)) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
query.value += event.key
|
||||
focusSearch()
|
||||
}
|
||||
|
||||
// Reka refocuses the first item (scrolling the list to the top) whenever the
|
||||
// menu regains focus, which fires as the pointer leaves an item while scrolling.
|
||||
function onEntryFocus(event: Event) {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
function focusFirstItem(target: HTMLElement) {
|
||||
const menu = target.closest<HTMLElement>('[role="menu"]')
|
||||
menu
|
||||
?.querySelector<HTMLElement>('[role="menuitem"]:not([data-disabled])')
|
||||
?.focus()
|
||||
}
|
||||
|
||||
function onRootSearchKeydown(event: KeyboardEvent) {
|
||||
// Let Reka close the menu natively on Escape.
|
||||
if (event.key === 'Escape') return
|
||||
event.stopPropagation()
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
focusFirstItem(event.currentTarget as HTMLElement)
|
||||
} else if (event.key === 'Enter' && trimmedQuery.value) {
|
||||
const first = searchResults.value[0]
|
||||
if (first) selectNode(first.node)
|
||||
}
|
||||
}
|
||||
|
||||
function show(event: MouseEvent) {
|
||||
actionTaken = false
|
||||
query.value = ''
|
||||
const menuHeight = estimateLinkReleaseMenuHeight({
|
||||
hasHeader: Boolean(headerLabel.value),
|
||||
suggestionCount: suggestions.value.length,
|
||||
categoryCount: categories.value.length,
|
||||
searchResultCount: 0,
|
||||
showReroute: true
|
||||
})
|
||||
const menuTop = computeContextMenuTop({
|
||||
cursorY: event.clientY,
|
||||
menuHeight,
|
||||
viewportHeight: window.innerHeight,
|
||||
margin: VIEWPORT_MARGIN,
|
||||
sideOffset: SIDE_OFFSET
|
||||
})
|
||||
menuMaxHeight.value = window.innerHeight - menuTop - VIEWPORT_MARGIN
|
||||
const maxX = Math.max(
|
||||
VIEWPORT_MARGIN,
|
||||
window.innerWidth - MENU_WIDTH - VIEWPORT_MARGIN
|
||||
)
|
||||
position.value = {
|
||||
x: Math.min(maxX, Math.max(VIEWPORT_MARGIN, event.clientX)),
|
||||
y: menuTop - SIDE_OFFSET
|
||||
}
|
||||
void nextTick(() => {
|
||||
open.value = true
|
||||
})
|
||||
}
|
||||
|
||||
function hide() {
|
||||
open.value = false
|
||||
}
|
||||
|
||||
function onOpenChange(value: boolean) {
|
||||
open.value = value
|
||||
if (value) return
|
||||
if (!actionTaken) emit('dismiss')
|
||||
actionTaken = false
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
120
src/components/searchbox/LinkReleaseNodeSubmenu.stories.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRoot,
|
||||
DropdownMenuTrigger
|
||||
} from 'reka-ui'
|
||||
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
|
||||
import LinkReleaseNodeSubmenu from './LinkReleaseNodeSubmenu.vue'
|
||||
import type { LinkReleaseNodeCategory } from './linkReleaseMenuModel'
|
||||
|
||||
const contentClass =
|
||||
'z-1700 flex max-h-[min(80vh,var(--reka-dropdown-menu-content-available-height))] min-w-[260px] max-w-sm flex-col overflow-hidden rounded-lg border border-interface-menu-stroke bg-interface-menu-surface p-1 shadow-interface'
|
||||
const submenuContentClass =
|
||||
'z-1700 flex w-sm max-h-[min(80vh,var(--reka-dropdown-menu-content-available-height))] flex-col overflow-hidden rounded-lg border border-interface-menu-stroke bg-interface-menu-surface p-1 shadow-interface'
|
||||
const submenuScrollClass =
|
||||
'overflow-y-auto scrollbar-custom max-h-[min(calc(var(--reka-dropdown-menu-content-available-height)-3.5rem),80vh)]'
|
||||
const itemClass =
|
||||
'flex cursor-pointer items-center gap-2 rounded-lg px-3 py-1.5 text-sm text-base-foreground outline-none select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-highlighted:bg-interface-menu-component-surface-hovered'
|
||||
|
||||
function node(name: string, display_name = name): ComfyNodeDefImpl {
|
||||
return { name, display_name } as ComfyNodeDefImpl
|
||||
}
|
||||
|
||||
const category: LinkReleaseNodeCategory = {
|
||||
key: 'comfy',
|
||||
labelKey: 'contextMenu.Comfy Nodes',
|
||||
icon: 'icon-[lucide--box]',
|
||||
nodes: [
|
||||
node('KSampler'),
|
||||
node('VAEDecode', 'VAE Decode'),
|
||||
node('VAEEncode', 'VAE Encode'),
|
||||
node('CLIPTextEncode', 'CLIP Text Encode'),
|
||||
node('LoadImage', 'Load Image'),
|
||||
node('SaveImage', 'Save Image'),
|
||||
node('EmptyLatentImage', 'Empty Latent Image'),
|
||||
node(
|
||||
'StableCascade_StageB_Conditioning',
|
||||
'StableCascade_StageB_Conditioning'
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
const meta: Meta<typeof LinkReleaseNodeSubmenu> = {
|
||||
title: 'Components/Searchbox/LinkReleaseNodeSubmenu',
|
||||
component: LinkReleaseNodeSubmenu
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
function renderAnchored(side: 'left' | 'right'): Story['render'] {
|
||||
return () => ({
|
||||
components: {
|
||||
DropdownMenuRoot,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
LinkReleaseNodeSubmenu
|
||||
},
|
||||
setup() {
|
||||
const anchorStyle =
|
||||
side === 'right'
|
||||
? 'position: fixed; top: 64px; right: 16px;'
|
||||
: 'position: fixed; top: 64px; left: 16px;'
|
||||
return {
|
||||
anchorStyle,
|
||||
contentClass,
|
||||
submenuContentClass,
|
||||
submenuScrollClass,
|
||||
itemClass,
|
||||
category,
|
||||
side
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div style="height: 480px;">
|
||||
<DropdownMenuRoot default-open>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<button :style="anchorStyle" class="rounded-md border border-interface-menu-stroke bg-interface-menu-surface px-3 py-1.5 text-sm text-base-foreground">
|
||||
Compatible Nodes
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
:class="contentClass"
|
||||
:side="side === 'right' ? 'bottom' : 'bottom'"
|
||||
:align="side === 'right' ? 'end' : 'start'"
|
||||
:side-offset="4"
|
||||
>
|
||||
<DropdownMenuLabel class="block truncate px-3 pt-2 pb-1 text-xs font-medium text-muted-foreground uppercase">
|
||||
Compatible Nodes
|
||||
</DropdownMenuLabel>
|
||||
<LinkReleaseNodeSubmenu
|
||||
:category="category"
|
||||
:item-class="itemClass"
|
||||
:content-class="submenuContentClass"
|
||||
:scroll-class="submenuScrollClass"
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuRoot>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
/** Anchored near the LEFT edge: the submenu opens to the RIGHT (normal). */
|
||||
export const OpensRight: Story = { render: renderAnchored('left') }
|
||||
|
||||
/**
|
||||
* Anchored near the RIGHT edge: with no room on the right, Floating UI flips the
|
||||
* submenu to the LEFT, landing flush against the parent menu's left edge.
|
||||
*/
|
||||
export const FlipsLeft: Story = { render: renderAnchored('right') }
|
||||
138
src/components/searchbox/LinkReleaseNodeSubmenu.test.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { Slots } from 'vue'
|
||||
import { computed, h, inject, nextTick, provide } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
|
||||
import LinkReleaseNodeSubmenu from './LinkReleaseNodeSubmenu.vue'
|
||||
import type { LinkReleaseNodeCategory } from './linkReleaseMenuModel'
|
||||
|
||||
const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: {} } })
|
||||
|
||||
const category: LinkReleaseNodeCategory = {
|
||||
key: 'comfy',
|
||||
labelKey: 'Comfy Nodes',
|
||||
icon: 'icon-[lucide--box]',
|
||||
nodes: [{ name: 'KSampler', display_name: 'KSampler' } as ComfyNodeDefImpl]
|
||||
}
|
||||
|
||||
const SUB_OPEN = Symbol('subOpen')
|
||||
|
||||
const stubs = {
|
||||
DropdownMenuSub: {
|
||||
props: ['open'],
|
||||
setup(props: { open?: boolean }, { slots }: { slots: Slots }) {
|
||||
provide(
|
||||
SUB_OPEN,
|
||||
computed(() => props.open ?? false)
|
||||
)
|
||||
return () => h('div', slots.default?.())
|
||||
}
|
||||
},
|
||||
DropdownMenuSubTrigger: {
|
||||
template: '<button data-testid="sub-trigger"><slot /></button>'
|
||||
},
|
||||
DropdownMenuPortal: { template: '<div><slot /></div>' },
|
||||
DropdownMenuSubContent: {
|
||||
setup(_: unknown, { slots }: { slots: Slots }) {
|
||||
const open = inject<{ value: boolean }>(SUB_OPEN)
|
||||
return () =>
|
||||
open?.value ? h('div', { role: 'menu' }, slots.default?.()) : null
|
||||
}
|
||||
},
|
||||
DropdownMenuSeparator: { template: '<hr />' },
|
||||
DropdownMenuItem: {
|
||||
template: '<div role="menuitem" tabindex="-1"><slot /></div>'
|
||||
},
|
||||
MiddleTruncate: { template: '<span>{{ text }}</span>', props: ['text'] }
|
||||
}
|
||||
|
||||
function renderSubmenu() {
|
||||
return render(LinkReleaseNodeSubmenu, {
|
||||
props: { category, itemClass: '', contentClass: '', scrollClass: '' },
|
||||
global: { plugins: [i18n], stubs }
|
||||
})
|
||||
}
|
||||
|
||||
describe('LinkReleaseNodeSubmenu keyboard handling', () => {
|
||||
it('steps into the submenu search on ArrowRight', async () => {
|
||||
renderSubmenu()
|
||||
await userEvent.click(screen.getByTestId('sub-trigger'))
|
||||
await userEvent.keyboard('{ArrowRight}')
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByRole('textbox')).toHaveFocus()
|
||||
})
|
||||
|
||||
it('steps into the submenu search on Enter', async () => {
|
||||
renderSubmenu()
|
||||
await userEvent.click(screen.getByTestId('sub-trigger'))
|
||||
await userEvent.keyboard('{Enter}')
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByRole('textbox')).toHaveFocus()
|
||||
})
|
||||
|
||||
it('does not move focus to the search on other keys', async () => {
|
||||
renderSubmenu()
|
||||
await userEvent.click(screen.getByTestId('sub-trigger'))
|
||||
await userEvent.keyboard('a')
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByRole('textbox')).not.toHaveFocus()
|
||||
})
|
||||
|
||||
async function stepIntoSearch() {
|
||||
await userEvent.click(screen.getByTestId('sub-trigger'))
|
||||
await userEvent.keyboard('{ArrowRight}')
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
it('selects the first filtered node on Enter in the search', async () => {
|
||||
const onSelect = vi.fn()
|
||||
render(LinkReleaseNodeSubmenu, {
|
||||
props: {
|
||||
category,
|
||||
itemClass: '',
|
||||
contentClass: '',
|
||||
scrollClass: '',
|
||||
onSelect
|
||||
},
|
||||
global: { plugins: [i18n], stubs }
|
||||
})
|
||||
await stepIntoSearch()
|
||||
expect(screen.getByRole('textbox')).toHaveFocus()
|
||||
|
||||
await userEvent.keyboard('{Enter}')
|
||||
expect(onSelect).toHaveBeenCalledWith(category.nodes[0])
|
||||
})
|
||||
|
||||
it('does not select on Escape in the search', async () => {
|
||||
const onSelect = vi.fn()
|
||||
render(LinkReleaseNodeSubmenu, {
|
||||
props: {
|
||||
category,
|
||||
itemClass: '',
|
||||
contentClass: '',
|
||||
scrollClass: '',
|
||||
onSelect
|
||||
},
|
||||
global: { plugins: [i18n], stubs }
|
||||
})
|
||||
await stepIntoSearch()
|
||||
|
||||
await userEvent.keyboard('{Escape}')
|
||||
expect(onSelect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('moves focus to the first node on ArrowDown from the search', async () => {
|
||||
renderSubmenu()
|
||||
await stepIntoSearch()
|
||||
|
||||
await userEvent.keyboard('{ArrowDown}')
|
||||
expect(screen.getByRole('menuitem')).toHaveFocus()
|
||||
})
|
||||
})
|
||||
242
src/components/searchbox/LinkReleaseNodeSubmenu.vue
Normal file
@@ -0,0 +1,242 @@
|
||||
<template>
|
||||
<DropdownMenuSub v-model:open="open">
|
||||
<DropdownMenuSubTrigger
|
||||
ref="triggerRef"
|
||||
:class="triggerClass"
|
||||
@focus="open = true"
|
||||
@keydown="onTriggerKeydown"
|
||||
@blur="onTriggerBlur"
|
||||
>
|
||||
<i :class="cn(category.icon, 'size-4 shrink-0 opacity-80')" />
|
||||
<span class="flex-1 truncate">{{ t(category.labelKey) }}</span>
|
||||
<span
|
||||
class="rounded-full bg-interface-menu-keybind-surface-default px-1.5 text-xs text-muted-foreground"
|
||||
>
|
||||
{{ category.nodes.length }}
|
||||
</span>
|
||||
<i class="icon-[lucide--chevron-right] size-4 shrink-0 opacity-60" />
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<!--
|
||||
Opens to the right of the trigger; when there's no room, Floating UI
|
||||
flips it to the LEFT. align-offset is computed per-open
|
||||
(alignToContextMenu) so the submenu's search field lines up with the
|
||||
root search field instead of the hovered trigger row. The height is also
|
||||
pinned per-open: maxHeight grows into the viewport space below the
|
||||
submenu top but never drops under the context menu height, so the panel
|
||||
scrolls internally instead of letting Floating UI shift it upward.
|
||||
-->
|
||||
<DropdownMenuSubContent
|
||||
:class="contentClass"
|
||||
:style="maxHeight ? { maxHeight: `${maxHeight}px` } : undefined"
|
||||
side="right"
|
||||
align="start"
|
||||
:side-offset="-2"
|
||||
:align-offset="alignOffset"
|
||||
:collision-padding="8"
|
||||
update-position-strategy="optimized"
|
||||
@open-auto-focus.prevent
|
||||
@entry-focus="onEntryFocus"
|
||||
@keydown.capture="redirectTypingToSearch"
|
||||
>
|
||||
<div class="shrink-0 p-0.5">
|
||||
<div
|
||||
class="flex h-9 items-center gap-2 rounded-lg bg-secondary-background px-2"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--search] size-4 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<input
|
||||
ref="searchInput"
|
||||
v-model="query"
|
||||
type="text"
|
||||
:aria-label="
|
||||
t('g.searchPlaceholder', { subject: t(category.labelKey) })
|
||||
"
|
||||
:placeholder="
|
||||
t('g.searchPlaceholder', { subject: t(category.labelKey) })
|
||||
"
|
||||
class="size-full min-w-0 appearance-none border-none bg-transparent text-sm text-base-foreground outline-none placeholder:text-muted-foreground"
|
||||
@keydown="onSearchKeydown"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenuSeparator
|
||||
class="-mx-1 my-1 h-px shrink-0 bg-border-subtle"
|
||||
/>
|
||||
<div :class="scrollClass">
|
||||
<DropdownMenuItem
|
||||
v-for="nodeDef in filteredNodes"
|
||||
:key="nodeDef.name"
|
||||
:class="itemClass"
|
||||
@select="emit('select', nodeDef)"
|
||||
>
|
||||
<MiddleTruncate
|
||||
:text="nodeDef.display_name"
|
||||
class="min-w-0 flex-1 self-stretch"
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
<div
|
||||
v-if="filteredNodes.length === 0"
|
||||
class="px-3 py-2 text-sm text-muted-foreground"
|
||||
>
|
||||
{{ t('g.noResults') }}
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger
|
||||
} from 'reka-ui'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
|
||||
import MiddleTruncate from './MiddleTruncate.vue'
|
||||
import {
|
||||
computeSubmenuAlignOffset,
|
||||
computeSubmenuMaxHeight,
|
||||
filterNodesByName
|
||||
} from './linkReleaseMenuModel'
|
||||
import type { LinkReleaseNodeCategory } from './linkReleaseMenuModel'
|
||||
|
||||
const { category, itemClass, contentClass, scrollClass } = defineProps<{
|
||||
category: LinkReleaseNodeCategory
|
||||
itemClass: string
|
||||
contentClass: string
|
||||
scrollClass: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [nodeDef: ComfyNodeDefImpl]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const open = ref(false)
|
||||
const query = ref('')
|
||||
const searchInput = ref<HTMLInputElement>()
|
||||
const triggerRef = ref<InstanceType<typeof DropdownMenuSubTrigger>>()
|
||||
// Pin the submenu's search field to the root search field rather than to the
|
||||
// hovered trigger row; both recomputed each time the submenu opens.
|
||||
const alignOffset = ref(-5)
|
||||
const maxHeight = ref<number>()
|
||||
|
||||
const VIEWPORT_MARGIN = 8
|
||||
|
||||
const triggerClass = computed(() =>
|
||||
cn(itemClass, 'data-[state=open]:bg-interface-menu-component-surface-hovered')
|
||||
)
|
||||
|
||||
const filteredNodes = computed(() =>
|
||||
filterNodesByName(category.nodes, query.value)
|
||||
)
|
||||
|
||||
function alignToContextMenu() {
|
||||
const triggerEl = triggerRef.value?.$el as HTMLElement | undefined
|
||||
const rootMenu = triggerEl?.closest<HTMLElement>('[role="menu"]')
|
||||
const rootSearch = rootMenu?.querySelector<HTMLElement>('[data-search-field]')
|
||||
if (!triggerEl || !rootMenu || !rootSearch) return
|
||||
const triggerTop = triggerEl.getBoundingClientRect().top
|
||||
const rootRect = rootMenu.getBoundingClientRect()
|
||||
const rootSearchTop = rootSearch.getBoundingClientRect().top
|
||||
const contentPaddingTop = parseFloat(getComputedStyle(rootMenu).paddingTop)
|
||||
alignOffset.value = computeSubmenuAlignOffset({
|
||||
triggerTop,
|
||||
rootSearchTop,
|
||||
contentPaddingTop
|
||||
})
|
||||
maxHeight.value = computeSubmenuMaxHeight({
|
||||
submenuTop: rootSearchTop - contentPaddingTop,
|
||||
contextMenuHeight: rootRect.height,
|
||||
viewportHeight: window.innerHeight,
|
||||
margin: VIEWPORT_MARGIN
|
||||
})
|
||||
}
|
||||
|
||||
watch(open, (isOpen) => {
|
||||
if (isOpen) alignToContextMenu()
|
||||
else query.value = ''
|
||||
})
|
||||
|
||||
function focusSearch() {
|
||||
searchInput.value?.focus()
|
||||
}
|
||||
|
||||
function submenuContent() {
|
||||
return searchInput.value?.closest<HTMLElement>('[role="menu"]') ?? null
|
||||
}
|
||||
|
||||
// Step into the open submenu, landing on its search field.
|
||||
function onTriggerKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'ArrowRight' && event.key !== 'Enter') return
|
||||
event.preventDefault()
|
||||
open.value = true
|
||||
void nextTick(focusSearch)
|
||||
}
|
||||
|
||||
// Close the preview when focus leaves the trigger to a sibling item rather
|
||||
// than into the submenu content.
|
||||
function onTriggerBlur(event: FocusEvent) {
|
||||
const next = event.relatedTarget
|
||||
if (next instanceof Node && submenuContent()?.contains(next)) return
|
||||
open.value = false
|
||||
}
|
||||
|
||||
function isPrintableKey(event: KeyboardEvent) {
|
||||
return (
|
||||
event.key.length === 1 &&
|
||||
event.key !== ' ' &&
|
||||
!event.ctrlKey &&
|
||||
!event.metaKey &&
|
||||
!event.altKey
|
||||
)
|
||||
}
|
||||
|
||||
// When the keyboard focus is on a submenu item, funnel printable keystrokes
|
||||
// into this submenu's search field instead of Reka's item type-ahead.
|
||||
function redirectTypingToSearch(event: KeyboardEvent) {
|
||||
if (event.target === searchInput.value || !isPrintableKey(event)) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
query.value += event.key
|
||||
focusSearch()
|
||||
}
|
||||
|
||||
// Reka refocuses the first item (scrolling the list to the top) whenever the
|
||||
// menu regains focus, which fires as the pointer leaves an item while scrolling.
|
||||
function onEntryFocus(event: Event) {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
function focusFirstNode(target: HTMLElement) {
|
||||
const panel = target.closest<HTMLElement>('[role="menu"]')
|
||||
panel
|
||||
?.querySelector<HTMLElement>('[role="menuitem"]:not([data-disabled])')
|
||||
?.focus()
|
||||
}
|
||||
|
||||
function onSearchKeydown(event: KeyboardEvent) {
|
||||
// Let Reka handle submenu/menu navigation keys natively.
|
||||
if (event.key === 'Escape' || event.key === 'ArrowLeft') return
|
||||
event.stopPropagation()
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
focusFirstNode(event.currentTarget as HTMLElement)
|
||||
} else if (event.key === 'Enter') {
|
||||
const first = filteredNodes.value[0]
|
||||
if (first) emit('select', first)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
182
src/components/searchbox/MiddleTruncate.test.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import MiddleTruncate from './MiddleTruncate.vue'
|
||||
import * as overflow from './isTextOverflowing'
|
||||
|
||||
function stubRect(el: HTMLElement, rect: Partial<DOMRect>) {
|
||||
el.getBoundingClientRect = () =>
|
||||
({
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
...rect
|
||||
}) as DOMRect
|
||||
}
|
||||
|
||||
describe('MiddleTruncate', () => {
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(document.documentElement, 'clientWidth', {
|
||||
configurable: true,
|
||||
value: 1024
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
Reflect.deleteProperty(document.documentElement, 'clientWidth')
|
||||
})
|
||||
|
||||
it('renders the full text inline', () => {
|
||||
render(MiddleTruncate, { props: { text: 'KSampler' } })
|
||||
expect(screen.getByText('KSampler')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not reveal a tooltip when the text fits', async () => {
|
||||
vi.spyOn(overflow, 'measureTextWidth').mockReturnValue(0)
|
||||
render(MiddleTruncate, { props: { text: 'KSampler' } })
|
||||
await userEvent.hover(screen.getByText('KSampler'))
|
||||
expect(screen.queryByRole('tooltip')).toBeNull()
|
||||
})
|
||||
|
||||
it('reveals the full text on hover when truncated', async () => {
|
||||
vi.spyOn(overflow, 'measureTextWidth').mockReturnValue(500)
|
||||
const longName = 'ONNX Detector (SEGS/legacy) - use BBOXDetector'
|
||||
render(MiddleTruncate, { props: { text: longName } })
|
||||
const el = screen.getByText(longName)
|
||||
stubRect(el, { left: 10, top: 20, width: 100, height: 20 })
|
||||
await userEvent.hover(el)
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent(longName)
|
||||
})
|
||||
|
||||
it('reveals when hovering anywhere on the parent menu item', async () => {
|
||||
vi.spyOn(overflow, 'measureTextWidth').mockReturnValue(500)
|
||||
const longName = 'ONNX Detector (SEGS/legacy) - use BBOXDetector'
|
||||
render({
|
||||
components: { MiddleTruncate },
|
||||
template: `<div role="menuitem"><MiddleTruncate text="${longName}" /></div>`
|
||||
})
|
||||
stubRect(screen.getByText(longName), {
|
||||
left: 10,
|
||||
top: 20,
|
||||
width: 120,
|
||||
height: 20
|
||||
})
|
||||
await userEvent.hover(screen.getByRole('menuitem'))
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent(longName)
|
||||
})
|
||||
|
||||
it('sizes the reveal to the parent menu item height', async () => {
|
||||
vi.spyOn(overflow, 'measureTextWidth').mockReturnValue(500)
|
||||
const nodeName = 'A long truncated node name'
|
||||
render({
|
||||
components: { MiddleTruncate },
|
||||
template: `<div role="menuitem"><MiddleTruncate text="${nodeName}" /></div>`
|
||||
})
|
||||
stubRect(screen.getByText(nodeName), {
|
||||
left: 10,
|
||||
top: 20,
|
||||
width: 100,
|
||||
height: 20
|
||||
})
|
||||
stubRect(screen.getByRole('menuitem'), {
|
||||
left: 0,
|
||||
top: 10,
|
||||
right: 200,
|
||||
width: 200,
|
||||
height: 36
|
||||
})
|
||||
await userEvent.hover(screen.getByText(nodeName))
|
||||
expect(screen.getByRole('tooltip')).toHaveStyle({ height: '36px' })
|
||||
})
|
||||
|
||||
it('anchors the reveal to the left when it fits to the right', async () => {
|
||||
vi.spyOn(overflow, 'measureTextWidth').mockReturnValue(50)
|
||||
const nodeName = 'Fits To The Right'
|
||||
render({
|
||||
components: { MiddleTruncate },
|
||||
template: `<div role="menuitem"><MiddleTruncate text="${nodeName}" /></div>`
|
||||
})
|
||||
stubRect(screen.getByText(nodeName), {
|
||||
left: 10,
|
||||
top: 20,
|
||||
width: 100,
|
||||
height: 20
|
||||
})
|
||||
stubRect(screen.getByRole('menuitem'), {
|
||||
left: 0,
|
||||
top: 10,
|
||||
right: 200,
|
||||
width: 200,
|
||||
height: 36
|
||||
})
|
||||
await userEvent.hover(screen.getByText(nodeName))
|
||||
expect(screen.getByRole('tooltip')).toHaveStyle({ left: '10px' })
|
||||
})
|
||||
|
||||
it('flips to a right anchor when revealing rightward would overflow', async () => {
|
||||
vi.spyOn(overflow, 'measureTextWidth').mockReturnValue(600)
|
||||
const nodeName = 'A very long node name near the right edge'
|
||||
render({
|
||||
components: { MiddleTruncate },
|
||||
template: `<div role="menuitem" style="padding-right: 16px"><MiddleTruncate text="${nodeName}" /></div>`
|
||||
})
|
||||
stubRect(screen.getByText(nodeName), {
|
||||
left: 850,
|
||||
top: 20,
|
||||
width: 150,
|
||||
height: 20
|
||||
})
|
||||
stubRect(screen.getByRole('menuitem'), {
|
||||
left: 840,
|
||||
top: 10,
|
||||
right: 1000,
|
||||
width: 160,
|
||||
height: 36
|
||||
})
|
||||
await userEvent.hover(screen.getByText(nodeName))
|
||||
const tooltip = screen.getByRole('tooltip')
|
||||
// Anchored to the item's right edge (1024 - 1000), independent of its padding.
|
||||
expect(tooltip).toHaveStyle({ right: '24px' })
|
||||
expect(tooltip).not.toHaveStyle({ left: '850px' })
|
||||
})
|
||||
|
||||
it('hides the reveal when the pointer leaves the menu item', async () => {
|
||||
vi.spyOn(overflow, 'measureTextWidth').mockReturnValue(500)
|
||||
const nodeName = 'A long truncated node name'
|
||||
render({
|
||||
components: { MiddleTruncate },
|
||||
template: `<div role="menuitem"><MiddleTruncate text="${nodeName}" /></div>`
|
||||
})
|
||||
const el = screen.getByText(nodeName)
|
||||
stubRect(el, { left: 10, top: 20, width: 100, height: 20 })
|
||||
await userEvent.hover(el)
|
||||
expect(screen.getByRole('tooltip')).toBeInTheDocument()
|
||||
|
||||
await userEvent.unhover(el)
|
||||
expect(screen.queryByRole('tooltip')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the reveal while the pointer moves within the menu item', async () => {
|
||||
vi.spyOn(overflow, 'measureTextWidth').mockReturnValue(500)
|
||||
const nodeName = 'A long truncated node name'
|
||||
render({
|
||||
components: { MiddleTruncate },
|
||||
template: `<div role="menuitem"><MiddleTruncate text="${nodeName}" /><span data-testid="sibling">x</span></div>`
|
||||
})
|
||||
const el = screen.getByText(nodeName)
|
||||
stubRect(el, { left: 10, top: 20, width: 100, height: 20 })
|
||||
await userEvent.hover(el)
|
||||
expect(screen.getByRole('tooltip')).toBeInTheDocument()
|
||||
|
||||
await userEvent.pointer({ target: screen.getByTestId('sibling') })
|
||||
expect(screen.getByRole('tooltip')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
156
src/components/searchbox/MiddleTruncate.vue
Normal file
@@ -0,0 +1,156 @@
|
||||
<template>
|
||||
<span
|
||||
ref="elRef"
|
||||
v-bind="$attrs"
|
||||
:class="cn('block min-w-0 truncate', revealed && 'text-transparent')"
|
||||
@pointerenter="reveal"
|
||||
@pointermove="reveal"
|
||||
@pointerleave="onPointerLeave"
|
||||
@focusin="reveal"
|
||||
@focusout="hide"
|
||||
>
|
||||
{{ text }}
|
||||
</span>
|
||||
<Teleport to="body">
|
||||
<span
|
||||
v-if="revealed && revealStyle"
|
||||
role="tooltip"
|
||||
:class="
|
||||
cn(
|
||||
'pointer-events-none fixed z-99999 inline-flex items-center rounded-lg bg-interface-menu-component-surface-hovered pr-3 text-sm whitespace-nowrap text-base-foreground shadow-interface',
|
||||
revealRect?.anchor === 'right' && 'pl-3'
|
||||
)
|
||||
"
|
||||
:style="revealStyle"
|
||||
>
|
||||
{{ text }}
|
||||
</span>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { measureTextWidth } from './isTextOverflowing'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const { text } = defineProps<{ text: string }>()
|
||||
|
||||
// Gap kept between the reveal and the viewport edge (mirrors the menu's
|
||||
// collision-padding) and the reveal's own far-side padding (`pl-3`/`pr-3`).
|
||||
const VIEWPORT_MARGIN = 8
|
||||
const REVEAL_PADDING = 12
|
||||
|
||||
type RevealRect = {
|
||||
top: number
|
||||
height: number
|
||||
minWidth: number
|
||||
maxWidth: number
|
||||
anchor: 'left' | 'right'
|
||||
offset: number
|
||||
}
|
||||
|
||||
const elRef = ref<HTMLElement>()
|
||||
const revealed = ref(false)
|
||||
const revealRect = ref<RevealRect>()
|
||||
|
||||
const revealStyle = computed(() => {
|
||||
const rect = revealRect.value
|
||||
if (!rect) return undefined
|
||||
return {
|
||||
top: `${rect.top}px`,
|
||||
height: `${rect.height}px`,
|
||||
minWidth: `${rect.minWidth}px`,
|
||||
maxWidth: `${rect.maxWidth}px`,
|
||||
width: 'max-content',
|
||||
[rect.anchor]: `${rect.offset}px`
|
||||
}
|
||||
})
|
||||
|
||||
const menuItem = computed(
|
||||
() =>
|
||||
elRef.value?.closest<HTMLElement>('[role="menuitem"]') ??
|
||||
elRef.value?.parentElement ??
|
||||
null
|
||||
)
|
||||
|
||||
function getRevealRect(el: HTMLElement, textWidth: number): RevealRect {
|
||||
const textRect = el.getBoundingClientRect()
|
||||
const item = menuItem.value
|
||||
const itemRect = item?.getBoundingClientRect()
|
||||
const paddingRight = item
|
||||
? Number.parseFloat(getComputedStyle(item).paddingRight) || 0
|
||||
: 0
|
||||
const rightInset = itemRect ? itemRect.right - paddingRight : textRect.right
|
||||
const itemRight = itemRect ? itemRect.right : textRect.right
|
||||
const viewportWidth = document.documentElement.clientWidth
|
||||
const top = itemRect?.top ?? textRect.top
|
||||
const height = itemRect?.height ?? textRect.height
|
||||
const minWidth = Math.max(textRect.width, rightInset - textRect.left)
|
||||
const neededWidth = Math.max(minWidth, textWidth + REVEAL_PADDING)
|
||||
const fitsRight =
|
||||
textRect.left + neededWidth <= viewportWidth - VIEWPORT_MARGIN
|
||||
|
||||
if (fitsRight) {
|
||||
return {
|
||||
top,
|
||||
height,
|
||||
minWidth,
|
||||
maxWidth: viewportWidth - VIEWPORT_MARGIN - textRect.left,
|
||||
anchor: 'left',
|
||||
offset: textRect.left
|
||||
}
|
||||
}
|
||||
return {
|
||||
top,
|
||||
height,
|
||||
minWidth,
|
||||
maxWidth: itemRight - VIEWPORT_MARGIN,
|
||||
anchor: 'right',
|
||||
offset: Math.max(VIEWPORT_MARGIN, viewportWidth - itemRight)
|
||||
}
|
||||
}
|
||||
|
||||
function reveal() {
|
||||
const el = elRef.value
|
||||
if (!el) {
|
||||
revealed.value = false
|
||||
return
|
||||
}
|
||||
const textWidth = measureTextWidth(el)
|
||||
if (textWidth <= el.clientWidth + 0.5) {
|
||||
revealed.value = false
|
||||
return
|
||||
}
|
||||
revealRect.value = getRevealRect(el, textWidth)
|
||||
revealed.value = true
|
||||
}
|
||||
|
||||
function hide() {
|
||||
revealed.value = false
|
||||
}
|
||||
|
||||
function isStillOverMenuItem(related: EventTarget | null) {
|
||||
const item = menuItem.value
|
||||
return (
|
||||
related instanceof Node &&
|
||||
item != null &&
|
||||
(item === related || item.contains(related))
|
||||
)
|
||||
}
|
||||
|
||||
function onPointerLeave(event: PointerEvent) {
|
||||
if (isStillOverMenuItem(event.relatedTarget)) return
|
||||
hide()
|
||||
}
|
||||
|
||||
useEventListener(menuItem, 'pointerenter', reveal)
|
||||
useEventListener(menuItem, 'pointermove', reveal)
|
||||
useEventListener(menuItem, 'pointerleave', (event: PointerEvent) => {
|
||||
if (isStillOverMenuItem(event.relatedTarget)) return
|
||||
hide()
|
||||
})
|
||||
</script>
|
||||
@@ -6,10 +6,14 @@ import { computed, defineComponent, nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { CORE_SETTINGS } from '@/platform/settings/constants/coreSettings'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import type { Settings } from '@/schemas/apiSchema'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
|
||||
import type { FuseFilter, FuseFilterWithValue } from '@/utils/fuseUtil'
|
||||
|
||||
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
|
||||
|
||||
import NodeSearchBoxPopover from './NodeSearchBoxPopover.vue'
|
||||
|
||||
const coreSettingsById = Object.fromEntries(CORE_SETTINGS.map((s) => [s.id, s]))
|
||||
@@ -51,6 +55,7 @@ describe('NodeSearchBoxPopover', () => {
|
||||
let emitAddFilter: EmitAddFilter | null = null
|
||||
let emitAddNodeV1: EmitAddNode | null = null
|
||||
let emitAddNodeV2: EmitAddNode | null = null
|
||||
let emitSelectNode: ((nodeDef: ComfyNodeDefImpl) => void) | null = null
|
||||
|
||||
const NodeSearchBoxStub = defineComponent({
|
||||
name: 'NodeSearchBox',
|
||||
@@ -82,6 +87,17 @@ describe('NodeSearchBoxPopover', () => {
|
||||
template: '<div data-testid="search-content-v2"></div>'
|
||||
})
|
||||
|
||||
const LinkReleaseContextMenuStub = defineComponent({
|
||||
name: 'LinkReleaseContextMenu',
|
||||
props: { context: { type: Object, default: null } },
|
||||
emits: ['selectNode', 'addReroute', 'dismiss'],
|
||||
setup(_, { emit }) {
|
||||
emitSelectNode = (nodeDef) => emit('selectNode', nodeDef)
|
||||
return {}
|
||||
},
|
||||
template: '<div data-testid="link-release-menu" />'
|
||||
})
|
||||
|
||||
const pinia = createTestingPinia({
|
||||
stubActions: false,
|
||||
initialState: {
|
||||
@@ -99,6 +115,7 @@ describe('NodeSearchBoxPopover', () => {
|
||||
stubs: {
|
||||
NodeSearchBox: NodeSearchBoxStub,
|
||||
NodeSearchContent: NodeSearchContentStub,
|
||||
LinkReleaseContextMenu: LinkReleaseContextMenuStub,
|
||||
NodePreviewCard: true,
|
||||
Dialog: {
|
||||
template: '<div><slot name="container" /></div>',
|
||||
@@ -122,6 +139,11 @@ describe('NodeSearchBoxPopover', () => {
|
||||
if (!emitAddNodeV2)
|
||||
throw new Error('NodeSearchContent stub did not mount')
|
||||
return emitAddNodeV2
|
||||
},
|
||||
get emitSelectNode() {
|
||||
if (!emitSelectNode)
|
||||
throw new Error('LinkReleaseContextMenu stub did not mount')
|
||||
return emitSelectNode
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -276,4 +298,122 @@ describe('NodeSearchBoxPopover', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('selecting a node from the link-release menu', () => {
|
||||
function setupCanvas() {
|
||||
const selectNode = vi.fn()
|
||||
const canvasStore = useCanvasStore()
|
||||
canvasStore.canvas = {
|
||||
graph: { nodes: [] },
|
||||
allow_searchbox: false,
|
||||
setDirty: vi.fn(),
|
||||
selectNode,
|
||||
linkConnector: {
|
||||
events: new EventTarget(),
|
||||
reset: vi.fn(),
|
||||
disconnectLinks: vi.fn(),
|
||||
connectToNode: vi.fn()
|
||||
}
|
||||
} as unknown as ReturnType<typeof useCanvasStore>['canvas']
|
||||
return { selectNode }
|
||||
}
|
||||
|
||||
it('auto-selects the placed node on the canvas', async () => {
|
||||
const node = { id: 7 }
|
||||
const { emitSelectNode } = renderComponent({
|
||||
'Comfy.NodeSearchBoxImpl': 'default'
|
||||
})
|
||||
const { selectNode } = setupCanvas()
|
||||
addNodeOnGraph.mockReturnValue(node)
|
||||
|
||||
emitSelectNode({ name: 'KSampler' } as ComfyNodeDefImpl)
|
||||
await nextTick()
|
||||
|
||||
expect(selectNode).toHaveBeenCalledWith(node)
|
||||
})
|
||||
|
||||
it('does not select when the node could not be created', async () => {
|
||||
const { emitSelectNode } = renderComponent({
|
||||
'Comfy.NodeSearchBoxImpl': 'default'
|
||||
})
|
||||
const { selectNode } = setupCanvas()
|
||||
addNodeOnGraph.mockReturnValue(null)
|
||||
|
||||
emitSelectNode({ name: 'KSampler' } as ComfyNodeDefImpl)
|
||||
await nextTick()
|
||||
|
||||
expect(selectNode).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('defaultRootFilter on dialog open', () => {
|
||||
function setGraphNodes(nodes: unknown[]) {
|
||||
const canvasStore = useCanvasStore()
|
||||
canvasStore.canvas = {
|
||||
graph: { nodes },
|
||||
allow_searchbox: false,
|
||||
setDirty: vi.fn(),
|
||||
linkConnector: {
|
||||
events: new EventTarget(),
|
||||
reset: vi.fn(),
|
||||
disconnectLinks: vi.fn()
|
||||
}
|
||||
} as unknown as ReturnType<typeof useCanvasStore>['canvas']
|
||||
}
|
||||
|
||||
async function openSearch() {
|
||||
useSearchBoxStore().visible = true
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
it('defaults to Essentials when the graph is empty', async () => {
|
||||
renderComponent({ 'Comfy.NodeSearchBoxImpl': 'default' })
|
||||
setGraphNodes([])
|
||||
await openSearch()
|
||||
|
||||
expect(screen.getByTestId('search-content-v2')).toHaveAttribute(
|
||||
'data-default-root-filter',
|
||||
RootCategory.Essentials
|
||||
)
|
||||
})
|
||||
|
||||
it('defaults to Essentials when the canvas is not yet available', async () => {
|
||||
renderComponent({ 'Comfy.NodeSearchBoxImpl': 'default' })
|
||||
await openSearch()
|
||||
|
||||
expect(screen.getByTestId('search-content-v2')).toHaveAttribute(
|
||||
'data-default-root-filter',
|
||||
RootCategory.Essentials
|
||||
)
|
||||
})
|
||||
|
||||
it('defaults to null when the graph has nodes', async () => {
|
||||
renderComponent({ 'Comfy.NodeSearchBoxImpl': 'default' })
|
||||
setGraphNodes([{ id: 1 }])
|
||||
await openSearch()
|
||||
|
||||
expect(screen.getByTestId('search-content-v2')).not.toHaveAttribute(
|
||||
'data-default-root-filter'
|
||||
)
|
||||
})
|
||||
|
||||
it('re-evaluates each time the dialog opens', async () => {
|
||||
renderComponent({ 'Comfy.NodeSearchBoxImpl': 'default' })
|
||||
|
||||
setGraphNodes([])
|
||||
await openSearch()
|
||||
expect(screen.getByTestId('search-content-v2')).toHaveAttribute(
|
||||
'data-default-root-filter',
|
||||
RootCategory.Essentials
|
||||
)
|
||||
|
||||
useSearchBoxStore().visible = false
|
||||
await nextTick()
|
||||
setGraphNodes([{ id: 1 }])
|
||||
await openSearch()
|
||||
expect(screen.getByTestId('search-content-v2')).not.toHaveAttribute(
|
||||
'data-default-root-filter'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -27,6 +27,8 @@
|
||||
<div v-if="useSearchBoxV2" role="search" class="relative">
|
||||
<NodeSearchContent
|
||||
:filters="nodeFilters"
|
||||
:default-root-filter="defaultRootFilter"
|
||||
:data-default-root-filter="defaultRootFilter"
|
||||
@add-filter="addFilter"
|
||||
@remove-filter="removeFilter"
|
||||
@add-node="addNode"
|
||||
@@ -51,6 +53,13 @@
|
||||
/>
|
||||
</template>
|
||||
</Dialog>
|
||||
<LinkReleaseContextMenu
|
||||
ref="linkReleaseMenu"
|
||||
:context="linkReleaseContext"
|
||||
@select-node="connectNodeFromMenu"
|
||||
@add-reroute="addRerouteFromMenu"
|
||||
@dismiss="reset"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -62,7 +71,11 @@ import { computed, ref, toRaw, watch, watchEffect } from 'vue'
|
||||
|
||||
import type { Point } from '@/lib/litegraph/src/interfaces'
|
||||
import type { LiteGraphCanvasEvent } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
LGraphNode,
|
||||
LiteGraph,
|
||||
isNodeSlot
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useSurveyFeatureTracking } from '@/platform/surveys/useSurveyFeatureTracking'
|
||||
@@ -78,11 +91,14 @@ import type { FuseFilterWithValue } from '@/utils/fuseUtil'
|
||||
|
||||
import NodePreviewCard from '@/components/node/NodePreviewCard.vue'
|
||||
|
||||
import LinkReleaseContextMenu from './LinkReleaseContextMenu.vue'
|
||||
import type { LinkReleaseContext } from './linkReleaseMenuModel'
|
||||
import NodeSearchContent from './v2/NodeSearchContent.vue'
|
||||
import { RootCategory } from './v2/rootCategories'
|
||||
import type { RootCategoryId } from './v2/rootCategories'
|
||||
import NodeSearchBox from './NodeSearchBox.vue'
|
||||
|
||||
let triggerEvent: CanvasPointerEvent | null = null
|
||||
let listenerController: AbortController | null = null
|
||||
let disconnectOnReset = false
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
@@ -103,6 +119,8 @@ const enableNodePreview = computed(
|
||||
settingStore.get('Comfy.NodeSearchBoxImpl.NodePreview') &&
|
||||
windowWidth.value >= MIN_WIDTH_FOR_PREVIEW
|
||||
)
|
||||
const linkReleaseMenu = ref<InstanceType<typeof LinkReleaseContextMenu>>()
|
||||
const linkReleaseContext = ref<LinkReleaseContext | null>(null)
|
||||
function getNewNodeLocation(): Point {
|
||||
return triggerEvent
|
||||
? [triggerEvent.canvasX, triggerEvent.canvasY]
|
||||
@@ -129,16 +147,26 @@ function closeDialog() {
|
||||
}
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
function addNode(nodeDef: ComfyNodeDefImpl, dragEvent?: MouseEvent) {
|
||||
const followCursor = settingStore.get('Comfy.NodeSearchBoxImpl.FollowCursor')
|
||||
// Pre-select the Essentials category when opening search on an empty graph,
|
||||
// where new users benefit most from a curated starting set.
|
||||
const defaultRootFilter = computed<RootCategoryId | null>(() => {
|
||||
const graph = canvasStore.canvas?.graph
|
||||
return graph && graph.nodes.length > 0 ? null : RootCategory.Essentials
|
||||
})
|
||||
|
||||
function connectNewNode(
|
||||
nodeDef: ComfyNodeDefImpl,
|
||||
options: { ghost?: boolean; dragEvent?: MouseEvent } = {}
|
||||
): LGraphNode | null {
|
||||
const { ghost = false, dragEvent } = options
|
||||
const node = withNodeAddSource('search_modal', () =>
|
||||
litegraphService.addNodeOnGraph(
|
||||
nodeDef,
|
||||
{ pos: getNewNodeLocation() },
|
||||
{ ghost: useSearchBoxV2.value && followCursor, dragEvent }
|
||||
{ ghost, dragEvent }
|
||||
)
|
||||
)
|
||||
if (!node) return
|
||||
if (!node) return null
|
||||
|
||||
if (disconnectOnReset && triggerEvent) {
|
||||
canvasStore.getCanvas().linkConnector.connectToNode(node, triggerEvent)
|
||||
@@ -150,6 +178,16 @@ function addNode(nodeDef: ComfyNodeDefImpl, dragEvent?: MouseEvent) {
|
||||
|
||||
// Notify changeTracker - new step should be added
|
||||
useWorkflowStore().activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
|
||||
return node
|
||||
}
|
||||
|
||||
function addNode(nodeDef: ComfyNodeDefImpl, dragEvent?: MouseEvent) {
|
||||
const followCursor = settingStore.get('Comfy.NodeSearchBoxImpl.FollowCursor')
|
||||
connectNewNode(nodeDef, {
|
||||
ghost: useSearchBoxV2.value && followCursor,
|
||||
dragEvent
|
||||
})
|
||||
window.requestAnimationFrame(closeDialog)
|
||||
}
|
||||
|
||||
@@ -202,62 +240,46 @@ function showContextMenu(e: CanvasPointerEvent) {
|
||||
const firstLink = getFirstLink()
|
||||
if (!firstLink) return
|
||||
|
||||
const { node, fromSlot, toType } = firstLink
|
||||
const commonOptions = {
|
||||
e,
|
||||
allow_searchbox: true,
|
||||
showSearchBox: () => {
|
||||
cancelResetOnContextClose()
|
||||
showSearchBox(e)
|
||||
}
|
||||
const { fromSlot, toType } = firstLink
|
||||
linkReleaseContext.value = {
|
||||
dataType: fromSlot.type?.toString() ?? '',
|
||||
slotName: fromSlot.name ?? '',
|
||||
isFromOutput: toType === 'input'
|
||||
}
|
||||
const afterRerouteId = firstLink.fromReroute?.id
|
||||
const connectionOptions =
|
||||
toType === 'input'
|
||||
? { nodeFrom: node, slotFrom: fromSlot, afterRerouteId }
|
||||
: { nodeTo: node, slotTo: fromSlot, afterRerouteId }
|
||||
|
||||
const canvas = canvasStore.getCanvas()
|
||||
const menu = canvas.showConnectionMenu({
|
||||
...connectionOptions,
|
||||
...commonOptions
|
||||
})
|
||||
|
||||
if (!menu) {
|
||||
console.warn('No menu was returned from showConnectionMenu')
|
||||
return
|
||||
}
|
||||
|
||||
triggerEvent = e
|
||||
listenerController = new AbortController()
|
||||
const { signal } = listenerController
|
||||
const options = { once: true, signal }
|
||||
|
||||
// Connect the node after it is created via context menu
|
||||
useEventListener(
|
||||
canvas.canvas,
|
||||
'connect-new-default-node',
|
||||
(createEvent) => {
|
||||
if (!(createEvent instanceof CustomEvent))
|
||||
throw new Error('Invalid event')
|
||||
// Hide the dangling link while the menu holds the connection open; the real
|
||||
// edge reappears once a node is committed (reset clears this flag).
|
||||
const canvas = canvasStore.getCanvas()
|
||||
canvas.linkConnector.renderLinksHidden = true
|
||||
canvas.setDirty(true, true)
|
||||
|
||||
const node: unknown = createEvent.detail?.node
|
||||
if (!(node instanceof LGraphNode)) throw new Error('Invalid node')
|
||||
linkReleaseMenu.value?.show(e)
|
||||
}
|
||||
|
||||
disconnectOnReset = false
|
||||
createEvent.preventDefault()
|
||||
canvas.linkConnector.connectToNode(node, e)
|
||||
},
|
||||
options
|
||||
)
|
||||
function connectNodeFromMenu(nodeDef: ComfyNodeDefImpl) {
|
||||
const node = connectNewNode(nodeDef)
|
||||
if (node) canvasStore.getCanvas().selectNode(node)
|
||||
reset()
|
||||
}
|
||||
|
||||
// Reset when the context menu is closed
|
||||
const cancelResetOnContextClose = useEventListener(
|
||||
menu.controller.signal,
|
||||
'abort',
|
||||
reset,
|
||||
options
|
||||
)
|
||||
function addRerouteFromMenu() {
|
||||
const firstLink = getFirstLink()
|
||||
const node = firstLink?.node
|
||||
if (
|
||||
firstLink &&
|
||||
triggerEvent &&
|
||||
node instanceof LGraphNode &&
|
||||
isNodeSlot(firstLink.fromSlot)
|
||||
) {
|
||||
node.connectFloatingReroute(
|
||||
[triggerEvent.canvasX, triggerEvent.canvasY],
|
||||
firstLink.fromSlot,
|
||||
firstLink.fromReroute?.id
|
||||
)
|
||||
useWorkflowStore().activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
}
|
||||
reset()
|
||||
}
|
||||
|
||||
// Disable litegraph's default behavior of release link and search box.
|
||||
@@ -333,25 +355,32 @@ function handleDroppedOnCanvas(e: CustomEvent<CanvasPointerEvent>) {
|
||||
|
||||
// Resets litegraph state
|
||||
function reset() {
|
||||
listenerController?.abort()
|
||||
listenerController = null
|
||||
triggerEvent = null
|
||||
|
||||
const canvas = canvasStore.getCanvas()
|
||||
canvas.linkConnector.events.removeEventListener('reset', preventDefault)
|
||||
if (disconnectOnReset) canvas.linkConnector.disconnectLinks()
|
||||
disconnectOnReset = false
|
||||
|
||||
canvas.linkConnector.reset()
|
||||
canvas.setDirty(true, true)
|
||||
}
|
||||
|
||||
// Tears down a held link-release session synchronously so a new link drag can
|
||||
// take over without hitting LinkConnector's "Already dragging links" guard.
|
||||
function cancelLinkRelease() {
|
||||
linkReleaseMenu.value?.hide()
|
||||
visible.value = false
|
||||
reset()
|
||||
}
|
||||
|
||||
// Reset connecting links when the search box is closed
|
||||
watch(visible, () => {
|
||||
if (!visible.value) reset()
|
||||
})
|
||||
|
||||
useEventListener(document, 'litegraph:canvas', canvasEventHandler)
|
||||
defineExpose({ showSearchBox })
|
||||
defineExpose({ showSearchBox, cancelLinkRelease })
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
45
src/components/searchbox/isTextOverflowing.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { isTextOverflowing } from './isTextOverflowing'
|
||||
|
||||
const CHAR_WIDTH = 10
|
||||
|
||||
function setup(text: string, contentWidth: number) {
|
||||
const el = document.createElement('span')
|
||||
el.textContent = text
|
||||
Object.defineProperty(el, 'clientWidth', {
|
||||
configurable: true,
|
||||
value: contentWidth
|
||||
})
|
||||
vi.spyOn(window, 'getComputedStyle').mockReturnValue(
|
||||
{} as CSSStyleDeclaration
|
||||
)
|
||||
vi.spyOn(
|
||||
HTMLSpanElement.prototype,
|
||||
'getBoundingClientRect'
|
||||
).mockImplementation(function (this: HTMLSpanElement) {
|
||||
return { width: (this.textContent?.length ?? 0) * CHAR_WIDTH } as DOMRect
|
||||
})
|
||||
return el
|
||||
}
|
||||
|
||||
describe('isTextOverflowing', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('returns false when the text fits the content width', () => {
|
||||
const el = setup('KSampler', 200)
|
||||
expect(isTextOverflowing(el)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when the full text is wider than the content width', () => {
|
||||
const el = setup('ONNX Detector (SEGS/legacy) - use BBOXDetector', 120)
|
||||
expect(isTextOverflowing(el)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for a zero-width element', () => {
|
||||
const el = setup('anything', 0)
|
||||
expect(isTextOverflowing(el)).toBe(false)
|
||||
})
|
||||
})
|
||||
46
src/components/searchbox/isTextOverflowing.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
const FONT_PROPS = [
|
||||
'fontStyle',
|
||||
'fontVariant',
|
||||
'fontWeight',
|
||||
'fontStretch',
|
||||
'fontSize',
|
||||
'fontFamily',
|
||||
'letterSpacing',
|
||||
'textTransform',
|
||||
'wordSpacing'
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Measures the full, unclipped width of an element's text by rendering it in a
|
||||
* hidden clone that copies the element's font metrics. `scrollWidth` is
|
||||
* unreliable for `text-overflow: ellipsis` in Chrome (it often reports equal to
|
||||
* `clientWidth`), so the clone is the source of truth.
|
||||
*/
|
||||
export function measureTextWidth(el: HTMLElement): number {
|
||||
const style = getComputedStyle(el)
|
||||
const clone = document.createElement('span')
|
||||
clone.textContent = el.textContent ?? ''
|
||||
clone.style.position = 'fixed'
|
||||
clone.style.top = '-9999px'
|
||||
clone.style.left = '-9999px'
|
||||
clone.style.visibility = 'hidden'
|
||||
clone.style.whiteSpace = 'nowrap'
|
||||
for (const prop of FONT_PROPS) clone.style[prop] = style[prop]
|
||||
|
||||
document.body.appendChild(clone)
|
||||
const textWidth = clone.getBoundingClientRect().width
|
||||
clone.remove()
|
||||
|
||||
return textWidth
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects whether a single-line, ellipsis-truncated element is actually
|
||||
* clipping its text by comparing its full text width against the available
|
||||
* content width.
|
||||
*/
|
||||
export function isTextOverflowing(el: HTMLElement): boolean {
|
||||
const contentWidth = el.clientWidth
|
||||
if (contentWidth <= 0) return false
|
||||
return measureTextWidth(el) > contentWidth + 0.5
|
||||
}
|
||||
317
src/components/searchbox/linkReleaseMenuModel.test.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { NodeSourceType } from '@/types/nodeSource'
|
||||
|
||||
import {
|
||||
buildLinkReleaseNodeCategories,
|
||||
computeContextMenuTop,
|
||||
computeSubmenuAlignOffset,
|
||||
computeSubmenuMaxHeight,
|
||||
estimateLinkReleaseMenuHeight,
|
||||
filterNodesByName,
|
||||
getLinkReleaseHeaderLabel,
|
||||
getLinkReleaseSuggestions,
|
||||
groupLinkReleaseSearchResults,
|
||||
searchLinkReleaseNodes
|
||||
} from './linkReleaseMenuModel'
|
||||
import type { LinkReleaseContext } from './linkReleaseMenuModel'
|
||||
|
||||
function coreNode(name: string, display_name = name): ComfyNodeDefImpl {
|
||||
return {
|
||||
name,
|
||||
display_name,
|
||||
nodeSource: { type: NodeSourceType.Core },
|
||||
api_node: false
|
||||
} as ComfyNodeDefImpl
|
||||
}
|
||||
|
||||
function customNode(name: string, display_name = name): ComfyNodeDefImpl {
|
||||
return {
|
||||
name,
|
||||
display_name,
|
||||
nodeSource: { type: NodeSourceType.CustomNodes },
|
||||
api_node: false
|
||||
} as ComfyNodeDefImpl
|
||||
}
|
||||
|
||||
function partnerNode(name: string, display_name = name): ComfyNodeDefImpl {
|
||||
return {
|
||||
name,
|
||||
display_name,
|
||||
nodeSource: { type: NodeSourceType.Core },
|
||||
api_node: true
|
||||
} as ComfyNodeDefImpl
|
||||
}
|
||||
|
||||
const ksampler = coreNode('KSampler')
|
||||
const vaeDecode = coreNode('VAEDecode', 'VAE Decode')
|
||||
const rerouteNode = coreNode('Reroute')
|
||||
|
||||
function createContext(
|
||||
overrides: Partial<LinkReleaseContext> = {}
|
||||
): LinkReleaseContext {
|
||||
return {
|
||||
dataType: 'MODEL',
|
||||
slotName: 'model',
|
||||
isFromOutput: true,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('getLinkReleaseHeaderLabel', () => {
|
||||
it('combines slot name and data type', () => {
|
||||
const label = getLinkReleaseHeaderLabel(
|
||||
createContext({ slotName: 'model', dataType: 'MODEL' })
|
||||
)
|
||||
expect(label).toBe('model | MODEL')
|
||||
})
|
||||
|
||||
it('falls back to whichever value is present', () => {
|
||||
const onlyType = getLinkReleaseHeaderLabel(
|
||||
createContext({ slotName: '', dataType: 'IMAGE' })
|
||||
)
|
||||
const onlyName = getLinkReleaseHeaderLabel(
|
||||
createContext({ slotName: 'clip', dataType: '' })
|
||||
)
|
||||
expect(onlyType).toBe('IMAGE')
|
||||
expect(onlyName).toBe('clip')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getLinkReleaseSuggestions', () => {
|
||||
it('excludes the Reroute node', () => {
|
||||
const suggestions = getLinkReleaseSuggestions([rerouteNode, vaeDecode])
|
||||
expect(suggestions.map((n) => n.name)).toEqual(['VAEDecode'])
|
||||
})
|
||||
|
||||
it('preserves the incoming order of remaining nodes', () => {
|
||||
const suggestions = getLinkReleaseSuggestions([vaeDecode, ksampler])
|
||||
expect(suggestions.map((n) => n.name)).toEqual(['VAEDecode', 'KSampler'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildLinkReleaseNodeCategories', () => {
|
||||
it('groups nodes by source into comfy, extensions and partner buckets', () => {
|
||||
const ext = customNode('ExtNode', 'Ext Node')
|
||||
const partner = partnerNode('PartnerNode', 'Partner Node')
|
||||
|
||||
const categories = buildLinkReleaseNodeCategories([ksampler, ext, partner])
|
||||
const byKey = Object.fromEntries(categories.map((c) => [c.key, c]))
|
||||
|
||||
expect(byKey.comfy.nodes.map((n) => n.name)).toContain('KSampler')
|
||||
expect(byKey.extensions.nodes.map((n) => n.name)).toContain('ExtNode')
|
||||
expect(byKey.partner.nodes.map((n) => n.name)).toContain('PartnerNode')
|
||||
})
|
||||
|
||||
it('omits empty buckets', () => {
|
||||
const categories = buildLinkReleaseNodeCategories([ksampler])
|
||||
expect(categories.map((c) => c.key)).toEqual(['comfy'])
|
||||
})
|
||||
|
||||
it('orders buckets comfy, extensions, partner', () => {
|
||||
const categories = buildLinkReleaseNodeCategories([
|
||||
partnerNode('P'),
|
||||
customNode('E'),
|
||||
coreNode('C')
|
||||
])
|
||||
expect(categories.map((c) => c.key)).toEqual([
|
||||
'comfy',
|
||||
'extensions',
|
||||
'partner'
|
||||
])
|
||||
})
|
||||
|
||||
it('sorts nodes alphabetically by display name within a bucket', () => {
|
||||
const categories = buildLinkReleaseNodeCategories([
|
||||
coreNode('B'),
|
||||
coreNode('A')
|
||||
])
|
||||
expect(categories[0].nodes.map((n) => n.display_name)).toEqual(['A', 'B'])
|
||||
})
|
||||
|
||||
it('classifies api-category nodes as partner', () => {
|
||||
const apiNode = {
|
||||
name: 'ApiThing',
|
||||
display_name: 'Api Thing',
|
||||
nodeSource: { type: NodeSourceType.Core },
|
||||
api_node: false,
|
||||
category: 'api node/openai'
|
||||
} as ComfyNodeDefImpl
|
||||
|
||||
const categories = buildLinkReleaseNodeCategories([apiNode])
|
||||
expect(categories.map((c) => c.key)).toEqual(['partner'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('filterNodesByName', () => {
|
||||
it('returns all nodes when query is blank', () => {
|
||||
expect(filterNodesByName([ksampler, vaeDecode], ' ')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('matches display name case-insensitively', () => {
|
||||
const result = filterNodesByName([ksampler, vaeDecode], 'vae')
|
||||
expect(result.map((n) => n.name)).toEqual(['VAEDecode'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('groupLinkReleaseSearchResults', () => {
|
||||
const categories = buildLinkReleaseNodeCategories([
|
||||
coreNode('LoadImage', 'Load Image'),
|
||||
customNode('ImageBlend', 'Image Blend'),
|
||||
partnerNode('ImageGen', 'Image Gen'),
|
||||
coreNode('KSampler')
|
||||
])
|
||||
|
||||
it('returns no groups for a blank query', () => {
|
||||
expect(groupLinkReleaseSearchResults(categories, ' ')).toEqual([])
|
||||
})
|
||||
|
||||
it('groups matching nodes by category', () => {
|
||||
const groups = groupLinkReleaseSearchResults(categories, 'image')
|
||||
expect(groups.map((g) => g.category.key)).toEqual([
|
||||
'comfy',
|
||||
'extensions',
|
||||
'partner'
|
||||
])
|
||||
expect(groups.map((g) => g.nodes.map((n) => n.name))).toEqual([
|
||||
['LoadImage'],
|
||||
['ImageBlend'],
|
||||
['ImageGen']
|
||||
])
|
||||
})
|
||||
|
||||
it('omits categories with no matches', () => {
|
||||
const groups = groupLinkReleaseSearchResults(categories, 'ksampler')
|
||||
expect(groups.map((g) => g.category.key)).toEqual(['comfy'])
|
||||
expect(groups[0].nodes.map((n) => n.name)).toEqual(['KSampler'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('searchLinkReleaseNodes', () => {
|
||||
const categories = buildLinkReleaseNodeCategories([
|
||||
coreNode('LoadImage', 'Load Image'),
|
||||
customNode('ImageBlend', 'Image Blend'),
|
||||
partnerNode('ImageGen', 'Image Gen'),
|
||||
coreNode('KSampler')
|
||||
])
|
||||
|
||||
it('returns no matches for a blank query', () => {
|
||||
expect(searchLinkReleaseNodes(categories, ' ')).toEqual([])
|
||||
})
|
||||
|
||||
it('flattens matching nodes across categories, tagged with their category', () => {
|
||||
const matches = searchLinkReleaseNodes(categories, 'image')
|
||||
expect(matches.map((m) => m.node.name)).toEqual([
|
||||
'LoadImage',
|
||||
'ImageBlend',
|
||||
'ImageGen'
|
||||
])
|
||||
expect(matches.map((m) => m.category.key)).toEqual([
|
||||
'comfy',
|
||||
'extensions',
|
||||
'partner'
|
||||
])
|
||||
})
|
||||
|
||||
it('matches display name case-insensitively', () => {
|
||||
const matches = searchLinkReleaseNodes(categories, 'ksampler')
|
||||
expect(matches.map((m) => m.node.name)).toEqual(['KSampler'])
|
||||
expect(matches[0].category.key).toBe('comfy')
|
||||
})
|
||||
|
||||
it('returns an empty list when nothing matches', () => {
|
||||
expect(searchLinkReleaseNodes(categories, 'zzz')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeSubmenuAlignOffset', () => {
|
||||
it('lifts the submenu up to the root search field for a trigger below it', () => {
|
||||
const offset = computeSubmenuAlignOffset({
|
||||
triggerTop: 200,
|
||||
rootSearchTop: 48,
|
||||
contentPaddingTop: 4
|
||||
})
|
||||
expect(offset).toBe(-156)
|
||||
})
|
||||
|
||||
it('offsets only by the content padding when the trigger sits at the search field', () => {
|
||||
const offset = computeSubmenuAlignOffset({
|
||||
triggerTop: 48,
|
||||
rootSearchTop: 48,
|
||||
contentPaddingTop: 4
|
||||
})
|
||||
expect(offset).toBe(-4)
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeSubmenuMaxHeight', () => {
|
||||
it('grows to the space below when there is ample room', () => {
|
||||
const height = computeSubmenuMaxHeight({
|
||||
submenuTop: 100,
|
||||
contextMenuHeight: 420,
|
||||
viewportHeight: 1000,
|
||||
margin: 8
|
||||
})
|
||||
expect(height).toBe(892)
|
||||
})
|
||||
|
||||
it('floors at the context menu height when room below is smaller', () => {
|
||||
const height = computeSubmenuMaxHeight({
|
||||
submenuTop: 600,
|
||||
contextMenuHeight: 420,
|
||||
viewportHeight: 1000,
|
||||
margin: 8
|
||||
})
|
||||
expect(height).toBe(420)
|
||||
})
|
||||
})
|
||||
|
||||
describe('estimateLinkReleaseMenuHeight', () => {
|
||||
it('estimates a typical default layout with header, suggestions, categories and reroute', () => {
|
||||
const height = estimateLinkReleaseMenuHeight({
|
||||
hasHeader: true,
|
||||
suggestionCount: 4,
|
||||
categoryCount: 3,
|
||||
searchResultCount: 0,
|
||||
showReroute: true
|
||||
})
|
||||
expect(height).toBe(468)
|
||||
})
|
||||
|
||||
it('estimates search results instead of the default sections', () => {
|
||||
const height = estimateLinkReleaseMenuHeight({
|
||||
hasHeader: true,
|
||||
suggestionCount: 4,
|
||||
categoryCount: 3,
|
||||
searchResultCount: 5,
|
||||
searchResultGroupCount: 2,
|
||||
showReroute: false
|
||||
})
|
||||
expect(height).toBe(280)
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeContextMenuTop', () => {
|
||||
const base = {
|
||||
menuHeight: 468,
|
||||
viewportHeight: 1000,
|
||||
margin: 8,
|
||||
sideOffset: 4
|
||||
}
|
||||
|
||||
it('bottom-anchors when the cursor is near the viewport bottom', () => {
|
||||
const top = computeContextMenuTop({ ...base, cursorY: 900 })
|
||||
expect(top).toBe(524)
|
||||
})
|
||||
|
||||
it('opens at the cursor when there is room below', () => {
|
||||
const top = computeContextMenuTop({ ...base, cursorY: 100 })
|
||||
expect(top).toBe(104)
|
||||
})
|
||||
|
||||
it('pins to the top margin when the cursor is above the viewport', () => {
|
||||
const top = computeContextMenuTop({ ...base, cursorY: -10 })
|
||||
expect(top).toBe(8)
|
||||
})
|
||||
})
|
||||
264
src/components/searchbox/linkReleaseMenuModel.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { NodeSourceType } from '@/types/nodeSource'
|
||||
|
||||
export interface LinkReleaseContext {
|
||||
/** The data type of the slot the link was dragged from (e.g. "MODEL"). */
|
||||
dataType: string
|
||||
/** The name of the slot the link was dragged from (e.g. "model"). */
|
||||
slotName: string
|
||||
/**
|
||||
* Whether the released link originates from an output slot, meaning the new
|
||||
* node will be connected to via one of its inputs.
|
||||
*/
|
||||
isFromOutput: boolean
|
||||
}
|
||||
|
||||
type LinkReleaseCategoryKey = 'comfy' | 'extensions' | 'partner'
|
||||
|
||||
export interface LinkReleaseNodeCategory {
|
||||
key: LinkReleaseCategoryKey
|
||||
/** i18n key for the group heading. */
|
||||
labelKey: string
|
||||
/** Iconify class shown beside the group label. */
|
||||
icon: string
|
||||
/** Nodes in the group, sorted alphabetically by display name. */
|
||||
nodes: ComfyNodeDefImpl[]
|
||||
}
|
||||
|
||||
const CATEGORY_META: Record<
|
||||
LinkReleaseCategoryKey,
|
||||
{ labelKey: string; icon: string }
|
||||
> = {
|
||||
comfy: { labelKey: 'contextMenu.Comfy Nodes', icon: 'icon-[lucide--box]' },
|
||||
extensions: {
|
||||
labelKey: 'contextMenu.Extensions',
|
||||
icon: 'icon-[lucide--puzzle]'
|
||||
},
|
||||
partner: {
|
||||
labelKey: 'contextMenu.Partner Nodes',
|
||||
icon: 'icon-[lucide--handshake]'
|
||||
}
|
||||
}
|
||||
|
||||
const CATEGORY_ORDER: LinkReleaseCategoryKey[] = [
|
||||
'comfy',
|
||||
'extensions',
|
||||
'partner'
|
||||
]
|
||||
|
||||
export function getLinkReleaseHeaderLabel(context: LinkReleaseContext): string {
|
||||
const { slotName, dataType } = context
|
||||
if (slotName && dataType) return `${slotName} | ${dataType}`
|
||||
return slotName || dataType
|
||||
}
|
||||
|
||||
function classifyNode(node: ComfyNodeDefImpl): LinkReleaseCategoryKey {
|
||||
if (node.api_node || node.category?.startsWith('api node')) return 'partner'
|
||||
if (
|
||||
node.nodeSource.type === NodeSourceType.Core ||
|
||||
node.nodeSource.type === NodeSourceType.Essentials
|
||||
) {
|
||||
return 'comfy'
|
||||
}
|
||||
return 'extensions'
|
||||
}
|
||||
|
||||
function byDisplayName(a: ComfyNodeDefImpl, b: ComfyNodeDefImpl): number {
|
||||
return a.display_name.localeCompare(b.display_name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Group slot-compatible nodes into source buckets for the cascading menu.
|
||||
* Empty buckets are omitted and each bucket's nodes are sorted by display name.
|
||||
*/
|
||||
export function buildLinkReleaseNodeCategories(
|
||||
compatibleNodes: ComfyNodeDefImpl[]
|
||||
): LinkReleaseNodeCategory[] {
|
||||
const buckets: Record<LinkReleaseCategoryKey, ComfyNodeDefImpl[]> = {
|
||||
comfy: [],
|
||||
extensions: [],
|
||||
partner: []
|
||||
}
|
||||
|
||||
for (const node of compatibleNodes) {
|
||||
buckets[classifyNode(node)].push(node)
|
||||
}
|
||||
|
||||
return CATEGORY_ORDER.filter((key) => buckets[key].length > 0).map((key) => ({
|
||||
key,
|
||||
labelKey: CATEGORY_META[key].labelKey,
|
||||
icon: CATEGORY_META[key].icon,
|
||||
nodes: [...buckets[key]].sort(byDisplayName)
|
||||
}))
|
||||
}
|
||||
|
||||
/** Quick-add suggestions for the released slot, excluding the Reroute node. */
|
||||
export function getLinkReleaseSuggestions(
|
||||
defaultNodeDefs: ComfyNodeDefImpl[]
|
||||
): ComfyNodeDefImpl[] {
|
||||
return defaultNodeDefs.filter((nodeDef) => nodeDef.name !== 'Reroute')
|
||||
}
|
||||
|
||||
/** Case-insensitive filter of a node list by display name. */
|
||||
export function filterNodesByName(
|
||||
nodes: ComfyNodeDefImpl[],
|
||||
query: string
|
||||
): ComfyNodeDefImpl[] {
|
||||
const trimmed = query.trim().toLowerCase()
|
||||
if (!trimmed) return nodes
|
||||
return nodes.filter((nodeDef) =>
|
||||
nodeDef.display_name.toLowerCase().includes(trimmed)
|
||||
)
|
||||
}
|
||||
|
||||
/** A node surfaced by the root flat-value search, tagged with its category. */
|
||||
export interface LinkReleaseNodeMatch {
|
||||
category: LinkReleaseNodeCategory
|
||||
node: ComfyNodeDefImpl
|
||||
}
|
||||
|
||||
export interface LinkReleaseSearchResultGroup {
|
||||
category: LinkReleaseNodeCategory
|
||||
nodes: ComfyNodeDefImpl[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Group matching nodes by category for the root flat-value search. Empty
|
||||
* categories are omitted; category order and per-category display-name order
|
||||
* are preserved.
|
||||
*/
|
||||
export function groupLinkReleaseSearchResults(
|
||||
categories: LinkReleaseNodeCategory[],
|
||||
query: string
|
||||
): LinkReleaseSearchResultGroup[] {
|
||||
const trimmed = query.trim().toLowerCase()
|
||||
if (!trimmed) return []
|
||||
return categories
|
||||
.map((category) => ({
|
||||
category,
|
||||
nodes: category.nodes.filter((node) =>
|
||||
node.display_name.toLowerCase().includes(trimmed)
|
||||
)
|
||||
}))
|
||||
.filter((group) => group.nodes.length > 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Flat-value search across every category submenu: when the root search has
|
||||
* text we surface matching nodes inline (tagged with their category) so a node
|
||||
* can be picked straight from the root without first drilling into a submenu.
|
||||
* Results preserve category order, then per-category display-name order.
|
||||
*/
|
||||
export function searchLinkReleaseNodes(
|
||||
categories: LinkReleaseNodeCategory[],
|
||||
query: string
|
||||
): LinkReleaseNodeMatch[] {
|
||||
const matches: LinkReleaseNodeMatch[] = []
|
||||
for (const group of groupLinkReleaseSearchResults(categories, query)) {
|
||||
for (const node of group.nodes) {
|
||||
matches.push({ category: group.category, node })
|
||||
}
|
||||
}
|
||||
return matches
|
||||
}
|
||||
|
||||
/**
|
||||
* Vertical `alignOffset` (px) that makes a category submenu open level with the
|
||||
* root menu rather than with the hovered trigger row. Positioning the submenu's
|
||||
* top one content-padding above the root search field lines the submenu's own
|
||||
* search field up with the root search field, since both menus share the same
|
||||
* content padding and search-field markup.
|
||||
*/
|
||||
export function computeSubmenuAlignOffset(metrics: {
|
||||
triggerTop: number
|
||||
rootSearchTop: number
|
||||
contentPaddingTop: number
|
||||
}): number {
|
||||
const { triggerTop, rootSearchTop, contentPaddingTop } = metrics
|
||||
return rootSearchTop - contentPaddingTop - triggerTop
|
||||
}
|
||||
|
||||
/**
|
||||
* Max height (px) for a category submenu pinned level with the root menu. The
|
||||
* panel grows into the viewport space below its top, but never shrinks below
|
||||
* the root menu's height so it can always be at least as tall as the context
|
||||
* menu even when there is little room beneath it.
|
||||
*/
|
||||
export function computeSubmenuMaxHeight(metrics: {
|
||||
submenuTop: number
|
||||
contextMenuHeight: number
|
||||
viewportHeight: number
|
||||
margin: number
|
||||
}): number {
|
||||
const { submenuTop, contextMenuHeight, viewportHeight, margin } = metrics
|
||||
return Math.max(contextMenuHeight, viewportHeight - submenuTop - margin)
|
||||
}
|
||||
|
||||
const CONTENT_PADDING_Y = 8
|
||||
const HEADER_HEIGHT = 36
|
||||
const SEARCH_HEIGHT = 40
|
||||
const SEPARATOR_HEIGHT = 8
|
||||
const SECTION_LABEL_HEIGHT = 36
|
||||
const MENU_ITEM_HEIGHT = 36
|
||||
|
||||
/**
|
||||
* Rough pixel height of the link-release context menu from its Tailwind layout.
|
||||
* Used once on open to bottom-anchor the panel without relying on Reka's 80vh
|
||||
* collision sizing.
|
||||
*/
|
||||
export function estimateLinkReleaseMenuHeight(layout: {
|
||||
hasHeader: boolean
|
||||
suggestionCount: number
|
||||
categoryCount: number
|
||||
searchResultCount: number
|
||||
searchResultGroupCount?: number
|
||||
showReroute: boolean
|
||||
}): number {
|
||||
const {
|
||||
hasHeader,
|
||||
suggestionCount,
|
||||
categoryCount,
|
||||
searchResultCount,
|
||||
searchResultGroupCount = 0,
|
||||
showReroute
|
||||
} = layout
|
||||
|
||||
let height = CONTENT_PADDING_Y + SEARCH_HEIGHT + SEPARATOR_HEIGHT
|
||||
if (hasHeader) height += HEADER_HEIGHT
|
||||
|
||||
if (searchResultCount > 0) {
|
||||
height += searchResultCount * MENU_ITEM_HEIGHT
|
||||
if (searchResultGroupCount > 1) {
|
||||
height += (searchResultGroupCount - 1) * SEPARATOR_HEIGHT
|
||||
}
|
||||
return height
|
||||
}
|
||||
|
||||
if (suggestionCount > 0) {
|
||||
height += SECTION_LABEL_HEIGHT + suggestionCount * MENU_ITEM_HEIGHT
|
||||
}
|
||||
if (suggestionCount > 0 && categoryCount > 0) {
|
||||
height += SEPARATOR_HEIGHT
|
||||
}
|
||||
if (categoryCount > 0) {
|
||||
height += SECTION_LABEL_HEIGHT + categoryCount * MENU_ITEM_HEIGHT
|
||||
}
|
||||
if (showReroute) {
|
||||
height += SEPARATOR_HEIGHT + MENU_ITEM_HEIGHT
|
||||
}
|
||||
return height
|
||||
}
|
||||
|
||||
/** Bottom-anchor the context menu top edge within the viewport. */
|
||||
export function computeContextMenuTop(metrics: {
|
||||
cursorY: number
|
||||
menuHeight: number
|
||||
viewportHeight: number
|
||||
margin: number
|
||||
sideOffset: number
|
||||
}): number {
|
||||
const { cursorY, menuHeight, viewportHeight, margin, sideOffset } = metrics
|
||||
const menuTopAtCursor = cursorY + sideOffset
|
||||
const maxMenuTop = Math.max(margin, viewportHeight - margin - menuHeight)
|
||||
return Math.min(Math.max(margin, menuTopAtCursor), maxMenuTop)
|
||||
}
|
||||
@@ -142,8 +142,9 @@ const sourceCategoryFilters: Record<string, (n: ComfyNodeDefImpl) => boolean> =
|
||||
[RootCategory.Custom]: isCustomNode
|
||||
}
|
||||
|
||||
const { filters } = defineProps<{
|
||||
const { filters, defaultRootFilter = null } = defineProps<{
|
||||
filters: FuseFilterWithValue<ComfyNodeDefImpl, string>[]
|
||||
defaultRootFilter?: RootCategoryId | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -195,7 +196,7 @@ function onSearchFocus() {
|
||||
}
|
||||
|
||||
// Root filter from filter bar category buttons (radio toggle)
|
||||
const rootFilter = ref<RootCategoryId | null>(null)
|
||||
const rootFilter = ref<RootCategoryId | null>(defaultRootFilter)
|
||||
|
||||
const rootFilterLabel = computed(() => {
|
||||
switch (rootFilter.value) {
|
||||
|
||||
@@ -1,8 +1,28 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { getWorkflowMode, isAppModeValue } from '@/utils/appMode'
|
||||
import type { AppMode } from '@/utils/appMode'
|
||||
|
||||
export type AppMode =
|
||||
| 'graph'
|
||||
| 'app'
|
||||
| 'builder:inputs'
|
||||
| 'builder:outputs'
|
||||
| 'builder:arrange'
|
||||
|
||||
type WorkflowModeSource = {
|
||||
activeMode: AppMode | null
|
||||
initialMode: AppMode | null | undefined
|
||||
}
|
||||
|
||||
export function getWorkflowMode(
|
||||
workflow: WorkflowModeSource | null | undefined
|
||||
): AppMode {
|
||||
return workflow?.activeMode ?? workflow?.initialMode ?? 'graph'
|
||||
}
|
||||
|
||||
export function isAppModeValue(mode: AppMode): boolean {
|
||||
return mode === 'app' || mode === 'builder:arrange'
|
||||
}
|
||||
|
||||
const enableAppBuilder = ref(true)
|
||||
|
||||
|
||||
@@ -1,105 +1,94 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useCopy } from './useCopy'
|
||||
|
||||
/**
|
||||
* Encodes a UTF-8 string to base64 (same logic as useCopy.ts)
|
||||
*/
|
||||
function encodeClipboardData(data: string): string {
|
||||
return btoa(
|
||||
String.fromCharCode(...Array.from(new TextEncoder().encode(data)))
|
||||
const copyMocks = vi.hoisted(() => ({
|
||||
copyHandler: undefined as ((event: ClipboardEvent) => unknown) | undefined,
|
||||
canvas: {
|
||||
selectedItems: new Set<object>([{}]),
|
||||
copyToClipboard: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useEventListener: vi.fn(
|
||||
(
|
||||
_target: EventTarget,
|
||||
event: string,
|
||||
handler: (event: ClipboardEvent) => unknown
|
||||
) => {
|
||||
if (event === 'copy') copyMocks.copyHandler = handler
|
||||
return vi.fn()
|
||||
}
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
canvas: copyMocks.canvas
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/workbench/eventHelpers', () => ({
|
||||
shouldIgnoreCopyPaste: vi.fn(() => false)
|
||||
}))
|
||||
|
||||
const multiChunkPayloadLength = 0x8000 * 6 + 123
|
||||
|
||||
function copySerializedData(serializedData: string): DataTransfer {
|
||||
copyMocks.canvas.copyToClipboard.mockReturnValue(serializedData)
|
||||
|
||||
useCopy()
|
||||
|
||||
const dataTransfer = new DataTransfer()
|
||||
const event = new ClipboardEvent('copy', {
|
||||
clipboardData: dataTransfer
|
||||
})
|
||||
const copyHandler = copyMocks.copyHandler
|
||||
expect(copyHandler).toBeDefined()
|
||||
if (!copyHandler) throw new Error('Expected copy handler to be registered')
|
||||
|
||||
expect(() => copyHandler(event)).not.toThrow()
|
||||
|
||||
return dataTransfer
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes base64 to UTF-8 string (same logic as usePaste.ts)
|
||||
*/
|
||||
function decodeClipboardData(base64: string): string {
|
||||
const binaryString = atob(base64)
|
||||
const bytes = Uint8Array.from(binaryString, (c) => c.charCodeAt(0))
|
||||
function readSerializedClipboardMetadata(dataTransfer: DataTransfer): string {
|
||||
const match = dataTransfer
|
||||
.getData('text/html')
|
||||
.match(/data-metadata="([A-Za-z0-9+/=]+)"/)?.[1]
|
||||
expect(match).toBeDefined()
|
||||
if (!match) throw new Error('Expected clipboard metadata to be written')
|
||||
|
||||
const binaryString = atob(match)
|
||||
const bytes = Uint8Array.from(binaryString, (char) => char.charCodeAt(0))
|
||||
return new TextDecoder().decode(bytes)
|
||||
}
|
||||
|
||||
describe('Clipboard UTF-8 base64 encoding/decoding', () => {
|
||||
it('should handle ASCII-only strings', () => {
|
||||
const original = '{"nodes":[{"id":1,"type":"LoadImage"}]}'
|
||||
const encoded = encodeClipboardData(original)
|
||||
const decoded = decodeClipboardData(encoded)
|
||||
expect(decoded).toBe(original)
|
||||
describe('useCopy', () => {
|
||||
beforeEach(() => {
|
||||
copyMocks.copyHandler = undefined
|
||||
copyMocks.canvas.copyToClipboard.mockReset()
|
||||
})
|
||||
|
||||
it('should handle Chinese characters in localized_name', () => {
|
||||
const original =
|
||||
'{"nodes":[{"id":1,"type":"LoadImage","localized_name":"图像"}]}'
|
||||
const encoded = encodeClipboardData(original)
|
||||
const decoded = decodeClipboardData(encoded)
|
||||
expect(decoded).toBe(original)
|
||||
})
|
||||
|
||||
it('should handle Japanese characters', () => {
|
||||
const original = '{"localized_name":"画像を読み込む"}'
|
||||
const encoded = encodeClipboardData(original)
|
||||
const decoded = decodeClipboardData(encoded)
|
||||
expect(decoded).toBe(original)
|
||||
})
|
||||
|
||||
it('should handle Korean characters', () => {
|
||||
const original = '{"localized_name":"이미지 불러오기"}'
|
||||
const encoded = encodeClipboardData(original)
|
||||
const decoded = decodeClipboardData(encoded)
|
||||
expect(decoded).toBe(original)
|
||||
})
|
||||
|
||||
it('should handle mixed ASCII and Unicode characters', () => {
|
||||
const original =
|
||||
'{"nodes":[{"id":1,"type":"LoadImage","localized_name":"加载图像","label":"Load Image 图片"}]}'
|
||||
const encoded = encodeClipboardData(original)
|
||||
const decoded = decodeClipboardData(encoded)
|
||||
expect(decoded).toBe(original)
|
||||
})
|
||||
|
||||
it('should handle emoji characters', () => {
|
||||
const original = '{"title":"Test Node 🎨🖼️"}'
|
||||
const encoded = encodeClipboardData(original)
|
||||
const decoded = decodeClipboardData(encoded)
|
||||
expect(decoded).toBe(original)
|
||||
})
|
||||
|
||||
it('should handle empty string', () => {
|
||||
const original = ''
|
||||
const encoded = encodeClipboardData(original)
|
||||
const decoded = decodeClipboardData(encoded)
|
||||
expect(decoded).toBe(original)
|
||||
})
|
||||
|
||||
it('should handle complex node data with multiple Unicode fields', () => {
|
||||
const original = JSON.stringify({
|
||||
it('should write large serialized node data to clipboard metadata', () => {
|
||||
const serializedData = JSON.stringify({
|
||||
nodes: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'LoadImage',
|
||||
localized_name: '图像',
|
||||
inputs: [{ localized_name: '图片', name: 'image' }],
|
||||
outputs: [{ localized_name: '输出', name: 'output' }]
|
||||
type: 'Subgraph',
|
||||
title: 'Large Subgraph',
|
||||
localized_name: '이미지 그룹 图像 🎨',
|
||||
payload: 'x'.repeat(multiChunkPayloadLength)
|
||||
}
|
||||
],
|
||||
groups: [{ title: '预处理组 🔧' }],
|
||||
links: []
|
||||
reroutes: [],
|
||||
links: [],
|
||||
subgraphs: []
|
||||
})
|
||||
const encoded = encodeClipboardData(original)
|
||||
const decoded = decodeClipboardData(encoded)
|
||||
expect(decoded).toBe(original)
|
||||
expect(JSON.parse(decoded)).toEqual(JSON.parse(original))
|
||||
})
|
||||
|
||||
it('should produce valid base64 output', () => {
|
||||
const original = '{"localized_name":"中文测试"}'
|
||||
const encoded = encodeClipboardData(original)
|
||||
// Base64 should only contain valid characters
|
||||
expect(encoded).toMatch(/^[A-Za-z0-9+/=]+$/)
|
||||
})
|
||||
const dataTransfer = copySerializedData(serializedData)
|
||||
|
||||
it('should fail with plain btoa for non-Latin1 characters', () => {
|
||||
const original = '{"localized_name":"图像"}'
|
||||
// This demonstrates why we need TextEncoder - plain btoa fails
|
||||
expect(() => btoa(original)).toThrow()
|
||||
expect(readSerializedClipboardMetadata(dataTransfer)).toBe(serializedData)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,6 +7,29 @@ const clipboardHTMLWrapper = [
|
||||
'<meta charset="utf-8"><div><span data-metadata="',
|
||||
'"></span></div><span style="white-space:pre-wrap;">Text</span>'
|
||||
]
|
||||
const clipboardByteChunkSize = 0x8000
|
||||
|
||||
function bytesToBinaryString(bytes: Uint8Array): string {
|
||||
const chunks: string[] = []
|
||||
|
||||
for (
|
||||
let offset = 0;
|
||||
offset < bytes.length;
|
||||
offset += clipboardByteChunkSize
|
||||
) {
|
||||
chunks.push(
|
||||
String.fromCharCode(
|
||||
...bytes.subarray(offset, offset + clipboardByteChunkSize)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return chunks.join('')
|
||||
}
|
||||
|
||||
function encodeClipboardData(data: string): string {
|
||||
return btoa(bytesToBinaryString(new TextEncoder().encode(data)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a handler on copy that serializes selected nodes to JSON
|
||||
@@ -23,17 +46,16 @@ export const useCopy = () => {
|
||||
const canvas = canvasStore.canvas
|
||||
if (canvas?.selectedItems) {
|
||||
const serializedData = canvas.copyToClipboard()
|
||||
// Use TextEncoder to handle Unicode characters properly
|
||||
const base64Data = btoa(
|
||||
String.fromCharCode(
|
||||
...Array.from(new TextEncoder().encode(serializedData))
|
||||
try {
|
||||
const base64Data = encodeClipboardData(serializedData)
|
||||
// clearData doesn't remove images from clipboard
|
||||
e.clipboardData?.setData(
|
||||
'text/html',
|
||||
clipboardHTMLWrapper.join(base64Data)
|
||||
)
|
||||
)
|
||||
// clearData doesn't remove images from clipboard
|
||||
e.clipboardData?.setData(
|
||||
'text/html',
|
||||
clipboardHTMLWrapper.join(base64Data)
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
e.preventDefault()
|
||||
e.stopImmediatePropagation()
|
||||
return false
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteG
|
||||
import { useSubgraphOperations } from '@/composables/graph/useSubgraphOperations'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useModelSelectorDialog } from '@/composables/useModelSelectorDialog'
|
||||
import { useRunButtonTelemetry } from '@/composables/useRunButtonTelemetry'
|
||||
import {
|
||||
DEFAULT_DARK_COLOR_PALETTE,
|
||||
DEFAULT_LIGHT_COLOR_PALETTE
|
||||
@@ -86,7 +85,6 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
const executionStore = useExecutionStore()
|
||||
const modelStore = useModelStore()
|
||||
const telemetry = useTelemetry()
|
||||
const { trackRunButton } = useRunButtonTelemetry()
|
||||
const { staticUrls, buildDocsUrl } = useExternalLink()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
@@ -501,7 +499,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
subscribe_to_run?: boolean
|
||||
trigger_source?: ExecutionTriggerSource
|
||||
}) => {
|
||||
trackRunButton(metadata)
|
||||
useTelemetry()?.trackRunButton(metadata)
|
||||
if (!isActiveSubscription.value) {
|
||||
showSubscriptionDialog()
|
||||
return
|
||||
@@ -524,7 +522,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
subscribe_to_run?: boolean
|
||||
trigger_source?: ExecutionTriggerSource
|
||||
}) => {
|
||||
trackRunButton(metadata)
|
||||
useTelemetry()?.trackRunButton(metadata)
|
||||
if (!isActiveSubscription.value) {
|
||||
showSubscriptionDialog()
|
||||
return
|
||||
@@ -546,7 +544,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
subscribe_to_run?: boolean
|
||||
trigger_source?: ExecutionTriggerSource
|
||||
}) => {
|
||||
trackRunButton(metadata)
|
||||
useTelemetry()?.trackRunButton(metadata)
|
||||
if (!isActiveSubscription.value) {
|
||||
showSubscriptionDialog()
|
||||
return
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
mode: { value: 'graph' },
|
||||
isAppMode: { value: false },
|
||||
telemetry: {
|
||||
trackRunButton: vi.fn()
|
||||
},
|
||||
executionContext: {
|
||||
is_template: false,
|
||||
workflow_name: 'Desktop workflow',
|
||||
custom_node_count: 2,
|
||||
total_node_count: 4,
|
||||
subgraph_count: 1,
|
||||
has_api_nodes: true,
|
||||
api_node_names: ['LoadImage'],
|
||||
has_toolkit_nodes: false,
|
||||
toolkit_node_names: []
|
||||
},
|
||||
executionContextError: null as Error | null
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useAppMode', () => ({
|
||||
useAppMode: () => ({
|
||||
mode: state.mode,
|
||||
isAppMode: state.isAppMode
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => state.telemetry
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry/utils/getExecutionContext', () => ({
|
||||
getExecutionContext: () => {
|
||||
if (state.executionContextError) throw state.executionContextError
|
||||
return state.executionContext
|
||||
}
|
||||
}))
|
||||
|
||||
import {
|
||||
getRunButtonTelemetryProperties,
|
||||
useRunButtonTelemetry
|
||||
} from './useRunButtonTelemetry'
|
||||
|
||||
describe('useRunButtonTelemetry', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
state.telemetry.trackRunButton.mockClear()
|
||||
state.mode.value = 'graph'
|
||||
state.isAppMode.value = false
|
||||
state.executionContextError = null
|
||||
})
|
||||
|
||||
it('builds run button properties from workspace state', () => {
|
||||
localStorage.setItem('Comfy.MenuPosition.Docked', 'false')
|
||||
|
||||
expect(
|
||||
getRunButtonTelemetryProperties({
|
||||
subscribe_to_run: true,
|
||||
trigger_source: 'button'
|
||||
})
|
||||
).toEqual({
|
||||
subscribe_to_run: true,
|
||||
workflow_type: 'custom',
|
||||
workflow_name: 'Desktop workflow',
|
||||
custom_node_count: 2,
|
||||
total_node_count: 4,
|
||||
subgraph_count: 1,
|
||||
has_api_nodes: true,
|
||||
api_node_names: ['LoadImage'],
|
||||
has_toolkit_nodes: false,
|
||||
toolkit_node_names: [],
|
||||
trigger_source: 'button',
|
||||
view_mode: 'graph',
|
||||
is_app_mode: false,
|
||||
dock_state: 'floating'
|
||||
})
|
||||
})
|
||||
|
||||
it('tracks the completed run button payload', () => {
|
||||
useRunButtonTelemetry().trackRunButton({ trigger_source: 'linear' })
|
||||
|
||||
expect(state.telemetry.trackRunButton).toHaveBeenCalledExactlyOnceWith(
|
||||
expect.objectContaining({
|
||||
subscribe_to_run: false,
|
||||
trigger_source: 'linear',
|
||||
workflow_name: 'Desktop workflow'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('does not throw when run button context collection fails', () => {
|
||||
const error = new Error('Context unavailable')
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
state.executionContextError = error
|
||||
|
||||
try {
|
||||
expect(() =>
|
||||
useRunButtonTelemetry().trackRunButton({ trigger_source: 'linear' })
|
||||
).not.toThrow()
|
||||
|
||||
expect(state.telemetry.trackRunButton).not.toHaveBeenCalled()
|
||||
expect(consoleError).toHaveBeenCalledExactlyOnceWith(
|
||||
'[Telemetry] Run button tracking failed',
|
||||
error
|
||||
)
|
||||
} finally {
|
||||
consoleError.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,52 +0,0 @@
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type {
|
||||
ExecutionTriggerSource,
|
||||
RunButtonProperties
|
||||
} from '@/platform/telemetry/types'
|
||||
import { getActionbarDockState } from '@/platform/telemetry/utils/getActionbarDockState'
|
||||
import { getExecutionContext } from '@/platform/telemetry/utils/getExecutionContext'
|
||||
|
||||
export type RunButtonTelemetryOptions = {
|
||||
subscribe_to_run?: boolean
|
||||
trigger_source?: ExecutionTriggerSource
|
||||
}
|
||||
|
||||
export function getRunButtonTelemetryProperties(
|
||||
options?: RunButtonTelemetryOptions
|
||||
): RunButtonProperties {
|
||||
const executionContext = getExecutionContext()
|
||||
const { mode, isAppMode } = useAppMode()
|
||||
|
||||
return {
|
||||
subscribe_to_run: options?.subscribe_to_run ?? false,
|
||||
workflow_type: executionContext.is_template ? 'template' : 'custom',
|
||||
workflow_name: executionContext.workflow_name ?? 'untitled',
|
||||
custom_node_count: executionContext.custom_node_count,
|
||||
total_node_count: executionContext.total_node_count,
|
||||
subgraph_count: executionContext.subgraph_count,
|
||||
has_api_nodes: executionContext.has_api_nodes,
|
||||
api_node_names: executionContext.api_node_names,
|
||||
has_toolkit_nodes: executionContext.has_toolkit_nodes,
|
||||
toolkit_node_names: executionContext.toolkit_node_names,
|
||||
trigger_source: options?.trigger_source,
|
||||
view_mode: mode.value,
|
||||
is_app_mode: isAppMode.value,
|
||||
dock_state: getActionbarDockState()
|
||||
}
|
||||
}
|
||||
|
||||
export function useRunButtonTelemetry() {
|
||||
function trackRunButton(options?: RunButtonTelemetryOptions): void {
|
||||
const telemetry = useTelemetry()
|
||||
if (!telemetry) return
|
||||
|
||||
try {
|
||||
telemetry.trackRunButton(getRunButtonTelemetryProperties(options))
|
||||
} catch (error) {
|
||||
console.error('[Telemetry] Run button tracking failed', error)
|
||||
}
|
||||
}
|
||||
|
||||
return { trackRunButton }
|
||||
}
|
||||
@@ -119,6 +119,23 @@ describe('load3dLazy', () => {
|
||||
expect(spec.upload_subfolder).toBe('3d')
|
||||
})
|
||||
|
||||
it('injects mesh_upload spec flags into the model_file widget for Load3DAdvanced nodes', async () => {
|
||||
const { hook } = await loadLazyExtensionFresh()
|
||||
const nodeData = makeNodeDef('Load3DAdvanced', {
|
||||
input: {
|
||||
required: { model_file: ['STRING', {}] }
|
||||
}
|
||||
} as Partial<ComfyNodeDef>)
|
||||
|
||||
await hook({} as typeof LGraphNode, nodeData)
|
||||
|
||||
const spec = (
|
||||
nodeData.input!.required!.model_file as [string, Record<string, unknown>]
|
||||
)[1]
|
||||
expect(spec.mesh_upload).toBe(true)
|
||||
expect(spec.upload_subfolder).toBe('3d')
|
||||
})
|
||||
|
||||
it('does not throw when a Load3D node has no model_file widget spec', async () => {
|
||||
const { hook } = await loadLazyExtensionFresh()
|
||||
const nodeData = makeNodeDef('Load3D', {
|
||||
|
||||
@@ -61,18 +61,12 @@ useExtensionService().registerExtension({
|
||||
if (isLoad3dNode(nodeData.name)) {
|
||||
// Inject mesh_upload spec flags so WidgetSelect.vue can detect
|
||||
// Load3D's model_file as a mesh upload widget without hardcoding.
|
||||
if (nodeData.name === 'Load3D') {
|
||||
if (nodeData.name === 'Load3D' || nodeData.name === 'Load3DAdvanced') {
|
||||
const modelFile = nodeData.input?.required?.model_file
|
||||
if (modelFile?.[1]) {
|
||||
modelFile[1].mesh_upload = true
|
||||
modelFile[1].upload_subfolder = '3d'
|
||||
}
|
||||
} else if (nodeData.name === 'Load3DAdvanced') {
|
||||
const modelFile = nodeData.input?.required?.model_file
|
||||
if (modelFile?.[1]) {
|
||||
modelFile[1].mesh_upload = true
|
||||
modelFile[1].upload_subfolder = ''
|
||||
}
|
||||
}
|
||||
|
||||
// Load the 3D extensions and replay their beforeRegisterNodeDef hooks,
|
||||
|
||||
@@ -5194,7 +5194,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
|
||||
private _drawConnectingLinks(ctx: CanvasRenderingContext2D): void {
|
||||
const { linkConnector } = this
|
||||
if (!linkConnector.isConnecting) return
|
||||
if (!linkConnector.isConnecting || linkConnector.renderLinksHidden) return
|
||||
|
||||
const { renderLinks } = linkConnector
|
||||
const highlightPos = this._getHighlightPosition()
|
||||
|
||||
@@ -118,6 +118,13 @@ export class LinkConnector {
|
||||
/** The reroute beneath the pointer, if it is a valid connection target. */
|
||||
overReroute?: Reroute
|
||||
|
||||
/**
|
||||
* When `true`, the in-progress dragging links are not rendered even though a
|
||||
* connection is still active. Used to hide the dangling link while a
|
||||
* link-release menu holds the connection open.
|
||||
*/
|
||||
renderLinksHidden = false
|
||||
|
||||
private readonly _setConnectingLinks: (value: ConnectingLink[]) => void
|
||||
|
||||
constructor(setConnectingLinks: (value: ConnectingLink[]) => void) {
|
||||
@@ -1098,6 +1105,8 @@ export class LinkConnector {
|
||||
const mayContinue = this.events.dispatch('reset', force)
|
||||
if (mayContinue === false) return
|
||||
|
||||
this.renderLinksHidden = false
|
||||
|
||||
const {
|
||||
state,
|
||||
outputLinks,
|
||||
|
||||
@@ -170,6 +170,7 @@ export type { TWidgetType, TWidgetValue, IWidgetOptions } from './types/widgets'
|
||||
export {
|
||||
findUsedSubgraphIds,
|
||||
getDirectSubgraphIds,
|
||||
isNodeSlot,
|
||||
isSubgraphInput,
|
||||
isSubgraphOutput
|
||||
} from './subgraph/subgraphUtils'
|
||||
|
||||
@@ -593,6 +593,12 @@
|
||||
"Bypass": "Bypass",
|
||||
"Copy (Clipspace)": "Copy (Clipspace)",
|
||||
"Add Node": "Add Node",
|
||||
"Add Reroute": "Add Reroute",
|
||||
"Most Relevant": "Most Relevant",
|
||||
"Comfy Nodes": "Comfy Nodes",
|
||||
"Extensions": "Extensions",
|
||||
"Partner Nodes": "Partner Nodes",
|
||||
"Compatible Nodes": "Compatible Nodes",
|
||||
"Add Group": "Add Group",
|
||||
"Manage Group Nodes": "Manage Group Nodes",
|
||||
"Add Group For Selected Nodes": "Add Group For Selected Nodes",
|
||||
@@ -634,8 +640,7 @@
|
||||
"Horizontal": "Horizontal",
|
||||
"Vertical": "Vertical",
|
||||
"new": "new",
|
||||
"deprecated": "deprecated",
|
||||
"Extensions": "Extensions"
|
||||
"deprecated": "deprecated"
|
||||
},
|
||||
"icon": {
|
||||
"bookmark": "Bookmark",
|
||||
@@ -3627,6 +3632,10 @@
|
||||
"hideAdvancedShort": "Hide advanced",
|
||||
"errors": "Errors",
|
||||
"noErrors": "No errors",
|
||||
"errorsDetected": "Error detected | Errors detected",
|
||||
"resolveBeforeRun": "Resolve before running the workflow",
|
||||
"expand": "Expand",
|
||||
"collapse": "Collapse",
|
||||
"executionErrorOccurred": "An error occurred during execution. Check the Errors tab for details.",
|
||||
"errorLog": "Error log",
|
||||
"findOnGithubTooltip": "Search GitHub issues for related problems",
|
||||
|
||||
@@ -143,7 +143,7 @@ const { t } = useI18n()
|
||||
<div class="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span class="flex shrink-0 items-baseline gap-1.5 whitespace-nowrap">
|
||||
<span
|
||||
class="text-[2rem] leading-none font-semibold text-base-foreground"
|
||||
class="text-[2rem]/none font-semibold text-base-foreground"
|
||||
data-testid="credit-slider-price"
|
||||
>
|
||||
{{ formatUsd(displayMonthly) }}
|
||||
|
||||
@@ -22,8 +22,8 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useRunButtonTelemetry } from '@/composables/useRunButtonTelemetry'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
|
||||
const { t } = useI18n()
|
||||
const breakpoints = useBreakpoints(breakpointsTailwind)
|
||||
@@ -36,11 +36,10 @@ const buttonLabel = computed(() =>
|
||||
)
|
||||
|
||||
const { showSubscriptionDialog } = useBillingContext()
|
||||
const { trackRunButton } = useRunButtonTelemetry()
|
||||
|
||||
const handleSubscribeToRun = () => {
|
||||
if (isCloud) {
|
||||
trackRunButton({ subscribe_to_run: true })
|
||||
useTelemetry()?.trackRunButton({ subscribe_to_run: true })
|
||||
}
|
||||
|
||||
showSubscriptionDialog()
|
||||
|
||||
@@ -1394,7 +1394,7 @@ describe('errorMessageResolver', () => {
|
||||
})
|
||||
).toEqual({
|
||||
catalogId: 'missing_node',
|
||||
displayTitle: 'Missing Node Packs (1)',
|
||||
displayTitle: 'Missing Node Packs',
|
||||
displayMessage: 'Install missing packs to use this workflow.',
|
||||
toastTitle: 'Missing node: FooNode',
|
||||
toastMessage:
|
||||
@@ -1410,7 +1410,7 @@ describe('errorMessageResolver', () => {
|
||||
})
|
||||
).toEqual({
|
||||
catalogId: 'missing_node',
|
||||
displayTitle: 'Unsupported Node Packs (1)',
|
||||
displayTitle: 'Unsupported Node Packs',
|
||||
displayMessage:
|
||||
"Required custom nodes aren't supported on Cloud. Replace them with supported nodes.",
|
||||
toastTitle: "FooNode isn't available on Cloud",
|
||||
@@ -1471,7 +1471,7 @@ describe('errorMessageResolver', () => {
|
||||
})
|
||||
).toEqual({
|
||||
catalogId: 'swap_nodes',
|
||||
displayTitle: 'Swap Nodes (1)',
|
||||
displayTitle: 'Swap Nodes',
|
||||
displayMessage: 'Some nodes can be replaced with alternatives',
|
||||
toastTitle: 'OldNode can be replaced',
|
||||
toastMessage: 'Replace it with NewNode from the error panel.'
|
||||
@@ -1520,7 +1520,7 @@ describe('errorMessageResolver', () => {
|
||||
})
|
||||
).toEqual({
|
||||
catalogId: 'missing_model',
|
||||
displayTitle: 'Missing Models (1)',
|
||||
displayTitle: 'Missing Models',
|
||||
displayMessage: 'Download a model, or open the node to replace it.',
|
||||
toastTitle: 'sdxl.safetensors is missing',
|
||||
toastMessage: 'Checkpoint Loader Simple is missing a required model file.'
|
||||
@@ -1535,7 +1535,7 @@ describe('errorMessageResolver', () => {
|
||||
})
|
||||
).toEqual({
|
||||
catalogId: 'missing_model',
|
||||
displayTitle: 'Missing Models (1)',
|
||||
displayTitle: 'Missing Models',
|
||||
displayMessage: 'Import a model, or open the node to replace it.',
|
||||
toastTitle: "sdxl.safetensors isn't available on Cloud",
|
||||
toastMessage: "This model isn't supported. Choose a different one."
|
||||
@@ -1573,7 +1573,7 @@ describe('errorMessageResolver', () => {
|
||||
})
|
||||
).toEqual({
|
||||
catalogId: 'missing_media',
|
||||
displayTitle: 'Missing Inputs (1)',
|
||||
displayTitle: 'Missing Inputs',
|
||||
displayMessage: 'A required media input has no file selected.',
|
||||
toastTitle: 'Media input missing',
|
||||
toastMessage: 'Load Image is missing a required media file.'
|
||||
@@ -1707,7 +1707,7 @@ describe('errorMessageResolver', () => {
|
||||
isCloud: false
|
||||
})
|
||||
).toMatchObject({
|
||||
displayTitle: 'Missing Inputs (2)',
|
||||
displayTitle: 'Missing Inputs',
|
||||
toastTitle: 'Missing media inputs',
|
||||
toastMessage:
|
||||
'Please select the missing media inputs before running this workflow.'
|
||||
|
||||
@@ -6,10 +6,6 @@ import { normalizeNodeName, translateCatalogMessage } from './catalogI18n'
|
||||
import { countMissingMediaReferences } from '@/platform/missingMedia/missingMediaGrouping'
|
||||
import { st } from '@/i18n'
|
||||
|
||||
function formatCountTitle(title: string, count: number): string {
|
||||
return `${title} (${count})`
|
||||
}
|
||||
|
||||
function formatNodeTypeName(nodeType: string): string | null {
|
||||
const trimmed = nodeType.trim()
|
||||
if (!trimmed) return null
|
||||
@@ -344,15 +340,12 @@ export function resolveMissingErrorMessage(
|
||||
case 'missing_node':
|
||||
return {
|
||||
catalogId: 'missing_node',
|
||||
displayTitle: formatCountTitle(
|
||||
source.isCloud
|
||||
? st(
|
||||
'rightSidePanel.missingNodePacks.unsupportedTitle',
|
||||
'Unsupported Node Packs'
|
||||
)
|
||||
: st('rightSidePanel.missingNodePacks.title', 'Missing Node Packs'),
|
||||
source.count
|
||||
),
|
||||
displayTitle: source.isCloud
|
||||
? st(
|
||||
'rightSidePanel.missingNodePacks.unsupportedTitle',
|
||||
'Unsupported Node Packs'
|
||||
)
|
||||
: st('rightSidePanel.missingNodePacks.title', 'Missing Node Packs'),
|
||||
displayMessage: resolveMissingNodeDisplayMessage(source),
|
||||
toastTitle: resolveMissingNodeToastTitle(source),
|
||||
toastMessage: resolveMissingNodeToastMessage(source)
|
||||
@@ -360,10 +353,7 @@ export function resolveMissingErrorMessage(
|
||||
case 'swap_nodes':
|
||||
return {
|
||||
catalogId: 'swap_nodes',
|
||||
displayTitle: formatCountTitle(
|
||||
st('nodeReplacement.swapNodesTitle', 'Swap Nodes'),
|
||||
source.count
|
||||
),
|
||||
displayTitle: st('nodeReplacement.swapNodesTitle', 'Swap Nodes'),
|
||||
displayMessage: resolveSwapNodeDisplayMessage(),
|
||||
toastTitle: resolveSwapNodeToastTitle(source),
|
||||
toastMessage: resolveSwapNodeToastMessage(source)
|
||||
@@ -371,12 +361,9 @@ export function resolveMissingErrorMessage(
|
||||
case 'missing_model':
|
||||
return {
|
||||
catalogId: 'missing_model',
|
||||
displayTitle: formatCountTitle(
|
||||
st(
|
||||
'rightSidePanel.missingModels.missingModelsTitle',
|
||||
'Missing Models'
|
||||
),
|
||||
source.count
|
||||
displayTitle: st(
|
||||
'rightSidePanel.missingModels.missingModelsTitle',
|
||||
'Missing Models'
|
||||
),
|
||||
displayMessage: resolveMissingModelDisplayMessage(source),
|
||||
toastTitle: resolveMissingModelToastTitle(source),
|
||||
@@ -385,9 +372,9 @@ export function resolveMissingErrorMessage(
|
||||
case 'missing_media':
|
||||
return {
|
||||
catalogId: 'missing_media',
|
||||
displayTitle: formatCountTitle(
|
||||
st('rightSidePanel.missingMedia.missingMediaTitle', 'Missing Inputs'),
|
||||
source.count
|
||||
displayTitle: st(
|
||||
'rightSidePanel.missingMedia.missingMediaTitle',
|
||||
'Missing Inputs'
|
||||
),
|
||||
displayMessage: resolveMissingMediaDisplayMessage(),
|
||||
toastTitle: resolveMissingMediaToastTitle(source),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="px-4 pb-2">
|
||||
<div class="px-3">
|
||||
<TransitionGroup
|
||||
tag="ul"
|
||||
name="list-scale"
|
||||
@@ -15,7 +15,7 @@
|
||||
<span class="flex min-w-0 flex-1">
|
||||
<button
|
||||
type="button"
|
||||
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-1 focus-visible:outline-none"
|
||||
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-xs/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
|
||||
@click="emit('locateNode', item.nodeId)"
|
||||
>
|
||||
{{ item.displayItemLabel }}
|
||||
@@ -25,7 +25,7 @@
|
||||
data-testid="missing-media-locate-button"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
|
||||
:aria-label="
|
||||
t('rightSidePanel.locateNodeFor', {
|
||||
item: item.displayItemLabel
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<div class="px-4 pb-2">
|
||||
<div class="px-3">
|
||||
<div
|
||||
v-if="importableModelRows.length > 0"
|
||||
data-testid="missing-model-importable-rows"
|
||||
class="flex flex-col gap-1 overflow-hidden py-2"
|
||||
class="flex flex-col gap-1 overflow-hidden"
|
||||
>
|
||||
<MissingModelRow
|
||||
v-for="row in importableModelRows"
|
||||
@@ -19,7 +19,7 @@
|
||||
<div
|
||||
v-if="unsupportedModelRows.length > 0"
|
||||
data-testid="missing-model-import-not-supported-section"
|
||||
class="flex flex-col gap-1 border-t border-interface-stroke pt-3"
|
||||
class="flex flex-col gap-1 border-t border-secondary-background pt-3"
|
||||
>
|
||||
<div class="mb-1">
|
||||
<p class="m-0 text-sm font-semibold text-warning-background">
|
||||
@@ -49,7 +49,7 @@
|
||||
data-testid="missing-model-download-all"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="h-8 min-w-0 flex-1 rounded-lg text-sm"
|
||||
class="h-8 min-w-0 flex-1 rounded-md text-xs"
|
||||
@click="downloadAllModels"
|
||||
>
|
||||
<i aria-hidden="true" class="icon-[lucide--download] size-4 shrink-0" />
|
||||
|
||||
@@ -12,27 +12,27 @@
|
||||
: t('rightSidePanel.missingModels.expandNodes')
|
||||
"
|
||||
:aria-expanded="expanded"
|
||||
:class="
|
||||
cn(
|
||||
'h-8 w-4 shrink-0 p-0 transition-transform duration-200 hover:bg-transparent',
|
||||
expanded && 'rotate-90'
|
||||
)
|
||||
"
|
||||
class="h-8 w-4 shrink-0 p-0 hover:bg-transparent focus-visible:ring-inset"
|
||||
@click="handleToggleExpand"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--chevron-right] size-4 text-muted-foreground"
|
||||
:class="
|
||||
cn(
|
||||
'icon-[lucide--chevron-right] size-4 text-muted-foreground transition-transform duration-200',
|
||||
expanded && 'rotate-90'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<span class="flex min-w-0 flex-1 flex-col gap-0">
|
||||
<span class="block min-w-0 text-sm/tight">
|
||||
<span class="flex min-w-0 items-center gap-1 text-xs/tight">
|
||||
<button
|
||||
v-if="hasModelLabelControl"
|
||||
ref="modelLabelControl"
|
||||
type="button"
|
||||
class="m-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left font-normal wrap-break-word text-base-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none"
|
||||
class="focus-visible:ring-ring m-0 min-w-0 cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left font-normal wrap-break-word text-base-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
|
||||
:title="displayModelName"
|
||||
@click="handleModelLabelClick"
|
||||
>
|
||||
@@ -40,7 +40,7 @@
|
||||
</button>
|
||||
<span
|
||||
v-else
|
||||
class="font-normal wrap-break-word text-base-foreground"
|
||||
class="min-w-0 font-normal wrap-break-word text-base-foreground"
|
||||
:title="displayModelName"
|
||||
>
|
||||
{{ displayModelName }}
|
||||
@@ -48,14 +48,14 @@
|
||||
<span
|
||||
v-if="hasMultipleReferences"
|
||||
data-testid="missing-model-reference-count"
|
||||
class="ml-2 inline-flex size-6 shrink-0 items-center justify-center rounded-md bg-secondary-background-selected align-middle text-xs font-bold text-muted-foreground"
|
||||
class="inline-flex h-4 min-w-4 shrink-0 items-center justify-center rounded-sm bg-secondary-background-hover px-1 text-2xs font-semibold text-base-foreground"
|
||||
>
|
||||
{{ model.referencingNodes.length }}
|
||||
</span>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="ml-2 inline-flex size-7 shrink-0 align-middle text-muted-foreground hover:bg-transparent hover:text-base-foreground"
|
||||
class="size-6 shrink-0 text-muted-foreground hover:bg-transparent hover:text-base-foreground focus-visible:ring-inset"
|
||||
:aria-label="linkLabel"
|
||||
:title="linkLabel"
|
||||
@click="copyModelLink"
|
||||
@@ -82,7 +82,7 @@
|
||||
data-testid="missing-model-import"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="h-8 shrink-0 rounded-lg text-sm"
|
||||
class="shrink-0 focus-visible:ring-inset"
|
||||
@click="showUploadDialog"
|
||||
>
|
||||
{{ t('g.import') }}
|
||||
@@ -123,7 +123,7 @@
|
||||
data-testid="missing-model-download"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="h-8 shrink-0 rounded-lg text-sm"
|
||||
class="shrink-0 focus-visible:ring-inset"
|
||||
:aria-label="`${t('g.download')} ${model.name}`"
|
||||
@click="handleDownload"
|
||||
>
|
||||
@@ -137,7 +137,7 @@
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="t('rightSidePanel.missingModels.locateNode')"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
|
||||
@click="handleLocatePrimary"
|
||||
>
|
||||
<i aria-hidden="true" class="icon-[lucide--locate] size-4" />
|
||||
@@ -149,7 +149,7 @@
|
||||
v-if="showReferenceList"
|
||||
:class="
|
||||
cn(
|
||||
'm-0 list-none space-y-0.5 p-0',
|
||||
'm-0 list-none p-0',
|
||||
(hasMultipleReferences || isUnknownCategory) && 'pl-5'
|
||||
)
|
||||
"
|
||||
@@ -159,10 +159,10 @@
|
||||
:key="`${String(ref.nodeId)}::${ref.widgetName}`"
|
||||
class="min-w-0"
|
||||
>
|
||||
<div class="flex min-h-6 min-w-0 items-center gap-2">
|
||||
<div class="flex min-h-8 min-w-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="m-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-sm/tight font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none"
|
||||
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-xs/tight font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
|
||||
@click="emit('locateModel', String(ref.nodeId))"
|
||||
>
|
||||
{{
|
||||
@@ -174,7 +174,7 @@
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="t('rightSidePanel.missingModels.locateNode')"
|
||||
class="ml-auto size-6 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
class="ml-auto size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
|
||||
@click="emit('locateModel', String(ref.nodeId))"
|
||||
>
|
||||
<i aria-hidden="true" class="icon-[lucide--locate] size-4" />
|
||||
|
||||
@@ -42,18 +42,6 @@ beforeEach(() => {
|
||||
delete window.__comfyDesktop2Remote
|
||||
})
|
||||
|
||||
function setLegacyDesktop2Bridge(
|
||||
downloadModel: NonNullable<
|
||||
NonNullable<typeof window.__comfyDesktop2>['downloadModel']
|
||||
>
|
||||
): void {
|
||||
Object.defineProperty(window, '__comfyDesktop2', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: { downloadModel }
|
||||
})
|
||||
}
|
||||
|
||||
describe('fetchModelMetadata', () => {
|
||||
beforeEach(() => {
|
||||
mockIsDesktop.value = false
|
||||
@@ -270,10 +258,7 @@ describe('downloadModel', () => {
|
||||
(url: string, filename: string, directory: string) => Promise<boolean>
|
||||
>()
|
||||
.mockResolvedValue(true)
|
||||
window.__comfyDesktop2 = {
|
||||
isRemote: () => false,
|
||||
downloadModel: desktopDownloadModel
|
||||
}
|
||||
window.__comfyDesktop2 = { downloadModel: desktopDownloadModel }
|
||||
|
||||
downloadModel(
|
||||
{
|
||||
@@ -304,10 +289,7 @@ describe('downloadModel', () => {
|
||||
(url: string, filename: string, directory: string) => Promise<boolean>
|
||||
>()
|
||||
.mockRejectedValue(bridgeError)
|
||||
window.__comfyDesktop2 = {
|
||||
isRemote: () => false,
|
||||
downloadModel: desktopDownloadModel
|
||||
}
|
||||
window.__comfyDesktop2 = { downloadModel: desktopDownloadModel }
|
||||
|
||||
downloadModel(
|
||||
{
|
||||
@@ -341,10 +323,7 @@ describe('downloadModel', () => {
|
||||
.mockImplementation(() => {
|
||||
throw bridgeError
|
||||
})
|
||||
window.__comfyDesktop2 = {
|
||||
isRemote: () => false,
|
||||
downloadModel: desktopDownloadModel
|
||||
}
|
||||
window.__comfyDesktop2 = { downloadModel: desktopDownloadModel }
|
||||
|
||||
downloadModel(
|
||||
{
|
||||
@@ -374,62 +353,7 @@ describe('downloadModel', () => {
|
||||
(url: string, filename: string, directory: string) => Promise<boolean>
|
||||
>()
|
||||
.mockResolvedValue(true)
|
||||
window.__comfyDesktop2 = {
|
||||
isRemote: () => true,
|
||||
downloadModel: desktopDownloadModel
|
||||
}
|
||||
|
||||
downloadModel(
|
||||
{
|
||||
name: 'model.safetensors',
|
||||
url: 'https://huggingface.co/org/model/resolve/main/model.safetensors',
|
||||
directory: 'checkpoints'
|
||||
},
|
||||
{ checkpoints: ['/models/checkpoints'] }
|
||||
)
|
||||
|
||||
expect(desktopDownloadModel).not.toHaveBeenCalled()
|
||||
expect(anchorClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('uses the Desktop2 bridge when the new remote check is not available', () => {
|
||||
const anchorClick = vi
|
||||
.spyOn(HTMLAnchorElement.prototype, 'click')
|
||||
.mockImplementation(() => {})
|
||||
const desktopDownloadModel = vi
|
||||
.fn<
|
||||
(url: string, filename: string, directory: string) => Promise<boolean>
|
||||
>()
|
||||
.mockResolvedValue(true)
|
||||
setLegacyDesktop2Bridge(desktopDownloadModel)
|
||||
|
||||
downloadModel(
|
||||
{
|
||||
name: 'model.safetensors',
|
||||
url: 'https://huggingface.co/org/model/resolve/main/model.safetensors',
|
||||
directory: 'checkpoints'
|
||||
},
|
||||
{ checkpoints: ['/models/checkpoints'] }
|
||||
)
|
||||
|
||||
expect(desktopDownloadModel).toHaveBeenCalledWith(
|
||||
'https://huggingface.co/org/model/resolve/main/model.safetensors',
|
||||
'model.safetensors',
|
||||
'checkpoints'
|
||||
)
|
||||
expect(anchorClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('honors the legacy Desktop2 remote marker when the new remote check is not available', () => {
|
||||
const anchorClick = vi
|
||||
.spyOn(HTMLAnchorElement.prototype, 'click')
|
||||
.mockImplementation(() => {})
|
||||
const desktopDownloadModel = vi
|
||||
.fn<
|
||||
(url: string, filename: string, directory: string) => Promise<boolean>
|
||||
>()
|
||||
.mockResolvedValue(true)
|
||||
setLegacyDesktop2Bridge(desktopDownloadModel)
|
||||
window.__comfyDesktop2 = { downloadModel: desktopDownloadModel }
|
||||
window.__comfyDesktop2Remote = true
|
||||
|
||||
downloadModel(
|
||||
|
||||
@@ -2,10 +2,20 @@ import { downloadUrlToHfRepoUrl, isCivitaiModelUrl } from '@/utils/formatUtil'
|
||||
import { isDesktop } from '@/platform/distribution/types'
|
||||
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
import type { ComfyDesktop2Bridge } from '@/types'
|
||||
|
||||
type Desktop2BridgeWithLegacyRemote = Omit<ComfyDesktop2Bridge, 'isRemote'> & {
|
||||
isRemote?: ComfyDesktop2Bridge['isRemote']
|
||||
interface ComfyDesktop2Bridge {
|
||||
downloadModel: (
|
||||
url: string,
|
||||
filename: string,
|
||||
directory: string
|
||||
) => Promise<boolean>
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__comfyDesktop2?: ComfyDesktop2Bridge
|
||||
__comfyDesktop2Remote?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
const ALLOWED_SOURCES = [
|
||||
@@ -41,22 +51,16 @@ export interface ModelWithUrl {
|
||||
}
|
||||
|
||||
async function startDesktop2ModelDownload(
|
||||
bridge: Desktop2BridgeWithLegacyRemote,
|
||||
bridge: ComfyDesktop2Bridge,
|
||||
model: ModelWithUrl
|
||||
): Promise<void> {
|
||||
try {
|
||||
await bridge.downloadModel?.(model.url, model.name, model.directory)
|
||||
await bridge.downloadModel(model.url, model.name, model.directory)
|
||||
} catch (error: unknown) {
|
||||
console.error('Failed to start Desktop2 model download:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function isRemoteDesktop2Bridge(
|
||||
bridge: Desktop2BridgeWithLegacyRemote
|
||||
): boolean {
|
||||
return bridge.isRemote?.() ?? window.__comfyDesktop2Remote ?? false
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a model download URL to a browsable page URL.
|
||||
* - HuggingFace: `/resolve/` → `/blob/` (file page with model info)
|
||||
@@ -86,10 +90,7 @@ export function downloadModel(
|
||||
paths: Record<string, string[]>
|
||||
): void {
|
||||
const desktop2Bridge = window.__comfyDesktop2
|
||||
if (
|
||||
desktop2Bridge?.downloadModel &&
|
||||
!isRemoteDesktop2Bridge(desktop2Bridge)
|
||||
) {
|
||||
if (desktop2Bridge?.downloadModel && !window.__comfyDesktop2Remote) {
|
||||
void startDesktop2ModelDownload(desktop2Bridge, model)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3,21 +3,26 @@
|
||||
<div class="flex min-h-8 w-full items-center gap-1">
|
||||
<Button
|
||||
v-if="hasMultipleNodeTypes"
|
||||
data-testid="swap-node-group-expand"
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:class="
|
||||
cn(
|
||||
'h-8 w-4 shrink-0 p-0 transition-transform duration-200 hover:bg-transparent',
|
||||
expanded && 'rotate-90'
|
||||
)
|
||||
:aria-label="
|
||||
expanded
|
||||
? t('rightSidePanel.missingNodePacks.collapse', 'Collapse')
|
||||
: t('rightSidePanel.missingNodePacks.expand', 'Expand')
|
||||
"
|
||||
aria-hidden="true"
|
||||
tabindex="-1"
|
||||
:aria-expanded="expanded"
|
||||
class="h-8 w-4 shrink-0 p-0 hover:bg-transparent focus-visible:ring-inset"
|
||||
@click="toggleExpand"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--chevron-right] size-4 text-muted-foreground"
|
||||
:class="
|
||||
cn(
|
||||
'icon-[lucide--chevron-right] size-4 text-muted-foreground transition-transform duration-200',
|
||||
expanded && 'rotate-90'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</Button>
|
||||
|
||||
@@ -27,7 +32,7 @@
|
||||
<button
|
||||
v-if="hasMultipleNodeTypes"
|
||||
type="button"
|
||||
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-base-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-1 focus-visible:outline-none"
|
||||
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-xs/relaxed font-normal wrap-break-word text-base-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
|
||||
:title="group.type"
|
||||
:aria-label="titleToggleAriaLabel"
|
||||
:aria-expanded="expanded"
|
||||
@@ -38,7 +43,7 @@
|
||||
<button
|
||||
v-else-if="primaryLocatableNodeType"
|
||||
type="button"
|
||||
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-base-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-1 focus-visible:outline-none"
|
||||
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-xs/relaxed font-normal wrap-break-word text-base-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
|
||||
:title="group.type"
|
||||
@click="handleLocateNode(primaryLocatableNodeType)"
|
||||
>
|
||||
@@ -46,7 +51,7 @@
|
||||
</button>
|
||||
<span
|
||||
v-else
|
||||
class="min-w-0 truncate text-sm/relaxed font-normal text-base-foreground"
|
||||
class="min-w-0 truncate text-xs/relaxed font-normal text-base-foreground"
|
||||
:title="group.type"
|
||||
>
|
||||
{{ group.type }}
|
||||
@@ -55,7 +60,7 @@
|
||||
v-if="hasMultipleNodeTypes"
|
||||
data-testid="swap-node-group-count"
|
||||
role="img"
|
||||
class="flex size-6 shrink-0 items-center justify-center rounded-md bg-secondary-background-selected text-xs font-bold text-muted-foreground"
|
||||
class="flex h-4 min-w-4 shrink-0 items-center justify-center rounded-sm bg-secondary-background-hover px-1 text-2xs font-semibold text-base-foreground"
|
||||
:aria-label="t('g.nodesCount', group.nodeTypes.length)"
|
||||
>
|
||||
{{ group.nodeTypes.length }}
|
||||
@@ -80,7 +85,7 @@
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="h-8 shrink-0 rounded-lg text-sm"
|
||||
class="shrink-0 focus-visible:ring-inset"
|
||||
@click="handleReplaceNode"
|
||||
>
|
||||
<i
|
||||
@@ -96,7 +101,7 @@
|
||||
v-if="primaryLocatableNodeType"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
|
||||
:aria-label="locateNodeLabel"
|
||||
@click="handleLocateNode(primaryLocatableNodeType)"
|
||||
>
|
||||
@@ -116,14 +121,14 @@
|
||||
<button
|
||||
v-if="isLocatableNodeType(nodeType)"
|
||||
type="button"
|
||||
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-1 focus-visible:outline-none"
|
||||
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-xs/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
|
||||
@click="handleLocateNode(nodeType)"
|
||||
>
|
||||
{{ getLabel(nodeType) }}
|
||||
</button>
|
||||
<span
|
||||
v-else
|
||||
class="text-sm/relaxed wrap-break-word text-muted-foreground"
|
||||
class="text-xs/relaxed wrap-break-word text-muted-foreground"
|
||||
>
|
||||
{{ getLabel(nodeType) }}
|
||||
</span>
|
||||
@@ -132,7 +137,7 @@
|
||||
v-if="isLocatableNodeType(nodeType)"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
|
||||
:aria-label="locateNodeLabel"
|
||||
@click="handleLocateNode(nodeType)"
|
||||
>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="mt-2 px-4 pb-2">
|
||||
<div class="px-3">
|
||||
<SwapNodeGroupRow
|
||||
v-for="group in swapNodeGroups"
|
||||
:key="group.type"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<BaseModalLayout content-title="" data-testid="settings-dialog" size="sm">
|
||||
<BaseModalLayout content-title="" data-testid="settings-dialog" size="full">
|
||||
<template #leftPanelHeaderTitle>
|
||||
<i class="icon-[lucide--settings]" />
|
||||
<h2 class="text-neutral text-base">{{ $t('g.settings') }}</h2>
|
||||
|
||||
@@ -53,13 +53,16 @@ describe('useSettingsDialog', () => {
|
||||
isCloudRef.value = false
|
||||
})
|
||||
|
||||
it("show() opens the Reka renderer with size 'full' and 960px content sizing", () => {
|
||||
it("show() opens the Reka renderer with size 'full' and 1280px content sizing", () => {
|
||||
useSettingsDialog().show()
|
||||
const [args] = showDialog.mock.calls[0]
|
||||
expect(args.key).toBe('global-settings')
|
||||
expect(args.dialogComponentProps.renderer).toBe('reka')
|
||||
expect(args.dialogComponentProps.size).toBe('full')
|
||||
expect(args.dialogComponentProps.contentClass).toContain('max-w-[960px]')
|
||||
expect(args.dialogComponentProps.contentClass).toContain('max-w-[1280px]')
|
||||
expect(args.dialogComponentProps.contentClass).not.toContain(
|
||||
'max-w-[960px]'
|
||||
)
|
||||
expect(args.dialogComponentProps.contentClass).toContain('h-[80vh]')
|
||||
})
|
||||
|
||||
|
||||
@@ -8,8 +8,9 @@ import type { SettingPanelType } from '@/platform/settings/types'
|
||||
|
||||
const DIALOG_KEY = 'global-settings'
|
||||
|
||||
// The redesigned Settings dialog is 1280px wide (DES 3253-16079).
|
||||
const SETTINGS_CONTENT_CLASS =
|
||||
'w-[90vw] max-w-[960px] sm:max-w-[960px] h-[80vh] max-h-none rounded-2xl overflow-hidden'
|
||||
'w-[90vw] max-w-[1280px] sm:max-w-[1280px] h-[80vh] max-h-none rounded-2xl overflow-hidden'
|
||||
|
||||
export function useSettingsDialog() {
|
||||
const dialogService = useDialogService()
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
ShareLinkOpenedMetadata,
|
||||
ExecutionErrorMetadata,
|
||||
ExecutionSuccessMetadata,
|
||||
ExecutionTriggerSource,
|
||||
HelpCenterClosedMetadata,
|
||||
HelpCenterOpenedMetadata,
|
||||
HelpResourceClickedMetadata,
|
||||
@@ -18,7 +19,6 @@ import type {
|
||||
SearchQueryMetadata,
|
||||
PageViewMetadata,
|
||||
PageVisibilityMetadata,
|
||||
RunButtonProperties,
|
||||
SettingChangedMetadata,
|
||||
SharedWorkflowRunMetadata,
|
||||
ShellLayoutMetadata,
|
||||
@@ -112,8 +112,11 @@ export class TelemetryRegistry implements TelemetryDispatcher {
|
||||
this.dispatch((provider) => provider.trackApiCreditTopupSucceeded?.())
|
||||
}
|
||||
|
||||
trackRunButton(properties: RunButtonProperties): void {
|
||||
this.dispatch((provider) => provider.trackRunButton?.(properties))
|
||||
trackRunButton(options?: {
|
||||
subscribe_to_run?: boolean
|
||||
trigger_source?: ExecutionTriggerSource
|
||||
}): void {
|
||||
this.dispatch((provider) => provider.trackRunButton?.(options))
|
||||
}
|
||||
|
||||
startTopupTracking(): void {
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/composables/useAppMode', () => ({
|
||||
useAppMode: () => ({
|
||||
mode: { value: 'app' },
|
||||
isAppMode: { value: true }
|
||||
})
|
||||
}))
|
||||
|
||||
import { GtmTelemetryProvider } from './GtmTelemetryProvider'
|
||||
|
||||
@@ -185,22 +192,8 @@ describe('GtmTelemetryProvider', () => {
|
||||
|
||||
it('pushes run_workflow with trigger_source', () => {
|
||||
const provider = createInitializedProvider()
|
||||
provider.trackRunButton({
|
||||
subscribe_to_run: false,
|
||||
workflow_type: 'custom',
|
||||
workflow_name: 'untitled',
|
||||
custom_node_count: 0,
|
||||
total_node_count: 0,
|
||||
subgraph_count: 0,
|
||||
has_api_nodes: false,
|
||||
api_node_names: [],
|
||||
has_toolkit_nodes: false,
|
||||
toolkit_node_names: [],
|
||||
trigger_source: 'button',
|
||||
view_mode: 'app',
|
||||
is_app_mode: true,
|
||||
dock_state: 'floating'
|
||||
})
|
||||
localStorage.setItem('Comfy.MenuPosition.Docked', 'false')
|
||||
provider.trackRunButton({ trigger_source: 'button' })
|
||||
expect(lastDataLayerEntry()).toMatchObject({
|
||||
event: 'run_workflow',
|
||||
trigger_source: 'button',
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
EnterLinearMetadata,
|
||||
ExecutionErrorMetadata,
|
||||
ExecutionSuccessMetadata,
|
||||
ExecutionTriggerSource,
|
||||
HelpCenterClosedMetadata,
|
||||
HelpCenterOpenedMetadata,
|
||||
HelpResourceClickedMetadata,
|
||||
@@ -12,7 +13,6 @@ import type {
|
||||
NodeSearchResultMetadata,
|
||||
PageViewMetadata,
|
||||
PageVisibilityMetadata,
|
||||
RunButtonProperties,
|
||||
SettingChangedMetadata,
|
||||
ShareFlowMetadata,
|
||||
SubscriptionMetadata,
|
||||
@@ -29,6 +29,8 @@ import type {
|
||||
WorkflowImportMetadata,
|
||||
WorkflowSavedMetadata
|
||||
} from '../../types'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { getActionbarDockState } from '../../utils/getActionbarDockState'
|
||||
|
||||
/**
|
||||
* Google Tag Manager telemetry provider.
|
||||
@@ -181,13 +183,18 @@ export class GtmTelemetryProvider implements TelemetryProvider {
|
||||
)
|
||||
}
|
||||
|
||||
trackRunButton(properties: RunButtonProperties): void {
|
||||
trackRunButton(options?: {
|
||||
subscribe_to_run?: boolean
|
||||
trigger_source?: ExecutionTriggerSource
|
||||
}): void {
|
||||
const { mode, isAppMode } = useAppMode()
|
||||
|
||||
this.pushEvent('run_workflow', {
|
||||
subscribe_to_run: properties.subscribe_to_run,
|
||||
trigger_source: properties.trigger_source ?? 'unknown',
|
||||
view_mode: properties.view_mode,
|
||||
is_app_mode: properties.is_app_mode,
|
||||
dock_state: properties.dock_state
|
||||
subscribe_to_run: options?.subscribe_to_run ?? false,
|
||||
trigger_source: options?.trigger_source ?? 'unknown',
|
||||
view_mode: mode.value,
|
||||
is_app_mode: isAppMode.value,
|
||||
dock_state: getActionbarDockState()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,13 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
useCurrentUser: () => ({ onUserResolved: mockOnUserResolved })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useAppMode', () => ({
|
||||
useAppMode: () => ({
|
||||
mode: { value: 'graph' },
|
||||
isAppMode: { value: false }
|
||||
})
|
||||
}))
|
||||
|
||||
const topupMocks = vi.hoisted(() => ({
|
||||
startTopupTracking: vi.fn(),
|
||||
clearTopupTracking: vi.fn(),
|
||||
@@ -24,6 +31,20 @@ const topupMocks = vi.hoisted(() => ({
|
||||
}))
|
||||
vi.mock('@/platform/telemetry/topupTracker', () => topupMocks)
|
||||
|
||||
vi.mock('@/platform/telemetry/utils/getExecutionContext', () => ({
|
||||
getExecutionContext: () => ({
|
||||
is_template: false,
|
||||
workflow_name: 'untitled',
|
||||
custom_node_count: 0,
|
||||
total_node_count: 0,
|
||||
subgraph_count: 0,
|
||||
has_api_nodes: false,
|
||||
api_node_names: [],
|
||||
has_toolkit_nodes: false,
|
||||
toolkit_node_names: []
|
||||
})
|
||||
}))
|
||||
|
||||
const mockNormalizeSurveyResponses = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/platform/telemetry/utils/surveyNormalization', () => ({
|
||||
normalizeSurveyResponses: mockNormalizeSurveyResponses
|
||||
@@ -38,7 +59,6 @@ import type {
|
||||
AuthMetadata,
|
||||
DefaultViewSetMetadata,
|
||||
EnterLinearMetadata,
|
||||
RunButtonProperties,
|
||||
ShareFlowMetadata,
|
||||
ShellLayoutMetadata,
|
||||
SurveyResponses,
|
||||
@@ -430,33 +450,27 @@ describe('MixpanelTelemetryProvider — direct event tracking methods', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('trackRunButton forwards RunButtonProperties', async () => {
|
||||
it('trackRunButton populates RunButtonProperties from the execution context', async () => {
|
||||
const provider = new MixpanelTelemetryProvider()
|
||||
await waitForMixpanelInit()
|
||||
mockMixpanel.track.mockClear()
|
||||
localStorage.setItem('Comfy.MenuPosition.Docked', 'false')
|
||||
|
||||
const properties: RunButtonProperties = {
|
||||
provider.trackRunButton({
|
||||
subscribe_to_run: true,
|
||||
workflow_type: 'custom',
|
||||
workflow_name: 'untitled',
|
||||
custom_node_count: 0,
|
||||
total_node_count: 0,
|
||||
subgraph_count: 0,
|
||||
has_api_nodes: false,
|
||||
api_node_names: [],
|
||||
has_toolkit_nodes: false,
|
||||
toolkit_node_names: [],
|
||||
trigger_source: 'button',
|
||||
view_mode: 'graph',
|
||||
is_app_mode: false,
|
||||
dock_state: 'floating'
|
||||
}
|
||||
|
||||
provider.trackRunButton(properties)
|
||||
trigger_source: 'button'
|
||||
})
|
||||
|
||||
expect(mockMixpanel.track).toHaveBeenCalledWith(
|
||||
TelemetryEvents.RUN_BUTTON_CLICKED,
|
||||
properties
|
||||
expect.objectContaining({
|
||||
subscribe_to_run: true,
|
||||
workflow_type: 'custom',
|
||||
trigger_source: 'button',
|
||||
view_mode: 'graph',
|
||||
is_app_mode: false,
|
||||
dock_state: 'floating'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { OverridedMixpanel } from 'mixpanel-browser'
|
||||
import { omit } from 'es-toolkit'
|
||||
import { watch } from 'vue'
|
||||
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import {
|
||||
checkForCompletedTopup as checkTopupUtil,
|
||||
@@ -10,11 +11,14 @@ import {
|
||||
} from '@/platform/telemetry/topupTracker'
|
||||
import type { AuditLog } from '@/services/customerEventsService'
|
||||
|
||||
import { getExecutionContext } from '../../utils/getExecutionContext'
|
||||
|
||||
import type {
|
||||
AuthMetadata,
|
||||
CreditTopupMetadata,
|
||||
DefaultViewSetMetadata,
|
||||
EnterLinearMetadata,
|
||||
ExecutionTriggerSource,
|
||||
HelpCenterClosedMetadata,
|
||||
HelpCenterOpenedMetadata,
|
||||
HelpResourceClickedMetadata,
|
||||
@@ -44,6 +48,7 @@ import type {
|
||||
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
import { TelemetryEvents } from '../../types'
|
||||
import { getActionbarDockState } from '../../utils/getActionbarDockState'
|
||||
import { normalizeSurveyResponses } from '../../utils/surveyNormalization'
|
||||
|
||||
const DEFAULT_DISABLED_EVENTS = [
|
||||
@@ -271,8 +276,31 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
|
||||
clearTopupUtil()
|
||||
}
|
||||
|
||||
trackRunButton(properties: RunButtonProperties): void {
|
||||
this.trackEvent(TelemetryEvents.RUN_BUTTON_CLICKED, properties)
|
||||
trackRunButton(options?: {
|
||||
subscribe_to_run?: boolean
|
||||
trigger_source?: ExecutionTriggerSource
|
||||
}): void {
|
||||
const executionContext = getExecutionContext()
|
||||
const { mode, isAppMode } = useAppMode()
|
||||
|
||||
const runButtonProperties: RunButtonProperties = {
|
||||
subscribe_to_run: options?.subscribe_to_run || false,
|
||||
workflow_type: executionContext.is_template ? 'template' : 'custom',
|
||||
workflow_name: executionContext.workflow_name ?? 'untitled',
|
||||
custom_node_count: executionContext.custom_node_count,
|
||||
total_node_count: executionContext.total_node_count,
|
||||
subgraph_count: executionContext.subgraph_count,
|
||||
has_api_nodes: executionContext.has_api_nodes,
|
||||
api_node_names: executionContext.api_node_names,
|
||||
has_toolkit_nodes: executionContext.has_toolkit_nodes,
|
||||
toolkit_node_names: executionContext.toolkit_node_names,
|
||||
trigger_source: options?.trigger_source,
|
||||
view_mode: mode.value,
|
||||
is_app_mode: isAppMode.value,
|
||||
dock_state: getActionbarDockState()
|
||||
}
|
||||
|
||||
this.trackEvent(TelemetryEvents.RUN_BUTTON_CLICKED, runButtonProperties)
|
||||
}
|
||||
|
||||
trackSurvey(
|
||||
|
||||
@@ -3,6 +3,7 @@ import { watch } from 'vue'
|
||||
|
||||
import { createPostHogBeforeSend } from '@comfyorg/shared-frontend-utils/piiUtil'
|
||||
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||
@@ -14,6 +15,7 @@ import type {
|
||||
EnterLinearMetadata,
|
||||
ShareFlowMetadata,
|
||||
ShareLinkOpenedMetadata,
|
||||
ExecutionTriggerSource,
|
||||
HelpCenterClosedMetadata,
|
||||
HelpCenterOpenedMetadata,
|
||||
HelpResourceClickedMetadata,
|
||||
@@ -44,6 +46,8 @@ import type {
|
||||
WorkflowSavedMetadata
|
||||
} from '../../types'
|
||||
import { TelemetryEvents } from '../../types'
|
||||
import { getActionbarDockState } from '../../utils/getActionbarDockState'
|
||||
import { getExecutionContext } from '../../utils/getExecutionContext'
|
||||
import { normalizeSurveyResponses } from '../../utils/surveyNormalization'
|
||||
|
||||
const DEFAULT_DISABLED_EVENTS = [
|
||||
@@ -370,8 +374,31 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
this.trackEvent(TelemetryEvents.API_CREDIT_TOPUP_SUCCEEDED)
|
||||
}
|
||||
|
||||
trackRunButton(properties: RunButtonProperties): void {
|
||||
this.trackEvent(TelemetryEvents.RUN_BUTTON_CLICKED, properties)
|
||||
trackRunButton(options?: {
|
||||
subscribe_to_run?: boolean
|
||||
trigger_source?: ExecutionTriggerSource
|
||||
}): void {
|
||||
const executionContext = getExecutionContext()
|
||||
const { mode, isAppMode } = useAppMode()
|
||||
|
||||
const runButtonProperties: RunButtonProperties = {
|
||||
subscribe_to_run: options?.subscribe_to_run || false,
|
||||
workflow_type: executionContext.is_template ? 'template' : 'custom',
|
||||
workflow_name: executionContext.workflow_name ?? 'untitled',
|
||||
custom_node_count: executionContext.custom_node_count,
|
||||
total_node_count: executionContext.total_node_count,
|
||||
subgraph_count: executionContext.subgraph_count,
|
||||
has_api_nodes: executionContext.has_api_nodes,
|
||||
api_node_names: executionContext.api_node_names,
|
||||
has_toolkit_nodes: executionContext.has_toolkit_nodes,
|
||||
toolkit_node_names: executionContext.toolkit_node_names,
|
||||
trigger_source: options?.trigger_source,
|
||||
view_mode: mode.value,
|
||||
is_app_mode: isAppMode.value,
|
||||
dock_state: getActionbarDockState()
|
||||
}
|
||||
|
||||
this.trackEvent(TelemetryEvents.RUN_BUTTON_CLICKED, runButtonProperties)
|
||||
}
|
||||
|
||||
trackSurvey(
|
||||
|
||||
@@ -12,11 +12,11 @@
|
||||
* 3. Check dist/assets/*.js files contain no tracking code
|
||||
*/
|
||||
|
||||
import type { AppMode } from '@/composables/useAppMode'
|
||||
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
|
||||
import type { AuditLog } from '@/services/customerEventsService'
|
||||
import type { AppMode } from '@/utils/appMode'
|
||||
|
||||
/**
|
||||
* Authentication metadata for sign-up tracking
|
||||
@@ -486,7 +486,10 @@ export interface TelemetryProvider {
|
||||
trackAddApiCreditButtonClicked?(): void
|
||||
trackApiCreditTopupButtonPurchaseClicked?(amount: number): void
|
||||
trackApiCreditTopupSucceeded?(): void
|
||||
trackRunButton?(properties: RunButtonProperties): void
|
||||
trackRunButton?(options?: {
|
||||
subscribe_to_run?: boolean
|
||||
trigger_source?: ExecutionTriggerSource
|
||||
}): void
|
||||
|
||||
// Credit top-up tracking (composition with internal utilities)
|
||||
startTopupTracking?(): void
|
||||
|
||||
@@ -5,7 +5,13 @@ const state = vi.hoisted(() => ({
|
||||
activeSidebarTabId: null as string | null,
|
||||
rightSidePanelOpen: false,
|
||||
bottomPanelVisible: false,
|
||||
openWorkflows: [] as unknown[]
|
||||
openWorkflows: [] as unknown[],
|
||||
mode: { value: 'graph' },
|
||||
isAppMode: { value: false }
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useAppMode', () => ({
|
||||
useAppMode: () => ({ mode: state.mode, isAppMode: state.isAppMode })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
@@ -42,12 +48,12 @@ describe('getShellLayoutSnapshot', () => {
|
||||
state.rightSidePanelOpen = false
|
||||
state.bottomPanelVisible = false
|
||||
state.openWorkflows = []
|
||||
state.mode.value = 'graph'
|
||||
state.isAppMode.value = false
|
||||
})
|
||||
|
||||
it('captures the default layout', () => {
|
||||
expect(
|
||||
getShellLayoutSnapshot({ view_mode: 'graph', is_app_mode: false })
|
||||
).toEqual({
|
||||
expect(getShellLayoutSnapshot()).toEqual({
|
||||
view_mode: 'graph',
|
||||
is_app_mode: false,
|
||||
dock_state: 'docked',
|
||||
@@ -65,10 +71,10 @@ describe('getShellLayoutSnapshot', () => {
|
||||
state.rightSidePanelOpen = true
|
||||
state.bottomPanelVisible = true
|
||||
state.openWorkflows = [{}, {}, {}]
|
||||
state.mode.value = 'app'
|
||||
state.isAppMode.value = true
|
||||
|
||||
expect(
|
||||
getShellLayoutSnapshot({ view_mode: 'app', is_app_mode: true })
|
||||
).toEqual({
|
||||
expect(getShellLayoutSnapshot()).toEqual({
|
||||
view_mode: 'app',
|
||||
is_app_mode: true,
|
||||
dock_state: 'floating',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||
@@ -7,15 +8,11 @@ import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
import type { ShellLayoutMetadata } from '../types'
|
||||
import { getActionbarDockState } from './getActionbarDockState'
|
||||
|
||||
type ShellLayoutMode = Pick<ShellLayoutMetadata, 'view_mode' | 'is_app_mode'>
|
||||
|
||||
export function getShellLayoutSnapshot({
|
||||
view_mode,
|
||||
is_app_mode
|
||||
}: ShellLayoutMode): ShellLayoutMetadata {
|
||||
export function getShellLayoutSnapshot(): ShellLayoutMetadata {
|
||||
const { mode, isAppMode } = useAppMode()
|
||||
return {
|
||||
view_mode,
|
||||
is_app_mode,
|
||||
view_mode: mode.value,
|
||||
is_app_mode: isAppMode.value,
|
||||
dock_state: getActionbarDockState(),
|
||||
actionbar_position: useSettingStore().get('Comfy.UseNewMenu'),
|
||||
active_sidebar_tab: useSidebarTabStore().activeSidebarTabId,
|
||||
|
||||
@@ -18,9 +18,9 @@ import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import type { AppMode } from '@/composables/useAppMode'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { createMockChangeTracker } from '@/utils/__tests__/litegraphTestUtils'
|
||||
import type { AppMode } from '@/utils/appMode'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
function createModeTestWorkflow(
|
||||
|
||||
@@ -23,6 +23,7 @@ import { app } from '@/scripts/app'
|
||||
import { blankGraph, defaultGraph } from '@/scripts/defaultGraph'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import type { AppMode } from '@/composables/useAppMode'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
@@ -36,7 +37,6 @@ import {
|
||||
appendWorkflowJsonExt,
|
||||
generateUUID
|
||||
} from '@/utils/formatUtil'
|
||||
import type { AppMode } from '@/utils/appMode'
|
||||
|
||||
function linearModeToAppMode(linearMode: unknown): AppMode | null {
|
||||
if (typeof linearMode !== 'boolean') return null
|
||||
|
||||
@@ -2,13 +2,13 @@ import { markRaw } from 'vue'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
import type { ChangeTracker } from '@/scripts/changeTracker'
|
||||
import type { AppMode } from '@/composables/useAppMode'
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { UserFile } from '@/stores/userFileStore'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { MissingModelCandidate } from '@/platform/missingModel/types'
|
||||
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import type { AppMode } from '@/utils/appMode'
|
||||
|
||||
export interface InputWidgetConfig {
|
||||
height?: number
|
||||
|
||||
@@ -187,9 +187,13 @@ vi.mock('@/lib/litegraph/src/LLink', () => ({
|
||||
LLink: { getReroutes: () => [] }
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/litegraph/src/types/globalEnums', () => ({
|
||||
LinkDirection: { LEFT: 0, RIGHT: 1, NONE: -1 }
|
||||
}))
|
||||
vi.mock('@/lib/litegraph/src/types/globalEnums', async (importOriginal) => {
|
||||
const original = await importOriginal()
|
||||
return {
|
||||
...(original as object),
|
||||
LinkDirection: { LEFT: 0, RIGHT: 1, NONE: -1 }
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/utils/rafBatch', () => ({
|
||||
createRafBatch: (fn: () => void) => ({
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { capturedHandlers, mockLinkConnector, mockAdapter, cancelLinkRelease } =
|
||||
vi.hoisted(() => ({
|
||||
capturedHandlers: {} as Record<string, (...args: unknown[]) => void>,
|
||||
mockLinkConnector: {
|
||||
isConnecting: false,
|
||||
state: { snapLinksPos: null as [number, number] | null },
|
||||
events: {}
|
||||
},
|
||||
mockAdapter: {
|
||||
beginFromOutput: vi.fn(),
|
||||
beginFromInput: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
renderLinks: [] as unknown[],
|
||||
linkConnector: null as unknown,
|
||||
isInputValidDrop: vi.fn(() => false),
|
||||
isOutputValidDrop: vi.fn(() => false),
|
||||
dropOnCanvas: vi.fn()
|
||||
},
|
||||
cancelLinkRelease: vi.fn()
|
||||
}))
|
||||
|
||||
mockAdapter.linkConnector = mockLinkConnector
|
||||
|
||||
// Emulate the real teardown: cancelling a held session clears the connector
|
||||
// state so the subsequent begin call no longer trips the guard.
|
||||
cancelLinkRelease.mockImplementation(() => {
|
||||
mockLinkConnector.isConnecting = false
|
||||
})
|
||||
|
||||
vi.mock('@/stores/workspace/searchBoxStore', () => ({
|
||||
useSearchBoxStore: () => ({ cancelLinkRelease })
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/useAutoPan', () => ({
|
||||
AutoPanController: class {
|
||||
updatePointer = vi.fn()
|
||||
start = vi.fn()
|
||||
stop = vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
canvas: {
|
||||
ds: { offset: [0, 0], scale: 1 },
|
||||
graph: {
|
||||
getNodeById: (id: string) => ({
|
||||
id,
|
||||
inputs: [],
|
||||
outputs: [{ name: 'out', type: '*', links: [], _floatingLinks: null }]
|
||||
}),
|
||||
getLink: () => null,
|
||||
getReroute: () => null
|
||||
},
|
||||
linkConnector: mockLinkConnector,
|
||||
canvas: {
|
||||
getBoundingClientRect: () => ({
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 800,
|
||||
bottom: 600,
|
||||
width: 800,
|
||||
height: 600
|
||||
})
|
||||
},
|
||||
setDirty: vi.fn()
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/links/linkConnectorAdapter', () => ({
|
||||
createLinkConnectorAdapter: () => mockAdapter
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/links/slotLinkDragUIState', () => {
|
||||
const pointer = { client: { x: 0, y: 0 }, canvas: { x: 0, y: 0 } }
|
||||
return {
|
||||
useSlotLinkDragUIState: () => ({
|
||||
state: {
|
||||
active: false,
|
||||
pointerId: null,
|
||||
source: null,
|
||||
pointer,
|
||||
candidate: null,
|
||||
compatible: new Map()
|
||||
},
|
||||
beginDrag: vi.fn(),
|
||||
endDrag: vi.fn(),
|
||||
updatePointerPosition: vi.fn(),
|
||||
setCandidate: vi.fn(),
|
||||
setCompatibleForKey: vi.fn(),
|
||||
clearCompatible: vi.fn()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/composables/element/useCanvasPositionConversion', () => ({
|
||||
useSharedCanvasPositionConversion: () => ({
|
||||
clientPosToCanvasPos: (pos: [number, number]): [number, number] => pos
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
|
||||
layoutStore: {
|
||||
getSlotLayout: () => ({
|
||||
nodeId: 'node1',
|
||||
index: 0,
|
||||
type: 'output',
|
||||
position: { x: 100, y: 200 }
|
||||
}),
|
||||
getAllSlotKeys: () => [],
|
||||
getRerouteLayout: () => null,
|
||||
queryRerouteAtPoint: () => null
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/layout/slots/slotIdentifier', () => ({
|
||||
getSlotKey: (...args: unknown[]) => args.join('-')
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/interaction/canvasPointerEvent', () => ({
|
||||
toCanvasPointerEvent: (e: PointerEvent) => e,
|
||||
clearCanvasPointerHistory: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/composables/slotLinkDragContext',
|
||||
() => ({
|
||||
createSlotLinkDragContext: () => ({
|
||||
reset: vi.fn(),
|
||||
dispose: vi.fn()
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/renderer/extensions/vueNodes/utils/eventUtils', () => ({
|
||||
augmentToCanvasPointerEvent: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/links/linkDropOrchestrator', () => ({
|
||||
resolveSlotTargetCandidate: () => null,
|
||||
resolveNodeSurfaceSlotCandidate: () => null
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useEventListener: (event: string, handler: (...args: unknown[]) => void) => {
|
||||
capturedHandlers[event] = handler
|
||||
return vi.fn()
|
||||
},
|
||||
tryOnScopeDispose: () => {}
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/litegraph/src/LLink', () => ({
|
||||
LLink: { getReroutes: () => [] }
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/litegraph/src/types/globalEnums', () => ({
|
||||
LinkDirection: { LEFT: 0, RIGHT: 1, NONE: -1 }
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/rafBatch', () => ({
|
||||
createRafBatch: (fn: () => void) => ({
|
||||
schedule: () => {},
|
||||
cancel: () => {},
|
||||
flush: fn
|
||||
})
|
||||
}))
|
||||
|
||||
import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction'
|
||||
|
||||
function pointerEvent(pointerId = 1): PointerEvent {
|
||||
return fromPartial<PointerEvent>({
|
||||
clientX: 400,
|
||||
clientY: 300,
|
||||
button: 0,
|
||||
pointerId,
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
target: document.createElement('div'),
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn()
|
||||
})
|
||||
}
|
||||
|
||||
function startDrag() {
|
||||
const { onPointerDown } = useSlotLinkInteraction({
|
||||
nodeId: 'node1',
|
||||
index: 0,
|
||||
type: 'output'
|
||||
})
|
||||
onPointerDown(pointerEvent())
|
||||
}
|
||||
|
||||
describe('useSlotLinkInteraction held-session takeover', () => {
|
||||
beforeEach(() => {
|
||||
for (const k of Object.keys(capturedHandlers)) delete capturedHandlers[k]
|
||||
mockLinkConnector.isConnecting = false
|
||||
cancelLinkRelease.mockClear()
|
||||
mockAdapter.beginFromOutput.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('cancels a held link-release session before starting a new drag', () => {
|
||||
mockLinkConnector.isConnecting = true
|
||||
|
||||
startDrag()
|
||||
|
||||
expect(cancelLinkRelease).toHaveBeenCalledOnce()
|
||||
expect(mockAdapter.beginFromOutput).toHaveBeenCalled()
|
||||
expect(cancelLinkRelease.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mockAdapter.beginFromOutput.mock.invocationCallOrder[0]
|
||||
)
|
||||
})
|
||||
|
||||
it('does not cancel when no session is held', () => {
|
||||
startDrag()
|
||||
|
||||
expect(cancelLinkRelease).not.toHaveBeenCalled()
|
||||
expect(mockAdapter.beginFromOutput).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -32,6 +32,7 @@ import { toPoint } from '@/renderer/core/layout/utils/geometry'
|
||||
import { createSlotLinkDragContext } from '@/renderer/extensions/vueNodes/composables/slotLinkDragContext'
|
||||
import { augmentToCanvasPointerEvent } from '@/renderer/extensions/vueNodes/utils/eventUtils'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
|
||||
import { createRafBatch } from '@/utils/rafBatch'
|
||||
|
||||
interface SlotInteractionOptions {
|
||||
@@ -605,6 +606,13 @@ export function useSlotLinkInteraction({
|
||||
const graph = canvas?.graph
|
||||
if (!canvas || !graph) return
|
||||
|
||||
// A held link-release session (menu open, links kept alive) leaves the
|
||||
// connector mid-drag. Tear it down so this new drag can take over instead
|
||||
// of tripping LinkConnector's "Already dragging links" guard.
|
||||
if (canvas.linkConnector.isConnecting && !pointerSession.isActive()) {
|
||||
useSearchBoxStore().cancelLinkRelease()
|
||||
}
|
||||
|
||||
activeAdapter = createLinkConnectorAdapter()
|
||||
if (!activeAdapter) return
|
||||
raf.cancel()
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { useRunButtonTelemetry } from '@/composables/useRunButtonTelemetry'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { WORKFLOW_ACCEPT_STRING } from '@/platform/workflow/core/types/formats'
|
||||
import { type StatusWsMessageStatus } from '@/schemas/apiSchema'
|
||||
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { extractWorkflow } from '@/platform/remote/comfyui/jobs/fetchJobs'
|
||||
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { WORKFLOW_ACCEPT_STRING } from '@/platform/workflow/core/types/formats'
|
||||
import { type StatusWsMessageStatus } from '@/schemas/apiSchema'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
@@ -489,9 +488,7 @@ export class ComfyUI {
|
||||
textContent: 'Queue Prompt',
|
||||
onclick: () => {
|
||||
if (isCloud) {
|
||||
useRunButtonTelemetry().trackRunButton({
|
||||
trigger_source: 'legacy_ui'
|
||||
})
|
||||
useTelemetry()?.trackRunButton({ trigger_source: 'legacy_ui' })
|
||||
useTelemetry()?.trackWorkflowExecution()
|
||||
}
|
||||
app.queuePrompt(0, this.batchCount)
|
||||
@@ -599,9 +596,7 @@ export class ComfyUI {
|
||||
textContent: 'Queue Front',
|
||||
onclick: () => {
|
||||
if (isCloud) {
|
||||
useRunButtonTelemetry().trackRunButton({
|
||||
trigger_source: 'legacy_ui'
|
||||
})
|
||||
useTelemetry()?.trackRunButton({ trigger_source: 'legacy_ui' })
|
||||
useTelemetry()?.trackWorkflowExecution()
|
||||
}
|
||||
app.queuePrompt(-1, this.batchCount)
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
SubgraphNode,
|
||||
createBounds
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { overlapBounding } from '@/lib/litegraph/src/measure'
|
||||
import type {
|
||||
CreateNodeOptions,
|
||||
GraphAddOptions,
|
||||
@@ -943,10 +944,40 @@ export const useLitegraphService = () => {
|
||||
const graph = useWorkflowStore().activeSubgraph ?? app.graph
|
||||
if (!graph || !node) return null
|
||||
|
||||
// Finalize placement before the node joins the graph so the only position
|
||||
// assignment happens during construction, not as a post-add mutation.
|
||||
if (!addOptions?.ghost) resolveOverlap(node, graph)
|
||||
graph.add(node, addOptions)
|
||||
if (!addOptions?.ghost) centerOnNewNode(node)
|
||||
return node
|
||||
}
|
||||
|
||||
const OVERLAP_GAP = 20
|
||||
const OVERLAP_MAX_ITER = 100
|
||||
|
||||
function resolveOverlap(
|
||||
node: LGraphNode,
|
||||
graph: { nodes: LGraphNode[] }
|
||||
): void {
|
||||
node.updateArea()
|
||||
let iter = 0
|
||||
while (
|
||||
iter++ < OVERLAP_MAX_ITER &&
|
||||
graph.nodes.some(
|
||||
(n) =>
|
||||
n.id !== node.id && overlapBounding(node.boundingRect, n.boundingRect)
|
||||
)
|
||||
) {
|
||||
node.pos[1] += node.size[1] + OVERLAP_GAP
|
||||
node.updateArea()
|
||||
}
|
||||
}
|
||||
|
||||
function centerOnNewNode(node: LGraphNode): void {
|
||||
node.updateArea()
|
||||
app.canvas?.animateToBounds(node.boundingRect, { zoom: 0 })
|
||||
}
|
||||
|
||||
function getCanvasCenter(): Point {
|
||||
const dpi = Math.max(window.devicePixelRatio ?? 1, 1)
|
||||
const visibleArea = app.canvas?.ds?.visible_area
|
||||
|
||||
@@ -2,7 +2,12 @@ import { defineStore } from 'pinia'
|
||||
import { computed, ref, shallowRef } from 'vue'
|
||||
|
||||
import { useNodeProgressText } from '@/composables/node/useNodeProgressText'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import type { AppMode } from '@/composables/useAppMode'
|
||||
import {
|
||||
getWorkflowMode,
|
||||
isAppModeValue,
|
||||
useAppMode
|
||||
} from '@/composables/useAppMode'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
@@ -35,8 +40,6 @@ import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import type { NodeLocatorId } from '@/types/nodeIdentification'
|
||||
import { classifyCloudValidationError } from '@/utils/executionErrorUtil'
|
||||
import { executionIdToNodeLocatorId } from '@/utils/graphTraversalUtil'
|
||||
import type { AppMode } from '@/utils/appMode'
|
||||
import { getWorkflowMode, isAppModeValue } from '@/utils/appMode'
|
||||
|
||||
interface ExecutionNodeInfo {
|
||||
title?: string | null
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useTimeoutFn } from '@vueuse/core'
|
||||
import { mapKeys } from 'es-toolkit'
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
@@ -358,8 +359,12 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
function restoreOutputs(
|
||||
outputs: Record<string, ExecutedWsMessage['output']>
|
||||
) {
|
||||
app.nodeOutputs = outputs
|
||||
nodeOutputs.value = { ...outputs }
|
||||
const parsedOutputs = mapKeys(
|
||||
outputs,
|
||||
(_, id) => executionIdToNodeLocatorId(app.rootGraph, id) ?? id
|
||||
)
|
||||
app.nodeOutputs = parsedOutputs
|
||||
nodeOutputs.value = { ...parsedOutputs }
|
||||
}
|
||||
|
||||
function updateNodeImages(node: LGraphNode) {
|
||||
|
||||
@@ -20,7 +20,7 @@ vi.mock('@/platform/settings/settingStore', () => ({
|
||||
}))
|
||||
|
||||
function createMockPopover(): InstanceType<typeof NodeSearchBoxPopover> {
|
||||
return { showSearchBox: vi.fn() } as Partial<
|
||||
return { showSearchBox: vi.fn(), cancelLinkRelease: vi.fn() } as Partial<
|
||||
InstanceType<typeof NodeSearchBoxPopover>
|
||||
> as InstanceType<typeof NodeSearchBoxPopover>
|
||||
}
|
||||
@@ -135,4 +135,23 @@ describe('useSearchBoxStore', () => {
|
||||
expect(store.visible).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cancelLinkRelease', () => {
|
||||
it('delegates to the popover to tear down a held link-release session', () => {
|
||||
const store = useSearchBoxStore()
|
||||
const mockPopover = createMockPopover()
|
||||
store.setPopoverRef(mockPopover)
|
||||
|
||||
store.cancelLinkRelease()
|
||||
|
||||
expect(vi.mocked(mockPopover.cancelLinkRelease)).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does nothing when the popover is not ready', () => {
|
||||
const store = useSearchBoxStore()
|
||||
store.setPopoverRef(null)
|
||||
|
||||
expect(() => store.cancelLinkRelease()).not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -28,6 +28,10 @@ export const useSearchBoxStore = defineStore('searchBox', () => {
|
||||
popoverRef.value = popover
|
||||
}
|
||||
|
||||
function cancelLinkRelease() {
|
||||
popoverRef.value?.cancelLinkRelease()
|
||||
}
|
||||
|
||||
const visible = ref(false)
|
||||
function toggleVisible() {
|
||||
if (newSearchBoxEnabled.value) {
|
||||
@@ -49,6 +53,7 @@ export const useSearchBoxStore = defineStore('searchBox', () => {
|
||||
useSearchBoxV2,
|
||||
newSearchBoxEnabled,
|
||||
setPopoverRef,
|
||||
cancelLinkRelease,
|
||||
toggleVisible,
|
||||
visible
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { ComfyDesktop2Bridge } from '@comfyorg/comfyui-desktop-bridge-types'
|
||||
import type {
|
||||
DeviceStats,
|
||||
EmbeddingsResponse,
|
||||
@@ -26,7 +25,6 @@ import type {
|
||||
} from './extensionTypes'
|
||||
|
||||
export type { ComfyExtension } from './comfy'
|
||||
export type { ComfyDesktop2Bridge } from '@comfyorg/comfyui-desktop-bridge-types'
|
||||
export type { ComfyApi } from '@/scripts/api'
|
||||
export type { ComfyApp } from '@/scripts/app'
|
||||
export type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
@@ -90,8 +88,5 @@ declare global {
|
||||
|
||||
/** For use in tests to track app initialization state */
|
||||
__appReadiness?: AppReadiness
|
||||
|
||||
__comfyDesktop2?: ComfyDesktop2Bridge
|
||||
__comfyDesktop2Remote?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
export type AppMode =
|
||||
| 'graph'
|
||||
| 'app'
|
||||
| 'builder:inputs'
|
||||
| 'builder:outputs'
|
||||
| 'builder:arrange'
|
||||
|
||||
type WorkflowModeSource = {
|
||||
activeMode: AppMode | null
|
||||
initialMode: AppMode | null | undefined
|
||||
}
|
||||
|
||||
export function getWorkflowMode(
|
||||
workflow: WorkflowModeSource | null | undefined
|
||||
): AppMode {
|
||||
return workflow?.activeMode ?? workflow?.initialMode ?? 'graph'
|
||||
}
|
||||
|
||||
export function isAppModeValue(mode: AppMode): boolean {
|
||||
return mode === 'app' || mode === 'builder:arrange'
|
||||
}
|
||||
@@ -111,7 +111,7 @@ const queueStore = useQueueStore()
|
||||
const assetsStore = useAssetsStore()
|
||||
const versionCompatibilityStore = useVersionCompatibilityStore()
|
||||
const graphCanvasContainerRef = ref<HTMLDivElement | null>(null)
|
||||
const { isBuilderMode, mode, isAppMode } = useAppMode()
|
||||
const { isBuilderMode } = useAppMode()
|
||||
const { linearMode } = storeToRefs(useCanvasStore())
|
||||
|
||||
watch(linearMode, (isLinear) => {
|
||||
@@ -354,12 +354,7 @@ const onGraphReady = () => {
|
||||
|
||||
// Shell layout snapshot, once per session (cloud only)
|
||||
if (isCloud && telemetry) {
|
||||
telemetry.trackShellLayout(
|
||||
getShellLayoutSnapshot({
|
||||
view_mode: mode.value,
|
||||
is_app_mode: isAppMode.value
|
||||
})
|
||||
)
|
||||
telemetry.trackShellLayout(getShellLayoutSnapshot())
|
||||
}
|
||||
|
||||
// Setting values now available after comfyApp.setup.
|
||||
|
||||
39
vercel.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"$schema": "https://openapi.vercel.sh/vercel.json",
|
||||
"buildCommand": "pnpm build:cloud",
|
||||
"outputDirectory": "dist",
|
||||
"installCommand": "pnpm install --frozen-lockfile",
|
||||
"framework": null,
|
||||
"env": {
|
||||
"DISTRIBUTION": "cloud",
|
||||
"USE_PROD_CONFIG": "true",
|
||||
"ALGOLIA_APP_ID": "4E0RO38HS8",
|
||||
"ALGOLIA_API_KEY": "684d998c36b67a9a9fce8fc2d8860579"
|
||||
},
|
||||
"rewrites": [
|
||||
{
|
||||
"source": "/api/:path*",
|
||||
"destination": "https://cloud.comfy.org/api/:path*"
|
||||
},
|
||||
{
|
||||
"source": "/internal/:path*",
|
||||
"destination": "https://cloud.comfy.org/internal/:path*"
|
||||
},
|
||||
{
|
||||
"source": "/extensions/:path*",
|
||||
"destination": "https://cloud.comfy.org/extensions/:path*"
|
||||
},
|
||||
{
|
||||
"source": "/workflow_templates/:path*",
|
||||
"destination": "https://cloud.comfy.org/workflow_templates/:path*"
|
||||
},
|
||||
{
|
||||
"source": "/oauth/:path*",
|
||||
"destination": "https://cloud.comfy.org/oauth/:path*"
|
||||
},
|
||||
{
|
||||
"source": "/((?!api/|assets/|.*\\..*).*)",
|
||||
"destination": "/index.html"
|
||||
}
|
||||
]
|
||||
}
|
||||