Compare commits

..

6 Commits

Author SHA1 Message Date
Austin Mroz
93ac026fc8 Support space bar pan while dragging vue nodes 2026-06-17 22:03:57 -07:00
Austin Mroz
0988cb1d87 Fix tests 2026-06-15 19:47:12 -07:00
Austin Mroz
00eade5e18 Add e2e link pan test 2026-06-15 16:25:48 -07:00
Austin Mroz
f58f0ce2d3 Fix space-bar pan while moving vue links 2026-06-15 15:51:29 -07:00
Connor Byrne
a4749501b1 refactor: use canvas event for read-only sync instead of callback
Replace LGraphCanvas.onReadOnlyChanged callback property with a
'litegraph:read-only-changed' CustomEvent dispatched on the canvas
DOM element. canvasStore now subscribes via useEventListener,
matching the existing pattern used for litegraph:set-graph,
subgraph-opened, subgraph-converted, and litegraph:ghost-placement.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/8998#discussion_r2831838643
https://github.com/Comfy-Org/ComfyUI_frontend/pull/8998#discussion_r3184409804
2026-06-15 15:51:23 -07:00
bymyself
5ed7948745 fix: space bar panning over Vue nodes in standard nav mode
Bridge LGraphCanvas.read_only to Vue reactivity via onReadOnlyChanged
callback so the existing CSS pointer-events-auto/none toggle on
LGraphNode.vue and NodeWidgets.vue re-evaluates when space key
toggles panning mode. Events then fall through to the LiteGraph
canvas naturally — no per-handler forwarding or force flags needed.

Fixes #7806

Amp-Thread-ID: https://ampcode.com/threads/T-019c796c-e83c-769d-85f4-20a349994bad
2026-06-15 15:51:17 -07:00
75 changed files with 685 additions and 3540 deletions

View File

@@ -1,14 +1,10 @@
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
@@ -16,10 +12,6 @@ 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)

View File

@@ -4,10 +4,6 @@
*/
export const TestIds = {
linkReleaseMenu: {
root: 'link-release-context-menu',
search: 'link-release-search'
},
sidebar: {
toolbar: 'side-toolbar',
nodeLibrary: 'node-library-tree',

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 100 KiB

View File

@@ -21,7 +21,7 @@ test.describe('Link & node interaction settings', { tag: '@canvas' }, () => {
await expect(comfyPage.searchBoxV2.input).toBeVisible()
})
test('"context menu" opens the link-release context menu on link release', async ({
test('"context menu" opens litegraph connection 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.linkReleaseMenu).toBeVisible()
await expect(comfyPage.contextMenu.litegraphContextMenu).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.linkReleaseMenu).toBeHidden()
await expect(comfyPage.contextMenu.litegraphContextMenu).toBeHidden()
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 325 KiB

After

Width:  |  Height:  |  Size: 324 KiB

View File

@@ -1,103 +0,0 @@
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()
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -296,7 +296,12 @@ test.describe('Release context menu', { tag: '@node' }, () => {
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await comfyPage.canvasOps.disconnectEdge()
await expect(comfyPage.contextMenu.linkReleaseMenu).toBeVisible()
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 comfyPage.page.mouse.move(10, 10)
await comfyPage.expectScreenshot(
comfyPage.canvas,
@@ -308,20 +313,14 @@ test.describe('Release context menu', { tag: '@node' }, () => {
test(
'Can search and add node from context menu',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
async ({ comfyPage, comfyMouse }) => {
const initialNodeCount = await comfyPage.nodeOps.getGraphNodesCount()
await comfyPage.canvasOps.disconnectEdge()
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 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.canvas).toHaveScreenshot(
'link-context-menu-search.png'
)
@@ -344,7 +343,8 @@ 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)
await expect(comfyPage.contextMenu.linkReleaseMenu).toBeVisible()
const contextMenu = comfyPage.page.locator('.litecontextmenu')
await expect(contextMenu).toBeVisible()
})
test('Explicit setting overrides versioned defaults', async ({
@@ -366,6 +366,7 @@ 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)
await expect(comfyPage.contextMenu.linkReleaseMenu).toBeVisible()
const contextMenu = comfyPage.page.locator('.litecontextmenu')
await expect(contextMenu).toBeVisible()
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -1270,3 +1270,38 @@ test(
})
}
)
test('Spacebar pan', { tag: '@vue-nodes' }, async ({ comfyPage }) => {
const initialOffset = await comfyPage.canvasOps.getOffset()
await test.step('Setup link drag', async () => {
await comfyPage.searchBoxV2.addNode('Load Diffusion')
const loadNode = await comfyPage.vueNodes.getFixtureByTitle('Load Diff')
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
await loadNode.getSlot('MODEL').hover()
await comfyPage.page.mouse.down()
await ksampler.getSlot('model').hover()
expect(await comfyPage.canvasOps.getOffset()).toEqual(initialOffset)
})
await test.step('Holding space initiates a pan', async () => {
await comfyPage.page.keyboard.down(' ')
await comfyPage.page.mouse.move(100, 100)
await comfyPage.page.keyboard.up(' ')
await expect
.poll(() => comfyPage.canvasOps.getOffset())
.not.toEqual(initialOffset)
})
await test.step('Mouse remains over model after pan', async () => {
await comfyPage.page.mouse.up()
await expect
.poll(() =>
comfyPage.page.evaluate(
() => graph?.nodes?.at(-1)?.outputs?.[0]?.links?.length === 1
)
)
.toBe(true)
})
})

View File

@@ -8,7 +8,6 @@ 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)
@@ -140,46 +139,6 @@ 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) {

View File

@@ -20,13 +20,10 @@
"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",
@@ -49,7 +46,6 @@
"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",

View File

@@ -1,67 +0,0 @@
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')

View File

@@ -66,6 +66,7 @@
@pointerdown.capture="forwardPointerDownPanEvent"
@pointerup.capture="forwardPointerUpPanEvent"
@pointermove.capture="forwardPointerMovePanEvent"
@keydown.space="canvasInteractions.forwardEventToCanvas"
>
<!-- Vue nodes rendered based on graph nodes -->
<LGraphNode

View File

@@ -1,71 +0,0 @@
<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>

View File

@@ -1,31 +1,29 @@
<template>
<div class="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden">
<div class="flex min-h-0 flex-1 flex-col overflow-hidden">
<div
v-if="card.nodeId && !compact"
class="flex min-h-8 flex-wrap items-center gap-2"
class="flex flex-wrap items-center gap-2 py-2"
>
<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>
<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>
<div class="flex shrink-0 items-center">
<Button
v-if="card.isSubgraphNode"
variant="secondary"
size="sm"
class="shrink-0 focus-visible:ring-inset"
class="h-8 shrink-0 rounded-lg text-sm"
@click.stop="handleEnterSubgraph"
>
{{ t('rightSidePanel.enterSubgraph') }}
@@ -36,7 +34,7 @@
size="icon-sm"
:class="
cn(
'size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset',
'size-8 shrink-0 text-muted-foreground hover:text-base-foreground',
runtimeDetailsExpanded &&
'bg-secondary-background-selected text-base-foreground hover:bg-secondary-background-selected'
)
@@ -51,7 +49,7 @@
<Button
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
:aria-label="t('rightSidePanel.locateNode')"
@click.stop="handleLocateNode"
>
@@ -61,29 +59,29 @@
</div>
<div
class="flex min-h-0 flex-1 flex-col space-y-2 divide-y divide-interface-stroke/20"
class="flex min-h-0 flex-1 flex-col space-y-4 divide-y divide-interface-stroke/20"
>
<div
v-for="(error, idx) in card.errors"
:key="idx"
class="flex min-h-0 flex-col gap-1"
class="flex min-h-0 flex-col gap-3"
>
<p
v-if="getInlineMessage(error)"
class="m-0 max-h-[4lh] overflow-y-auto px-0.5 text-xs/relaxed wrap-break-word whitespace-pre-wrap"
class="m-0 max-h-[4lh] overflow-y-auto px-0.5 text-sm/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-xs/relaxed text-muted-foreground marker:text-muted-foreground"
class="m-0 list-disc space-y-1 pl-5 text-sm/relaxed text-muted-foreground marker:text-muted-foreground"
>
<li class="min-w-0 wrap-break-word">
<button
v-if="card.nodeId"
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:ring-1 focus-visible:outline-none focus-visible:ring-inset"
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"
>
{{ getInlineItemLabel(error) }}
@@ -98,13 +96,13 @@
v-if="!error.isRuntimeError && getInlineDetails(error, idx)"
:class="
cn(
'overflow-y-auto rounded-lg bg-base-foreground/5 p-3',
'overflow-y-auto rounded-lg border border-interface-stroke/30 bg-secondary-background p-2.5',
'max-h-[6lh]'
)
"
>
<p
class="m-0 text-xs/normal wrap-break-word whitespace-pre-wrap text-base-foreground/50"
class="m-0 font-mono text-xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
>
{{ getInlineDetails(error, idx) }}
</p>
@@ -117,61 +115,60 @@
role="region"
data-testid="runtime-error-panel"
:aria-label="t('rightSidePanel.errorLog')"
class="flex min-h-0 flex-col gap-1"
class="flex min-h-0 flex-col gap-3"
>
<div
v-if="getInlineDetails(error, idx)"
class="flex flex-col gap-3 rounded-lg bg-base-foreground/5 p-3"
class="overflow-hidden rounded-lg border border-interface-stroke/30 bg-secondary-background"
>
<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
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>
<div aria-hidden="true" class="h-px w-full bg-interface-stroke" />
<div class="flex items-center justify-between gap-2">
<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">
<Button
v-tooltip.top="t('rightSidePanel.getHelpTooltip')"
variant="textonly"
size="sm"
class="justify-start gap-1 px-0 text-xs hover:bg-transparent hover:text-base-foreground focus-visible:ring-inset"
class="h-8 justify-start gap-1 rounded-lg px-0 text-sm hover:bg-transparent hover:text-base-foreground"
@click="handleGetHelp"
>
<i class="icon-[lucide--external-link] size-4" />
<i class="icon-[lucide--external-link] size-3.5" />
{{ t('g.getHelpAction') }}
</Button>
<Button
v-tooltip.top="t('rightSidePanel.findOnGithubTooltip')"
variant="textonly"
size="sm"
class="justify-end gap-1 px-0 text-xs hover:bg-transparent hover:text-base-foreground focus-visible:ring-inset"
class="h-8 justify-end gap-1 rounded-lg px-0 text-sm hover:bg-transparent hover:text-base-foreground"
data-testid="error-card-find-on-github"
@click="handleCheckGithub(error)"
>
<i class="icon-[lucide--github] size-4" />
<i class="icon-[lucide--github] size-3.5" />
{{ t('g.findOnGithub') }}
</Button>
</div>

View File

@@ -1,5 +1,5 @@
<template>
<div data-testid="missing-node-card" class="px-3">
<div data-testid="missing-node-card" class="px-4 pb-2">
<!-- 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">
<div class="flex flex-col gap-1 overflow-hidden py-2">
<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-md text-xs"
class="mt-2 h-8 w-full min-w-0 rounded-lg text-sm"
@click="applyChanges()"
>
<DotSpinner v-if="isRestarting" duration="1s" :size="12" />

View File

@@ -12,17 +12,17 @@
: t('rightSidePanel.missingNodePacks.expand')
"
:aria-expanded="expanded"
class="h-8 w-4 shrink-0 p-0 hover:bg-transparent focus-visible:ring-inset"
:class="
cn(
'h-8 w-4 shrink-0 p-0 transition-transform duration-200 hover:bg-transparent',
expanded && 'rotate-90'
)
"
@click="toggleExpand"
>
<i
aria-hidden="true"
:class="
cn(
'icon-[lucide--chevron-right] size-4 text-muted-foreground transition-transform duration-200',
expanded && 'rotate-90'
)
"
class="icon-[lucide--chevron-right] size-4 text-muted-foreground"
/>
</Button>
<i
@@ -64,7 +64,7 @@
</button>
<span
v-else
class="min-w-0 truncate text-xs/relaxed font-normal"
class="min-w-0 truncate text-sm/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-6 shrink-0 text-muted-foreground hover:bg-transparent hover:text-base-foreground focus-visible:ring-inset"
class="size-7 shrink-0 text-muted-foreground hover:bg-transparent hover:text-base-foreground"
: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 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"
class="flex size-6 shrink-0 items-center justify-center rounded-md bg-secondary-background-selected text-xs font-bold text-muted-foreground"
>
{{ group.nodeTypes.length }}
</span>
@@ -99,7 +99,7 @@
<Button
variant="secondary"
size="sm"
class="shrink-0 focus-visible:ring-inset"
class="h-8 shrink-0 rounded-lg text-sm"
:disabled="isPackInstalled || isInstalling"
@click="handlePackInstallClick"
>
@@ -122,10 +122,10 @@
</div>
<div
v-else-if="showLoadingAction"
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"
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"
>
<DotSpinner duration="1s" :size="12" class="mr-1.5 shrink-0" />
<span class="text-foreground min-w-0 truncate text-xs">
<span class="text-foreground min-w-0 truncate text-sm">
{{ t('g.loading') }}
</span>
</div>
@@ -133,7 +133,7 @@
<Button
variant="secondary"
size="sm"
class="shrink-0 focus-visible:ring-inset"
class="h-8 shrink-0 rounded-lg text-sm"
@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 focus-visible:ring-inset"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
:aria-label="t('rightSidePanel.locateNode')"
@click="handleLocateNode(primaryLocatableNodeType)"
>
@@ -163,7 +163,7 @@
v-if="showNodeTypeList"
:class="
cn(
'm-0 list-none p-0',
'm-0 list-none space-y-1 p-0',
(hasMultipleNodeTypes || isUnknownPack) && 'pl-5'
)
"
@@ -190,7 +190,7 @@
</button>
<span
v-else
class="text-xs/relaxed wrap-break-word text-muted-foreground"
class="text-sm/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 focus-visible:ring-inset"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
: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-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'
'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'
const { missingNodePacks, isLoading } = useMissingNodes()
const comfyManagerStore = useComfyManagerStore()

View File

@@ -78,10 +78,6 @@ 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',
@@ -122,6 +118,9 @@ 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>'
}
@@ -212,13 +211,7 @@ describe('TabErrors.vue', () => {
})
expect(screen.getByText('Missing connection')).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.getByText('(3)')).toBeInTheDocument()
expect(
screen.getAllByText(
'Required input slots have no connection feeding them.'
@@ -411,7 +404,7 @@ describe('TabErrors.vue', () => {
})
const missingModelStore = useMissingModelStore()
expect(screen.getByText('Missing Models')).toBeInTheDocument()
expect(screen.getByText('Missing Models (1)')).toBeInTheDocument()
expect(
screen.queryByTestId('missing-model-actions')
).not.toBeInTheDocument()
@@ -421,40 +414,6 @@ 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',
@@ -472,7 +431,7 @@ describe('TabErrors.vue', () => {
}
})
expect(screen.getByText('Missing Models')).toBeInTheDocument()
expect(screen.getByText('Missing Models (1)')).toBeInTheDocument()
expect(
screen.getByText('Download a model, or open the node to replace it.')
).toBeInTheDocument()
@@ -494,7 +453,7 @@ describe('TabErrors.vue', () => {
}
})
expect(screen.getByText('Missing Inputs')).toBeInTheDocument()
expect(screen.getByText('Missing Inputs (1)')).toBeInTheDocument()
expect(
screen.getByText('A required media input has no file selected.')
).toBeInTheDocument()
@@ -536,12 +495,6 @@ 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' })
@@ -573,7 +526,7 @@ describe('TabErrors.vue', () => {
}
})
expect(screen.getByText('Swap Nodes')).toBeInTheDocument()
expect(screen.getByText('Swap Nodes (1)')).toBeInTheDocument()
expect(
screen.getByText('Some nodes can be replaced with alternatives')
).toBeInTheDocument()

View File

@@ -11,62 +11,49 @@
/>
</div>
<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 class="min-w-0 flex-1 overflow-y-auto" aria-live="polite">
<TransitionGroup tag="div" name="list-scale" class="relative">
<div
data-testid="errors-summary-hero"
class="flex items-center gap-2 bg-base-foreground/5 p-2"
v-if="filteredGroups.length === 0"
key="empty"
class="px-4 pt-5 pb-15 text-center text-sm text-muted-foreground"
>
<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>
{{
searchQuery.trim()
? t('rightSidePanel.noneSearchDesc')
: t('rightSidePanel.noErrors')
}}
</div>
<!-- Group by Class Type -->
<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>
<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>
<Button
v-if="
group.type === 'missing_node' &&
@@ -75,7 +62,7 @@
"
variant="secondary"
size="sm"
class="shrink-0"
class="mr-2 h-8 shrink-0 rounded-lg text-sm"
:disabled="isInstallingAll"
@click.stop="installAll"
>
@@ -96,7 +83,7 @@
"
variant="secondary"
size="sm"
class="shrink-0"
class="mr-2 h-8 shrink-0 rounded-lg text-sm"
@click.stop="handleReplaceAll()"
>
{{ t('nodeReplacement.replaceAll', 'Replace All') }}
@@ -109,7 +96,7 @@
data-testid="missing-model-header-refresh"
variant="muted-textonly"
size="icon"
class="shrink-0 rounded-lg hover:bg-transparent hover:text-base-foreground"
class="mr-2 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"
@@ -142,142 +129,140 @@
: ''
}}
</span>
</template>
<div
v-if="group.displayMessage"
data-testid="error-group-display-message"
class="px-3 py-1"
>
<p
class="m-0 text-xs/normal wrap-break-word whitespace-pre-wrap text-base-foreground/50"
>
{{ group.displayMessage }}
</p>
</div>
</template>
<!-- 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"
/>
<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"
>
{{ group.displayMessage }}
</p>
</div>
<!-- Swap Nodes -->
<SwapNodesCard
v-if="group.type === 'swap_nodes'"
:swap-node-groups="swapNodeGroups"
@locate-node="handleLocateMissingNode"
@replace="handleReplaceGroup"
/>
<!-- 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"
/>
<!-- 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>
<!-- 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"
>
<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>
<Button
v-if="item.displayDetails"
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
: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'
)
"
:aria-label="
t('rightSidePanel.locateNodeFor', { item: item.label })
t('rightSidePanel.infoFor', { item: item.label })
"
@click.stop="handleLocateNode(item.nodeId)"
:aria-controls="getExecutionItemDetailId(item.key)"
:aria-expanded="isExecutionItemDetailExpanded(item.key)"
@click.stop="toggleExecutionItemDetail(item.key)"
>
<i class="icon-[lucide--locate] size-4" />
<i class="icon-[lucide--info] size-3.5" />
</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-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"
</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>
<!-- Missing Media -->
<MissingMediaCard
v-if="group.type === 'missing_media'"
:missing-media-groups="missingMediaGroups"
@locate-node="handleLocateAssetNode"
/>
</ErrorCardSection>
</TransitionGroup>
</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>
</div>
<ErrorPanelSurveyCta v-if="ErrorPanelSurveyCta" />
<!-- Fixed Footer: Help Links -->
<div
class="min-w-0 shrink-0 border-t border-interface-stroke bg-interface-panel-surface p-4"
>
<div class="min-w-0 shrink-0 border-t border-interface-stroke p-4">
<i18n-t
keypath="rightSidePanel.errorHelp"
tag="p"
@@ -319,16 +304,15 @@ 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'
@@ -372,6 +356,16 @@ 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' &&
@@ -458,28 +452,6 @@ 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
)

View File

@@ -334,7 +334,7 @@ describe('useErrorGroups', () => {
)
expect(missingGroup).toBeDefined()
expect(missingGroup?.groupKey).toBe('missing_node')
expect(missingGroup?.displayTitle).toBe('Missing Node Packs')
expect(missingGroup?.displayTitle).toBe('Missing Node Packs (1)')
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')
expect(modelGroup?.displayTitle).toBe('Missing Models (1)')
})
})
@@ -1098,7 +1098,7 @@ describe('useErrorGroups', () => {
const missingMediaGroup = groups.allErrorGroups.value.find(
(group) => group.type === 'missing_media'
)
expect(missingMediaGroup?.displayTitle).toBe('Missing Inputs')
expect(missingMediaGroup?.displayTitle).toBe('Missing Inputs (2)')
})
})

View File

@@ -1,180 +0,0 @@
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()
})
})

View File

@@ -1,384 +0,0 @@
<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>

View File

@@ -1,120 +0,0 @@
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') }

View File

@@ -1,138 +0,0 @@
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()
})
})

View File

@@ -1,242 +0,0 @@
<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>

View File

@@ -1,182 +0,0 @@
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()
})
})

View File

@@ -1,156 +0,0 @@
<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>

View File

@@ -6,14 +6,10 @@ 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]))
@@ -55,7 +51,6 @@ 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',
@@ -87,17 +82,6 @@ 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: {
@@ -115,7 +99,6 @@ describe('NodeSearchBoxPopover', () => {
stubs: {
NodeSearchBox: NodeSearchBoxStub,
NodeSearchContent: NodeSearchContentStub,
LinkReleaseContextMenu: LinkReleaseContextMenuStub,
NodePreviewCard: true,
Dialog: {
template: '<div><slot name="container" /></div>',
@@ -139,11 +122,6 @@ 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
}
}
}
@@ -298,122 +276,4 @@ 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'
)
})
})
})

View File

@@ -27,8 +27,6 @@
<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"
@@ -53,13 +51,6 @@
/>
</template>
</Dialog>
<LinkReleaseContextMenu
ref="linkReleaseMenu"
:context="linkReleaseContext"
@select-node="connectNodeFromMenu"
@add-reroute="addRerouteFromMenu"
@dismiss="reset"
/>
</div>
</template>
@@ -71,11 +62,7 @@ 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,
isNodeSlot
} from '@/lib/litegraph/src/litegraph'
import { LGraphNode, LiteGraph } 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'
@@ -91,14 +78,11 @@ 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()
@@ -119,8 +103,6 @@ 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]
@@ -147,26 +129,16 @@ function closeDialog() {
}
const canvasStore = useCanvasStore()
// 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
function addNode(nodeDef: ComfyNodeDefImpl, dragEvent?: MouseEvent) {
const followCursor = settingStore.get('Comfy.NodeSearchBoxImpl.FollowCursor')
const node = withNodeAddSource('search_modal', () =>
litegraphService.addNodeOnGraph(
nodeDef,
{ pos: getNewNodeLocation() },
{ ghost, dragEvent }
{ ghost: useSearchBoxV2.value && followCursor, dragEvent }
)
)
if (!node) return null
if (!node) return
if (disconnectOnReset && triggerEvent) {
canvasStore.getCanvas().linkConnector.connectToNode(node, triggerEvent)
@@ -178,16 +150,6 @@ function connectNewNode(
// 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)
}
@@ -240,46 +202,62 @@ function showContextMenu(e: CanvasPointerEvent) {
const firstLink = getFirstLink()
if (!firstLink) return
const { fromSlot, toType } = firstLink
linkReleaseContext.value = {
dataType: fromSlot.type?.toString() ?? '',
slotName: fromSlot.name ?? '',
isFromOutput: toType === 'input'
const { node, fromSlot, toType } = firstLink
const commonOptions = {
e,
allow_searchbox: true,
showSearchBox: () => {
cancelResetOnContextClose()
showSearchBox(e)
}
}
triggerEvent = e
const afterRerouteId = firstLink.fromReroute?.id
const connectionOptions =
toType === 'input'
? { nodeFrom: node, slotFrom: fromSlot, afterRerouteId }
: { nodeTo: node, slotTo: fromSlot, afterRerouteId }
// 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 menu = canvas.showConnectionMenu({
...connectionOptions,
...commonOptions
})
linkReleaseMenu.value?.show(e)
}
function connectNodeFromMenu(nodeDef: ComfyNodeDefImpl) {
const node = connectNewNode(nodeDef)
if (node) canvasStore.getCanvas().selectNode(node)
reset()
}
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()
if (!menu) {
console.warn('No menu was returned from showConnectionMenu')
return
}
reset()
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')
const node: unknown = createEvent.detail?.node
if (!(node instanceof LGraphNode)) throw new Error('Invalid node')
disconnectOnReset = false
createEvent.preventDefault()
canvas.linkConnector.connectToNode(node, e)
},
options
)
// Reset when the context menu is closed
const cancelResetOnContextClose = useEventListener(
menu.controller.signal,
'abort',
reset,
options
)
}
// Disable litegraph's default behavior of release link and search box.
@@ -355,32 +333,25 @@ 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, cancelLinkRelease })
defineExpose({ showSearchBox })
</script>
<style>

View File

@@ -1,45 +0,0 @@
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)
})
})

View File

@@ -1,46 +0,0 @@
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
}

View File

@@ -1,317 +0,0 @@
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)
})
})

View File

@@ -1,264 +0,0 @@
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)
}

View File

@@ -142,9 +142,8 @@ const sourceCategoryFilters: Record<string, (n: ComfyNodeDefImpl) => boolean> =
[RootCategory.Custom]: isCustomNode
}
const { filters, defaultRootFilter = null } = defineProps<{
const { filters } = defineProps<{
filters: FuseFilterWithValue<ComfyNodeDefImpl, string>[]
defaultRootFilter?: RootCategoryId | null
}>()
const emit = defineEmits<{
@@ -196,7 +195,7 @@ function onSearchFocus() {
}
// Root filter from filter bar category buttons (radio toggle)
const rootFilter = ref<RootCategoryId | null>(defaultRootFilter)
const rootFilter = ref<RootCategoryId | null>(null)
const rootFilterLabel = computed(() => {
switch (rootFilter.value) {

View File

@@ -1,94 +1,105 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useCopy } from './useCopy'
import { describe, expect, it } from 'vitest'
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()
}
/**
* 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)))
)
}))
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
}
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))
/**
* 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))
return new TextDecoder().decode(bytes)
}
describe('useCopy', () => {
beforeEach(() => {
copyMocks.copyHandler = undefined
copyMocks.canvas.copyToClipboard.mockReset()
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)
})
it('should write large serialized node data to clipboard metadata', () => {
const serializedData = JSON.stringify({
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({
nodes: [
{
id: 1,
type: 'Subgraph',
title: 'Large Subgraph',
localized_name: '이미지 그룹 图像 🎨',
payload: 'x'.repeat(multiChunkPayloadLength)
type: 'LoadImage',
localized_name: '图像',
inputs: [{ localized_name: '图片', name: 'image' }],
outputs: [{ localized_name: '输出', name: 'output' }]
}
],
groups: [{ title: '预处理组 🔧' }],
reroutes: [],
links: [],
subgraphs: []
links: []
})
const encoded = encodeClipboardData(original)
const decoded = decodeClipboardData(encoded)
expect(decoded).toBe(original)
expect(JSON.parse(decoded)).toEqual(JSON.parse(original))
})
const dataTransfer = copySerializedData(serializedData)
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+/=]+$/)
})
expect(readSerializedClipboardMetadata(dataTransfer)).toBe(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()
})
})

View File

@@ -7,29 +7,6 @@ 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
@@ -46,16 +23,17 @@ export const useCopy = () => {
const canvas = canvasStore.canvas
if (canvas?.selectedItems) {
const serializedData = canvas.copyToClipboard()
try {
const base64Data = encodeClipboardData(serializedData)
// clearData doesn't remove images from clipboard
e.clipboardData?.setData(
'text/html',
clipboardHTMLWrapper.join(base64Data)
// Use TextEncoder to handle Unicode characters properly
const base64Data = btoa(
String.fromCharCode(
...Array.from(new TextEncoder().encode(serializedData))
)
} catch (error) {
console.error(error)
}
)
// clearData doesn't remove images from clipboard
e.clipboardData?.setData(
'text/html',
clipboardHTMLWrapper.join(base64Data)
)
e.preventDefault()
e.stopImmediatePropagation()
return false

View File

@@ -119,23 +119,6 @@ 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', {

View File

@@ -61,12 +61,18 @@ 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' || nodeData.name === 'Load3DAdvanced') {
if (nodeData.name === 'Load3D') {
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,

View File

@@ -410,8 +410,12 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
set read_only(value: boolean) {
const changed = this.state.readOnly !== value
this.state.readOnly = value
this._updateCursorStyle()
if (changed) {
this.dispatchEvent('litegraph:read-only-changed', { readOnly: value })
}
}
get isDragging(): boolean {
@@ -3972,7 +3976,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
if (this._previously_dragging_canvas === null) {
this._previously_dragging_canvas = this.dragging_canvas
}
this.dragging_canvas = this.pointer.isDown
this.dragging_canvas =
this.pointer.isDown || !!this.linkConnector.renderLinks.length
block_default = true
} else if (e.key === 'Escape') {
// esc
@@ -5194,7 +5199,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
private _drawConnectingLinks(ctx: CanvasRenderingContext2D): void {
const { linkConnector } = this
if (!linkConnector.isConnecting || linkConnector.renderLinksHidden) return
if (!linkConnector.isConnecting) return
const { renderLinks } = linkConnector
const highlightPos = this._getHighlightPosition()

View File

@@ -118,13 +118,6 @@ 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) {
@@ -1105,8 +1098,6 @@ export class LinkConnector {
const mayContinue = this.events.dispatch('reset', force)
if (mayContinue === false) return
this.renderLinksHidden = false
const {
state,
outputLinks,

View File

@@ -59,4 +59,9 @@ export interface LGraphCanvasEventMap {
active: boolean
nodeId: NodeId
}
/** The canvas read-only state has changed. */
'litegraph:read-only-changed': {
readOnly: boolean
}
}

View File

@@ -170,7 +170,6 @@ export type { TWidgetType, TWidgetValue, IWidgetOptions } from './types/widgets'
export {
findUsedSubgraphIds,
getDirectSubgraphIds,
isNodeSlot,
isSubgraphInput,
isSubgraphOutput
} from './subgraph/subgraphUtils'

View File

@@ -593,12 +593,6 @@
"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",
@@ -640,7 +634,8 @@
"Horizontal": "Horizontal",
"Vertical": "Vertical",
"new": "new",
"deprecated": "deprecated"
"deprecated": "deprecated",
"Extensions": "Extensions"
},
"icon": {
"bookmark": "Bookmark",
@@ -3632,10 +3627,6 @@
"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",

View File

@@ -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]/none font-semibold text-base-foreground"
class="text-[2rem] leading-none font-semibold text-base-foreground"
data-testid="credit-slider-price"
>
{{ formatUsd(displayMonthly) }}

View File

@@ -1394,7 +1394,7 @@ describe('errorMessageResolver', () => {
})
).toEqual({
catalogId: 'missing_node',
displayTitle: 'Missing Node Packs',
displayTitle: 'Missing Node Packs (1)',
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',
displayTitle: 'Unsupported Node Packs (1)',
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',
displayTitle: 'Swap Nodes (1)',
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',
displayTitle: 'Missing Models (1)',
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',
displayTitle: 'Missing Models (1)',
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',
displayTitle: 'Missing Inputs (1)',
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',
displayTitle: 'Missing Inputs (2)',
toastTitle: 'Missing media inputs',
toastMessage:
'Please select the missing media inputs before running this workflow.'

View File

@@ -6,6 +6,10 @@ 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
@@ -340,12 +344,15 @@ export function resolveMissingErrorMessage(
case 'missing_node':
return {
catalogId: 'missing_node',
displayTitle: source.isCloud
? st(
'rightSidePanel.missingNodePacks.unsupportedTitle',
'Unsupported Node Packs'
)
: st('rightSidePanel.missingNodePacks.title', 'Missing Node Packs'),
displayTitle: formatCountTitle(
source.isCloud
? st(
'rightSidePanel.missingNodePacks.unsupportedTitle',
'Unsupported Node Packs'
)
: st('rightSidePanel.missingNodePacks.title', 'Missing Node Packs'),
source.count
),
displayMessage: resolveMissingNodeDisplayMessage(source),
toastTitle: resolveMissingNodeToastTitle(source),
toastMessage: resolveMissingNodeToastMessage(source)
@@ -353,7 +360,10 @@ export function resolveMissingErrorMessage(
case 'swap_nodes':
return {
catalogId: 'swap_nodes',
displayTitle: st('nodeReplacement.swapNodesTitle', 'Swap Nodes'),
displayTitle: formatCountTitle(
st('nodeReplacement.swapNodesTitle', 'Swap Nodes'),
source.count
),
displayMessage: resolveSwapNodeDisplayMessage(),
toastTitle: resolveSwapNodeToastTitle(source),
toastMessage: resolveSwapNodeToastMessage(source)
@@ -361,9 +371,12 @@ export function resolveMissingErrorMessage(
case 'missing_model':
return {
catalogId: 'missing_model',
displayTitle: st(
'rightSidePanel.missingModels.missingModelsTitle',
'Missing Models'
displayTitle: formatCountTitle(
st(
'rightSidePanel.missingModels.missingModelsTitle',
'Missing Models'
),
source.count
),
displayMessage: resolveMissingModelDisplayMessage(source),
toastTitle: resolveMissingModelToastTitle(source),
@@ -372,9 +385,9 @@ export function resolveMissingErrorMessage(
case 'missing_media':
return {
catalogId: 'missing_media',
displayTitle: st(
'rightSidePanel.missingMedia.missingMediaTitle',
'Missing Inputs'
displayTitle: formatCountTitle(
st('rightSidePanel.missingMedia.missingMediaTitle', 'Missing Inputs'),
source.count
),
displayMessage: resolveMissingMediaDisplayMessage(),
toastTitle: resolveMissingMediaToastTitle(source),

View File

@@ -1,5 +1,5 @@
<template>
<div class="px-3">
<div class="px-4 pb-2">
<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-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"
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"
@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 focus-visible:ring-inset"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
:aria-label="
t('rightSidePanel.locateNodeFor', {
item: item.displayItemLabel

View File

@@ -1,9 +1,9 @@
<template>
<div class="px-3">
<div class="px-4 pb-2">
<div
v-if="importableModelRows.length > 0"
data-testid="missing-model-importable-rows"
class="flex flex-col gap-1 overflow-hidden"
class="flex flex-col gap-1 overflow-hidden py-2"
>
<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-secondary-background pt-3"
class="flex flex-col gap-1 border-t border-interface-stroke 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-md text-xs"
class="h-8 min-w-0 flex-1 rounded-lg text-sm"
@click="downloadAllModels"
>
<i aria-hidden="true" class="icon-[lucide--download] size-4 shrink-0" />

View File

@@ -12,27 +12,27 @@
: t('rightSidePanel.missingModels.expandNodes')
"
:aria-expanded="expanded"
class="h-8 w-4 shrink-0 p-0 hover:bg-transparent focus-visible:ring-inset"
:class="
cn(
'h-8 w-4 shrink-0 p-0 transition-transform duration-200 hover:bg-transparent',
expanded && 'rotate-90'
)
"
@click="handleToggleExpand"
>
<i
aria-hidden="true"
:class="
cn(
'icon-[lucide--chevron-right] size-4 text-muted-foreground transition-transform duration-200',
expanded && 'rotate-90'
)
"
class="icon-[lucide--chevron-right] size-4 text-muted-foreground"
/>
</Button>
<span class="flex min-w-0 flex-1 flex-col gap-0">
<span class="flex min-w-0 items-center gap-1 text-xs/tight">
<span class="block min-w-0 text-sm/tight">
<button
v-if="hasModelLabelControl"
ref="modelLabelControl"
type="button"
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"
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"
:title="displayModelName"
@click="handleModelLabelClick"
>
@@ -40,7 +40,7 @@
</button>
<span
v-else
class="min-w-0 font-normal wrap-break-word text-base-foreground"
class="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="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"
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"
>
{{ model.referencingNodes.length }}
</span>
<Button
variant="textonly"
size="icon-sm"
class="size-6 shrink-0 text-muted-foreground hover:bg-transparent hover:text-base-foreground focus-visible:ring-inset"
class="ml-2 inline-flex size-7 shrink-0 align-middle text-muted-foreground hover:bg-transparent hover:text-base-foreground"
:aria-label="linkLabel"
:title="linkLabel"
@click="copyModelLink"
@@ -82,7 +82,7 @@
data-testid="missing-model-import"
variant="secondary"
size="sm"
class="shrink-0 focus-visible:ring-inset"
class="h-8 shrink-0 rounded-lg text-sm"
@click="showUploadDialog"
>
{{ t('g.import') }}
@@ -123,7 +123,7 @@
data-testid="missing-model-download"
variant="secondary"
size="sm"
class="shrink-0 focus-visible:ring-inset"
class="h-8 shrink-0 rounded-lg text-sm"
: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 focus-visible:ring-inset"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
@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 p-0',
'm-0 list-none space-y-0.5 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-8 min-w-0 items-center gap-2">
<div class="flex min-h-6 min-w-0 items-center gap-2">
<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-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"
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"
@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-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
class="ml-auto size-6 shrink-0 text-muted-foreground hover:text-base-foreground"
@click="emit('locateModel', String(ref.nodeId))"
>
<i aria-hidden="true" class="icon-[lucide--locate] size-4" />

View File

@@ -3,26 +3,21 @@
<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"
:aria-label="
expanded
? t('rightSidePanel.missingNodePacks.collapse', 'Collapse')
: t('rightSidePanel.missingNodePacks.expand', 'Expand')
:class="
cn(
'h-8 w-4 shrink-0 p-0 transition-transform duration-200 hover:bg-transparent',
expanded && 'rotate-90'
)
"
:aria-expanded="expanded"
class="h-8 w-4 shrink-0 p-0 hover:bg-transparent focus-visible:ring-inset"
aria-hidden="true"
tabindex="-1"
@click="toggleExpand"
>
<i
aria-hidden="true"
:class="
cn(
'icon-[lucide--chevron-right] size-4 text-muted-foreground transition-transform duration-200',
expanded && 'rotate-90'
)
"
class="icon-[lucide--chevron-right] size-4 text-muted-foreground"
/>
</Button>
@@ -32,7 +27,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-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"
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"
:title="group.type"
:aria-label="titleToggleAriaLabel"
:aria-expanded="expanded"
@@ -43,7 +38,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-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"
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"
:title="group.type"
@click="handleLocateNode(primaryLocatableNodeType)"
>
@@ -51,7 +46,7 @@
</button>
<span
v-else
class="min-w-0 truncate text-xs/relaxed font-normal text-base-foreground"
class="min-w-0 truncate text-sm/relaxed font-normal text-base-foreground"
:title="group.type"
>
{{ group.type }}
@@ -60,7 +55,7 @@
v-if="hasMultipleNodeTypes"
data-testid="swap-node-group-count"
role="img"
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"
class="flex size-6 shrink-0 items-center justify-center rounded-md bg-secondary-background-selected text-xs font-bold text-muted-foreground"
:aria-label="t('g.nodesCount', group.nodeTypes.length)"
>
{{ group.nodeTypes.length }}
@@ -85,7 +80,7 @@
<Button
variant="secondary"
size="sm"
class="shrink-0 focus-visible:ring-inset"
class="h-8 shrink-0 rounded-lg text-sm"
@click="handleReplaceNode"
>
<i
@@ -101,7 +96,7 @@
v-if="primaryLocatableNodeType"
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
:aria-label="locateNodeLabel"
@click="handleLocateNode(primaryLocatableNodeType)"
>
@@ -121,14 +116,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-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"
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"
@click="handleLocateNode(nodeType)"
>
{{ getLabel(nodeType) }}
</button>
<span
v-else
class="text-xs/relaxed wrap-break-word text-muted-foreground"
class="text-sm/relaxed wrap-break-word text-muted-foreground"
>
{{ getLabel(nodeType) }}
</span>
@@ -137,7 +132,7 @@
v-if="isLocatableNodeType(nodeType)"
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
:aria-label="locateNodeLabel"
@click="handleLocateNode(nodeType)"
>

View File

@@ -1,5 +1,5 @@
<template>
<div class="px-3">
<div class="mt-2 px-4 pb-2">
<SwapNodeGroupRow
v-for="group in swapNodeGroups"
:key="group.type"

View File

@@ -1,5 +1,5 @@
<template>
<BaseModalLayout content-title="" data-testid="settings-dialog" size="full">
<BaseModalLayout content-title="" data-testid="settings-dialog" size="sm">
<template #leftPanelHeaderTitle>
<i class="icon-[lucide--settings]" />
<h2 class="text-neutral text-base">{{ $t('g.settings') }}</h2>

View File

@@ -53,16 +53,13 @@ describe('useSettingsDialog', () => {
isCloudRef.value = false
})
it("show() opens the Reka renderer with size 'full' and 1280px content sizing", () => {
it("show() opens the Reka renderer with size 'full' and 960px 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-[1280px]')
expect(args.dialogComponentProps.contentClass).not.toContain(
'max-w-[960px]'
)
expect(args.dialogComponentProps.contentClass).toContain('max-w-[960px]')
expect(args.dialogComponentProps.contentClass).toContain('h-[80vh]')
})

View File

@@ -8,9 +8,8 @@ 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-[1280px] sm:max-w-[1280px] h-[80vh] max-h-none rounded-2xl overflow-hidden'
'w-[90vw] max-w-[960px] sm:max-w-[960px] h-[80vh] max-h-none rounded-2xl overflow-hidden'
export function useSettingsDialog() {
const dialogService = useDialogService()

View File

@@ -1,8 +1,10 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
vi.mock('@/composables/useAppMode', () => ({
@@ -36,6 +38,30 @@ vi.mock('@/scripts/app', () => ({
}
}))
vi.mock('@vueuse/core', async (importOriginal) => {
const actual = await importOriginal()
return {
...(actual as Record<string, unknown>),
useEventListener: vi.fn(
(
target: EventTarget,
event: string,
handler: EventListenerOrEventListenerObject
) => {
target.addEventListener(event, handler)
return () => target.removeEventListener(event, handler)
}
)
}
})
function createMockCanvas(readOnly = false): LGraphCanvas {
return {
read_only: readOnly,
canvas: document.createElement('canvas')
} as unknown as LGraphCanvas
}
describe('useCanvasStore', () => {
let store: ReturnType<typeof useCanvasStore>
@@ -90,4 +116,42 @@ describe('useCanvasStore', () => {
expect(store.selectedNodeIds).toHaveLength(0)
})
describe('isReadOnly', () => {
it('syncs initial read_only value when canvas is set', async () => {
const mockCanvas = createMockCanvas(true)
store.canvas = mockCanvas as unknown as LGraphCanvas
await nextTick()
expect(store.isReadOnly).toBe(true)
})
it('updates isReadOnly when litegraph:read-only-changed event fires', async () => {
const mockCanvas = createMockCanvas(false)
store.canvas = mockCanvas as unknown as LGraphCanvas
await nextTick()
expect(store.isReadOnly).toBe(false)
// Simulate space key press → LGraphCanvas sets read_only = true
mockCanvas.canvas.dispatchEvent(
new CustomEvent('litegraph:read-only-changed', {
detail: { readOnly: true }
})
)
expect(store.isReadOnly).toBe(true)
// Simulate space key release
mockCanvas.canvas.dispatchEvent(
new CustomEvent('litegraph:read-only-changed', {
detail: { readOnly: false }
})
)
expect(store.isReadOnly).toBe(false)
})
})
})

View File

@@ -56,6 +56,7 @@ export const useCanvasStore = defineStore('canvas', () => {
setMode(val ? 'app' : 'graph')
}
})
const isReadOnly = ref(false)
// Set up scale synchronization when canvas is available
let originalOnChanged: ((scale: number, offset: Point) => void) | undefined =
@@ -131,6 +132,16 @@ export const useCanvasStore = defineStore('canvas', () => {
whenever(
() => canvas.value,
(newCanvas) => {
isReadOnly.value = newCanvas.read_only
useEventListener(
newCanvas.canvas,
'litegraph:read-only-changed',
(event: CustomEvent<{ readOnly: boolean }>) => {
isReadOnly.value = event.detail.readOnly
}
)
useEventListener(
newCanvas.canvas,
'litegraph:set-graph',
@@ -176,6 +187,7 @@ export const useCanvasStore = defineStore('canvas', () => {
rerouteSelected,
appScalePercentage,
linearMode,
isReadOnly,
updateSelectedItems,
getCanvas,
setAppZoomFromPercentage,

View File

@@ -13,7 +13,8 @@ vi.mock('@/renderer/core/canvas/canvasStore', () => {
return {
useCanvasStore: vi.fn(() => ({
getCanvas,
setCursorStyle
setCursorStyle,
isReadOnly: false
}))
}
})

View File

@@ -27,9 +27,7 @@ export function useCanvasInteractions() {
* Whether Vue node components should handle pointer events.
* Returns false when canvas is in read-only/panning mode (e.g., space key held for panning).
*/
const shouldHandleNodePointerEvents = computed(
() => !(canvasStore.canvas?.read_only ?? false)
)
const shouldHandleNodePointerEvents = computed(() => !canvasStore.isReadOnly)
/**
* Returns true if the wheel event target is inside an element that should

View File

@@ -52,6 +52,10 @@ vi.mock('@/renderer/core/canvas/useAutoPan', () => ({
}
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({ isReadOnly: false })
}))
vi.mock('@/scripts/app', () => ({
app: {
canvas: {
@@ -187,13 +191,9 @@ vi.mock('@/lib/litegraph/src/LLink', () => ({
LLink: { getReroutes: () => [] }
}))
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('@/lib/litegraph/src/types/globalEnums', () => ({
LinkDirection: { LEFT: 0, RIGHT: 1, NONE: -1 }
}))
vi.mock('@/utils/rafBatch', () => ({
createRafBatch: (fn: () => void) => ({

View File

@@ -1,229 +0,0 @@
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()
})
})

View File

@@ -23,6 +23,7 @@ import {
resolveNodeSurfaceSlotCandidate,
resolveSlotTargetCandidate
} from '@/renderer/core/canvas/links/linkDropOrchestrator'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useSlotLinkDragUIState } from '@/renderer/core/canvas/links/slotLinkDragUIState'
import type { SlotDropCandidate } from '@/renderer/core/canvas/links/slotLinkDragUIState'
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
@@ -32,7 +33,6 @@ 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 {
@@ -123,6 +123,7 @@ export function useSlotLinkInteraction({
setCompatibleForKey,
clearCompatible
} = useSlotLinkDragUIState()
const canvasStore = useCanvasStore()
const conversion = useSharedCanvasPositionConversion()
const pointerSession = createPointerSession()
let activeAdapter: LinkConnectorAdapter | null = null
@@ -415,9 +416,11 @@ export function useSlotLinkInteraction({
const canvas = app.canvas
const node = canvas.graph?.getNodeById(nodeId)
const handlePointerMove = (event: PointerEvent) => {
if (!pointerSession.matches(event)) return
if (!pointerSession.matches(event) || canvasStore.isReadOnly) return
event.stopPropagation()
app.canvas.last_mouse = [event.clientX, event.clientY]
autoPan?.updatePointer(event.clientX, event.clientY)
if (canvas.subgraph && node) {
@@ -606,13 +609,6 @@ 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()

View File

@@ -199,6 +199,19 @@ function useNodeDragIndividual() {
if (!dragStartPos || !dragStartMouse) {
return
}
if (canvasStore.isReadOnly) {
const canvas = canvasStore.getCanvas()
const delta = [event.clientX - lastPointerX, event.clientY - lastPointerY]
canvas.ds.offset[0] += delta[0] / canvas.ds.scale
canvas.ds.offset[1] += delta[1] / canvas.ds.scale
canvas.setDirty(true, true)
lastPointerX = event.clientX
lastPointerY = event.clientY
dragStartMouse.x += delta[0]
dragStartMouse.y += delta[1]
return
}
// Throttle position updates using requestAnimationFrame for better performance
if (rafId !== null) return // Skip if frame already scheduled

View File

@@ -21,7 +21,6 @@ import {
SubgraphNode,
createBounds
} from '@/lib/litegraph/src/litegraph'
import { overlapBounding } from '@/lib/litegraph/src/measure'
import type {
CreateNodeOptions,
GraphAddOptions,
@@ -944,40 +943,10 @@ 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

View File

@@ -193,14 +193,13 @@ export const useAppModeStore = defineStore('appMode', () => {
let unwatchReadOnly: (() => void) | undefined
function enforceReadOnly(inSelect: boolean) {
const { state } = getCanvas()
if (!state) return
state.readOnly = inSelect
const canvas = getCanvas()
canvas.read_only = inSelect
unwatchReadOnly?.()
if (inSelect)
unwatchReadOnly = watch(
() => state.readOnly,
() => (state.readOnly = true)
() => canvas.read_only,
() => (canvas.read_only = true)
)
}

View File

@@ -1,5 +1,4 @@
import { useTimeoutFn } from '@vueuse/core'
import { mapKeys } from 'es-toolkit'
import { defineStore } from 'pinia'
import { ref } from 'vue'
@@ -359,12 +358,8 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
function restoreOutputs(
outputs: Record<string, ExecutedWsMessage['output']>
) {
const parsedOutputs = mapKeys(
outputs,
(_, id) => executionIdToNodeLocatorId(app.rootGraph, id) ?? id
)
app.nodeOutputs = parsedOutputs
nodeOutputs.value = { ...parsedOutputs }
app.nodeOutputs = outputs
nodeOutputs.value = { ...outputs }
}
function updateNodeImages(node: LGraphNode) {

View File

@@ -20,7 +20,7 @@ vi.mock('@/platform/settings/settingStore', () => ({
}))
function createMockPopover(): InstanceType<typeof NodeSearchBoxPopover> {
return { showSearchBox: vi.fn(), cancelLinkRelease: vi.fn() } as Partial<
return { showSearchBox: vi.fn() } as Partial<
InstanceType<typeof NodeSearchBoxPopover>
> as InstanceType<typeof NodeSearchBoxPopover>
}
@@ -135,23 +135,4 @@ 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()
})
})
})

View File

@@ -28,10 +28,6 @@ export const useSearchBoxStore = defineStore('searchBox', () => {
popoverRef.value = popover
}
function cancelLinkRelease() {
popoverRef.value?.cancelLinkRelease()
}
const visible = ref(false)
function toggleVisible() {
if (newSearchBoxEnabled.value) {
@@ -53,7 +49,6 @@ export const useSearchBoxStore = defineStore('searchBox', () => {
useSearchBoxV2,
newSearchBoxEnabled,
setPopoverRef,
cancelLinkRelease,
toggleVisible,
visible
}

View File

@@ -1,39 +0,0 @@
{
"$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"
}
]
}