Compare commits

..

1 Commits

Author SHA1 Message Date
coderabbitai[bot]
fc11de54fa CodeRabbit Generated Unit Tests: Add unit tests 2026-06-12 15:27:11 +00:00
80 changed files with 1185 additions and 3117 deletions

View File

@@ -15,9 +15,7 @@ test.describe('Download page @smoke', () => {
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle(
'Download Comfy Desktop — Run AI on Your Hardware'
)
await expect(page).toHaveTitle('Download Comfy — Run AI Locally')
})
test('CloudBannerSection is visible with cloud link', async ({ page }) => {

View File

@@ -51,6 +51,20 @@ export class FeatureFlagHelper {
})
}
async setServerFlags(flags: Record<string, unknown>): Promise<void> {
await this.page.evaluate((flagMap: Record<string, unknown>) => {
const api = window.app!.api
api.serverFeatureFlags.value = {
...api.serverFeatureFlags.value,
...flagMap
}
}, flags)
}
async setServerFlag(name: string, value: unknown): Promise<void> {
await this.setServerFlags({ [name]: value })
}
/**
* Mock server feature flags via route interception on /api/features.
*/

View File

@@ -309,6 +309,50 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => {
)
})
test.describe('Empty graph defaults', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.featureFlags.setServerFlag(
'node_library_essentials_enabled',
true
)
})
test('Defaults to Essentials when graph is empty', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
await comfyPage.nodeOps.clearGraph()
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
await searchBoxV2.open()
const essentialsBtn = searchBoxV2.rootCategoryButton(
RootCategory.Essentials
)
await expect(essentialsBtn).toBeVisible()
await expect(essentialsBtn).toHaveAttribute('aria-pressed', 'true')
})
test('Defaults to Most Relevant when graph has nodes', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBeGreaterThan(0)
await searchBoxV2.open()
await expect(searchBoxV2.categoryButton('most-relevant')).toHaveAttribute(
'aria-current',
'true'
)
await expect(
searchBoxV2.rootCategoryButton(RootCategory.Essentials)
).toHaveAttribute('aria-pressed', 'false')
})
})
test.describe('Search behavior', () => {
test('Search narrows results progressively', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage

View File

@@ -87,14 +87,6 @@ vi.mock('@/scripts/app', () => ({
}
}))
const mockTrackUiButtonClicked = vi.hoisted(() => vi.fn())
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({
trackUiButtonClicked: mockTrackUiButtonClicked
})
}))
type WrapperOptions = {
pinia?: ReturnType<typeof createTestingPinia>
stubs?: Record<string, boolean | Component>
@@ -118,9 +110,6 @@ function createWrapper({
activeJobsShort: '{count} active | {count} active',
clearQueueTooltip: 'Clear queue'
}
},
rightSidePanel: {
togglePanel: 'Toggle properties panel'
}
}
}
@@ -277,19 +266,6 @@ describe('TopMenuSection', () => {
expect(screen.queryByTestId('active-jobs-indicator')).toBeNull()
})
it('tracks right side panel opens', async () => {
const { user } = createWrapper()
await user.click(
screen.getByRole('button', { name: 'Toggle properties panel' })
)
expect(mockTrackUiButtonClicked).toHaveBeenCalledWith({
button_id: 'right_side_panel_opened',
element_group: 'top_menu'
})
})
it('hides queue progress overlay when QPO V2 is enabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn })
const settingStore = useSettingStore(pinia)

View File

@@ -78,7 +78,7 @@
variant="secondary"
size="icon"
:aria-label="t('rightSidePanel.togglePanel')"
@click="openRightSidePanel"
@click="rightSidePanelStore.togglePanel"
>
<i class="icon-[lucide--panel-right] size-4" />
</Button>
@@ -148,7 +148,6 @@ import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useActionBarButtonStore } from '@/stores/actionBarButtonStore'
@@ -283,14 +282,6 @@ const rightSidePanelTooltipConfig = computed(() =>
buildTooltipConfig(t('rightSidePanel.togglePanel'))
)
function openRightSidePanel() {
useTelemetry()?.trackUiButtonClicked({
button_id: 'right_side_panel_opened',
element_group: 'top_menu'
})
rightSidePanelStore.togglePanel()
}
// Maintain support for legacy topbar elements attached by custom scripts
const legacyCommandsContainerRef = ref<HTMLElement>()
const hasLegacyContent = ref(false)

View File

@@ -222,8 +222,7 @@ watch(visible, async (newVisible) => {
*/
useEventListener(dragHandleRef, 'mousedown', () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'actionbar_run_handle_drag_start',
element_group: 'actionbar'
button_id: 'actionbar_run_handle_drag_start'
})
})

View File

@@ -131,8 +131,7 @@ const queueModeMenuItemLookup = computed<Record<string, QueueModeMenuItem>>(
tooltip: t('menu.onChangeTooltip'),
command: () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'queue_mode_option_run_on_change_selected',
element_group: 'queue'
button_id: 'queue_mode_option_run_on_change_selected'
})
queueMode.value = 'change'
}
@@ -146,8 +145,7 @@ const queueModeMenuItemLookup = computed<Record<string, QueueModeMenuItem>>(
tooltip: t('menu.instantTooltip'),
command: () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'queue_mode_option_run_instant_selected',
element_group: 'queue'
button_id: 'queue_mode_option_run_instant_selected'
})
queueMode.value = 'instant-idle'
}
@@ -239,8 +237,7 @@ const queuePrompt = async (e: Event) => {
if (batchCount.value > 1) {
useTelemetry()?.trackUiButtonClicked({
button_id: 'queue_run_multiple_batches_submitted',
element_group: 'queue'
button_id: 'queue_run_multiple_batches_submitted'
})
}

View File

@@ -88,8 +88,7 @@ const home = computed(() => ({
isBlueprint: isBlueprint.value,
command: () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'breadcrumb_subgraph_root_selected',
element_group: 'breadcrumb'
button_id: 'breadcrumb_subgraph_root_selected'
})
const canvas = useCanvasStore().getCanvas()
if (!canvas.graph) throw new TypeError('Canvas has no graph')
@@ -104,8 +103,7 @@ const items = computed(() => {
key: `subgraph-${subgraph.id}`,
command: () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'breadcrumb_subgraph_item_selected',
element_group: 'breadcrumb'
button_id: 'breadcrumb_subgraph_item_selected'
})
const canvas = useCanvasStore().getCanvas()
if (!canvas.graph) throw new TypeError('Canvas has no graph')

View File

@@ -40,8 +40,7 @@ function handleOpen(open: boolean) {
if (open) {
markAsSeen()
useTelemetry()?.trackUiButtonClicked({
button_id: source,
element_group: 'workflow_actions'
button_id: source
})
}
}

View File

@@ -101,8 +101,7 @@ const reportOpen = ref(false)
*/
const showReport = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'error_dialog_show_report_clicked',
element_group: 'error_dialog'
button_id: 'error_dialog_show_report_clicked'
})
reportOpen.value = true
}

View File

@@ -25,8 +25,7 @@ const queryString = computed(() => props.errorMessage + ' is:issue')
function openGitHubIssues() {
useTelemetry()?.trackUiButtonClicked({
button_id: 'error_dialog_find_existing_issues_clicked',
element_group: 'error_dialog'
button_id: 'error_dialog_find_existing_issues_clicked'
})
const query = encodeURIComponent(queryString.value)
const url = `https://github.com/${props.repoOwner}/${props.repoName}/issues?q=${query}`

View File

@@ -218,8 +218,7 @@ onMounted(() => {
*/
const onMinimapToggleClick = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'graph_menu_minimap_toggle_clicked',
element_group: 'graph_menu'
button_id: 'graph_menu_minimap_toggle_clicked'
})
void commandStore.execute('Comfy.Canvas.ToggleMinimap')
}
@@ -229,8 +228,7 @@ const onMinimapToggleClick = () => {
*/
const onLinkVisibilityToggleClick = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'graph_menu_hide_links_toggle_clicked',
element_group: 'graph_menu'
button_id: 'graph_menu_hide_links_toggle_clicked'
})
void commandStore.execute('Comfy.Canvas.ToggleLinkVisibility')
}

View File

@@ -65,8 +65,7 @@ describe('InfoButton', () => {
expect(openNodeInfoMock).toHaveBeenCalled()
expect(trackUiButtonClickedMock).toHaveBeenCalledWith({
button_id: 'selection_toolbox_node_info_opened',
element_group: 'selection_toolbox'
button_id: 'selection_toolbox_node_info_opened'
})
})

View File

@@ -24,8 +24,7 @@ const onInfoClick = () => {
if (!openNodeInfo()) return
useTelemetry()?.trackUiButtonClicked({
button_id: 'selection_toolbox_node_info_opened',
element_group: 'selection_toolbox'
button_id: 'selection_toolbox_node_info_opened'
})
}
</script>

View File

@@ -14,7 +14,6 @@ import { getActiveGraphNodeIds } from '@/utils/graphTraversalUtil'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
@@ -107,10 +106,6 @@ const isSingleSubgraphNode = computed(() => {
})
function closePanel() {
useTelemetry()?.trackUiButtonClicked({
button_id: 'right_side_panel_closed',
element_group: 'right_side_panel'
})
rightSidePanelStore.closePanel()
}

View File

@@ -58,8 +58,7 @@ describe('useErrorActions', () => {
openGitHubIssues()
expect(mocks.trackUiButtonClicked).toHaveBeenCalledWith({
button_id: 'error_tab_github_issues_clicked',
element_group: 'errors_panel'
button_id: 'error_tab_github_issues_clicked'
})
expect(windowOpenSpy).toHaveBeenCalledWith(
mocks.staticUrls.githubIssues,
@@ -124,8 +123,7 @@ describe('useErrorActions', () => {
findOnGitHub('CUDA out of memory')
expect(mocks.trackUiButtonClicked).toHaveBeenCalledWith({
button_id: 'error_tab_find_existing_issues_clicked',
element_group: 'errors_panel'
button_id: 'error_tab_find_existing_issues_clicked'
})
const expectedQuery = encodeURIComponent('CUDA out of memory is:issue')
expect(windowOpenSpy).toHaveBeenCalledWith(

View File

@@ -9,8 +9,7 @@ export function useErrorActions() {
function openGitHubIssues() {
telemetry?.trackUiButtonClicked({
button_id: 'error_tab_github_issues_clicked',
element_group: 'errors_panel'
button_id: 'error_tab_github_issues_clicked'
})
window.open(staticUrls.githubIssues, '_blank', 'noopener,noreferrer')
}
@@ -26,8 +25,7 @@ export function useErrorActions() {
function findOnGitHub(errorMessage: string) {
telemetry?.trackUiButtonClicked({
button_id: 'error_tab_find_existing_issues_clicked',
element_group: 'errors_panel'
button_id: 'error_tab_find_existing_issues_clicked'
})
const query = encodeURIComponent(errorMessage + ' is:issue')
window.open(

View File

@@ -1,103 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { 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><slot /></div>' },
DropdownMenuLabel: { template: '<div><slot /></div>' },
DropdownMenuItem: { template: '<div role="menuitem"><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', () => {
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)
})
})

View File

@@ -1,379 +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"
@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="p-.5 shrink-0">
<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"
: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 = window.innerWidth - MENU_WIDTH - VIEWPORT_MARGIN
position.value = {
x: Math.min(event.clientX, Math.max(VIEWPORT_MARGIN, maxX)),
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,67 +0,0 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it } from 'vitest'
import { nextTick } 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 stubs = {
DropdownMenuSub: { template: '<div><slot /></div>' },
DropdownMenuSubTrigger: {
template: '<button data-testid="sub-trigger"><slot /></button>'
},
DropdownMenuPortal: { template: '<div><slot /></div>' },
DropdownMenuSubContent: { template: '<div role="menu"><slot /></div>' },
DropdownMenuSeparator: { template: '<hr />' },
DropdownMenuItem: { template: '<div role="menuitem"><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()
})
})

View File

@@ -1,239 +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="p-.5 shrink-0">
<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"
: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,150 +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' })
})
})

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

@@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, defineComponent, nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
import { CORE_SETTINGS } from '@/platform/settings/constants/coreSettings'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import type { Settings } from '@/schemas/apiSchema'
@@ -12,8 +13,6 @@ 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 +54,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',
@@ -76,7 +74,8 @@ describe('NodeSearchBoxPopover', () => {
const NodeSearchContentStub = defineComponent({
name: 'NodeSearchContent',
props: {
filters: { type: Array, default: () => [] }
filters: { type: Array, default: () => [] },
defaultRootFilter: { type: String, default: null }
},
emits: ['addFilter', 'removeFilter', 'addNode', 'hoverNode'],
setup(_, { emit }) {
@@ -84,18 +83,8 @@ describe('NodeSearchBoxPopover', () => {
emit('addNode', nodeDef, dragEvent)
return {}
},
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" />'
template:
'<div data-testid="search-content-v2" :data-default-root-filter="defaultRootFilter"></div>'
})
const pinia = createTestingPinia({
@@ -115,7 +104,6 @@ describe('NodeSearchBoxPopover', () => {
stubs: {
NodeSearchBox: NodeSearchBoxStub,
NodeSearchContent: NodeSearchContentStub,
LinkReleaseContextMenu: LinkReleaseContextMenuStub,
NodePreviewCard: true,
Dialog: {
template: '<div><slot name="container" /></div>',
@@ -139,11 +127,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
}
}
}
@@ -299,53 +282,6 @@ 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()

View File

@@ -27,6 +27,7 @@
<div v-if="useSearchBoxV2" role="search" class="relative">
<NodeSearchContent
:filters="nodeFilters"
:default-root-filter="defaultRootFilter"
@add-filter="addFilter"
@remove-filter="removeFilter"
@add-node="addNode"
@@ -51,13 +52,6 @@
/>
</template>
</Dialog>
<LinkReleaseContextMenu
ref="linkReleaseMenu"
:context="linkReleaseContext"
@select-node="connectNodeFromMenu"
@add-reroute="addRerouteFromMenu"
@dismiss="reset"
/>
</div>
</template>
@@ -69,11 +63,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'
@@ -88,18 +78,20 @@ import { LinkReleaseTriggerAction } from '@/types/searchBoxTypes'
import type { FuseFilterWithValue } from '@/utils/fuseUtil'
import NodePreviewCard from '@/components/node/NodePreviewCard.vue'
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
import type { RootCategoryId } from '@/components/searchbox/v2/rootCategories'
import LinkReleaseContextMenu from './LinkReleaseContextMenu.vue'
import type { LinkReleaseContext } from './linkReleaseMenuModel'
import NodeSearchContent from './v2/NodeSearchContent.vue'
import NodeSearchBox from './NodeSearchBox.vue'
let triggerEvent: CanvasPointerEvent | null = null
let listenerController: AbortController | null = null
let disconnectOnReset = false
const settingStore = useSettingStore()
const searchBoxStore = useSearchBoxStore()
const litegraphService = useLitegraphService()
const canvasStore = useCanvasStore()
const { trackFeatureUsed } = useSurveyFeatureTracking('node-search')
const { visible, newSearchBoxEnabled, useSearchBoxV2 } =
@@ -115,8 +107,13 @@ 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)
const defaultRootFilter = ref<RootCategoryId | null>(null)
watch(visible, (isVisible) => {
if (!isVisible) return
defaultRootFilter.value = !canvasStore.canvas?.graph?.nodes?.length
? RootCategory.Essentials
: null
})
function getNewNodeLocation(): Point {
return triggerEvent
? [triggerEvent.canvasX, triggerEvent.canvasY]
@@ -141,21 +138,17 @@ function clearFilters() {
function closeDialog() {
visible.value = false
}
const canvasStore = useCanvasStore()
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)
@@ -167,16 +160,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)
}
@@ -229,46 +212,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.
@@ -344,32 +343,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 = viewportHeight - margin - menuHeight
return Math.min(Math.max(margin, menuTopAtCursor), maxMenuTop)
}

View File

@@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import NodeSearchContent from '@/components/searchbox/v2/NodeSearchContent.vue'
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
import {
createMockNodeDef,
setViewport,
@@ -230,6 +231,48 @@ describe('NodeSearchContent', () => {
})
})
it('should apply defaultRootFilter when provided and category is available', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'EssentialNode',
display_name: 'Essential Node',
essentials_category: 'basic'
}),
createMockNodeDef({
name: 'RegularNode',
display_name: 'Regular Node'
})
])
renderComponent({ defaultRootFilter: RootCategory.Essentials })
await waitFor(() => {
const items = screen.getAllByTestId('node-item')
expect(items).toHaveLength(1)
expect(items[0]).toHaveTextContent('Essential Node')
})
})
it('should ignore defaultRootFilter of Essentials when no essentials exist', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'FrequentNode',
display_name: 'Frequent Node'
})
])
vi.spyOn(useNodeFrequencyStore(), 'topNodeDefs', 'get').mockReturnValue([
useNodeDefStore().nodeDefsByName['FrequentNode']
])
renderComponent({ defaultRootFilter: RootCategory.Essentials })
await waitFor(() => {
const items = screen.getAllByTestId('node-item')
expect(items).toHaveLength(1)
expect(items[0]).toHaveTextContent('Frequent Node')
})
})
it('should show only API nodes when Partner Nodes filter is active', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({

View File

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

View File

@@ -150,8 +150,7 @@ const telemetry = useTelemetry()
function onLogoMenuClick(event: MouseEvent) {
telemetry?.trackUiButtonClicked({
button_id: 'sidebar_comfy_menu_opened',
element_group: 'sidebar'
button_id: 'sidebar_comfy_menu_opened'
})
menuRef.value?.toggle(event)
}
@@ -218,8 +217,7 @@ const extraMenuItems = computed(() => [
icon: 'icon-[lucide--settings]',
command: () => {
telemetry?.trackUiButtonClicked({
button_id: 'sidebar_settings_menu_opened',
element_group: 'sidebar'
button_id: 'sidebar_settings_menu_opened'
})
showSettings()
}
@@ -331,8 +329,7 @@ const handleNodes2ToggleClick = () => {
const onNodes2ToggleChange = async (value: boolean) => {
await settingStore.set('Comfy.VueNodes.Enabled', value)
telemetry?.trackUiButtonClicked({
button_id: `menu_nodes_2.0_toggle_${value ? 'enabled' : 'disabled'}`,
element_group: 'sidebar'
button_id: `menu_nodes_2.0_toggle_${value ? 'enabled' : 'disabled'}`
})
}
</script>

View File

@@ -138,23 +138,19 @@ const onTabClick = async (item: SidebarTabExtension) => {
if (isNodeLibraryTab)
telemetry?.trackUiButtonClicked({
button_id: 'sidebar_tab_node_library_selected',
element_group: 'sidebar'
button_id: 'sidebar_tab_node_library_selected'
})
else if (isModelLibraryTab)
telemetry?.trackUiButtonClicked({
button_id: 'sidebar_tab_model_library_selected',
element_group: 'sidebar'
button_id: 'sidebar_tab_model_library_selected'
})
else if (isWorkflowsTab)
telemetry?.trackUiButtonClicked({
button_id: 'sidebar_tab_workflows_selected',
element_group: 'sidebar'
button_id: 'sidebar_tab_workflows_selected'
})
else if (isAssetsTab)
telemetry?.trackUiButtonClicked({
button_id: 'sidebar_tab_assets_media_selected',
element_group: 'sidebar'
button_id: 'sidebar_tab_assets_media_selected'
})
await commandStore.commands

View File

@@ -21,8 +21,7 @@ const bottomPanelStore = useBottomPanelStore()
*/
const toggleConsole = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'sidebar_bottom_panel_console_toggled',
element_group: 'sidebar'
button_id: 'sidebar_bottom_panel_console_toggled'
})
bottomPanelStore.toggleBottomPanel()
}

View File

@@ -30,8 +30,7 @@ const tooltipText = computed(
const showSettingsDialog = () => {
command.function()
useTelemetry()?.trackUiButtonClicked({
button_id: 'sidebar_settings_button_clicked',
element_group: 'sidebar'
button_id: 'sidebar_settings_button_clicked'
})
}
</script>

View File

@@ -37,8 +37,7 @@ const tooltipText = computed(
*/
const toggleShortcutsPanel = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'sidebar_shortcuts_panel_toggled',
element_group: 'sidebar'
button_id: 'sidebar_shortcuts_panel_toggled'
})
bottomPanelStore.togglePanel('shortcuts')
}

View File

@@ -29,8 +29,7 @@ const isSmall = computed(
*/
const openTemplates = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'sidebar_templates_dialog_opened',
element_group: 'sidebar'
button_id: 'sidebar_templates_dialog_opened'
})
useWorkflowTemplateSelectorDialog().show('sidebar')
}

View File

@@ -118,8 +118,7 @@ const toggleBookmark = async () => {
const onHelpClick = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'node_library_help_button',
element_group: 'node_library'
button_id: 'node_library_help_button'
})
props.openNodeHelp(nodeDef.value)
}

View File

@@ -9,26 +9,16 @@ export type AppMode =
| 'builder:outputs'
| 'builder:arrange'
type WorkflowModeSource = {
activeMode: AppMode | null
initialMode: AppMode | null | undefined
}
export function getWorkflowMode(
workflow: WorkflowModeSource | null | undefined
): AppMode {
return workflow?.activeMode ?? workflow?.initialMode ?? 'graph'
}
export function isAppModeValue(mode: AppMode): boolean {
return mode === 'app' || mode === 'builder:arrange'
}
const enableAppBuilder = ref(true)
export function useAppMode() {
const workflowStore = useWorkflowStore()
const mode = computed(() => getWorkflowMode(workflowStore.activeWorkflow))
const mode = computed(
() =>
workflowStore.activeWorkflow?.activeMode ??
workflowStore.activeWorkflow?.initialMode ??
'graph'
)
const isBuilderMode = computed(
() => isSelectMode.value || isArrangeMode.value
@@ -39,7 +29,9 @@ export function useAppMode() {
() => isSelectInputsMode.value || isSelectOutputsMode.value
)
const isArrangeMode = computed(() => mode.value === 'builder:arrange')
const isAppMode = computed(() => isAppModeValue(mode.value))
const isAppMode = computed(
() => mode.value === 'app' || mode.value === 'builder:arrange'
)
const isGraphMode = computed(
() => mode.value === 'graph' || isSelectMode.value
)

View File

@@ -38,8 +38,7 @@ export function useHelpCenter() {
*/
const toggleHelpCenter = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'sidebar_help_center_toggled',
element_group: 'sidebar'
button_id: 'sidebar_help_center_toggled'
})
helpCenterStore.toggle()
}

View File

@@ -0,0 +1,175 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { IS_CONTROL_WIDGET } from './controlWidgetMarker'
import { runWidgetControl } from '@/core/graph/widgets/widgetControlSystem'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { installWidgetControlHooks } from './useWidgetControlHooks'
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: (key: string) =>
key === 'Comfy.WidgetControlMode' ? 'after' : undefined
})
}))
function controlFor(
store: ReturnType<typeof useWidgetValueStore>,
graph: LGraph,
targetId: string
) {
return store
.getWidgetControls(graph.rootGraph.id)
.find(([id]) => id === targetId)?.[1]
}
function addSeedNode(graph: LGraph): LGraphNode {
const node = new LGraphNode('SeedNode')
node.id = 1
const seed = node.addWidget('number', 'seed', 1, () => {}, {
min: 0,
max: 1_000_000,
step2: 1
})
const control = node.addWidget(
'combo',
'control_after_generate',
'increment',
() => {},
{ values: ['fixed', 'increment', 'decrement', 'randomize'] }
)
;(control as IBaseWidget & Record<symbol, unknown>)[IS_CONTROL_WIDGET] = true
seed.linkedWidgets = [control]
graph.add(node)
return node
}
describe('installWidgetControlHooks', () => {
let graph: LGraph
let store: ReturnType<typeof useWidgetValueStore>
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
store = useWidgetValueStore()
graph = new LGraph()
})
it('registers a control component for a control-target widget', () => {
const node = addSeedNode(graph)
const seedId = node.widgets![0].widgetId!
installWidgetControlHooks(graph)
const control = controlFor(store, graph, seedId)
expect(control?.controlWidgetId).toBe(node.widgets![1].widgetId)
})
it('advances the registered target value through the control system', () => {
const node = addSeedNode(graph)
const seedId = node.widgets![0].widgetId!
installWidgetControlHooks(graph)
runWidgetControl(graph.rootGraph.id, 'after')
expect(store.getWidget(seedId)?.value).toBe(2)
})
it('removes the control component when the node is removed', () => {
const node = addSeedNode(graph)
const seedId = node.widgets![0].widgetId!
installWidgetControlHooks(graph)
expect(controlFor(store, graph, seedId)).toBeDefined()
graph.remove(node)
expect(controlFor(store, graph, seedId)).toBeUndefined()
})
it('registers controls for nodes added after install', () => {
installWidgetControlHooks(graph)
const node = addSeedNode(graph)
const seedId = node.widgets![0].widgetId!
expect(controlFor(store, graph, seedId)).toBeDefined()
})
it('restores the original node callback on uninstall', () => {
const node = addSeedNode(graph)
const original = node.onConnectionsChange
const uninstall = installWidgetControlHooks(graph)
expect(node.onConnectionsChange).not.toBe(original)
uninstall()
expect(node.onConnectionsChange).toBe(original)
})
it('does not register a control for a widget with no linked control widget', () => {
const node = new LGraphNode('PlainNode')
node.id = 2
const plain = node.addWidget('number', 'steps', 20, () => {}, {
min: 1,
max: 100,
step2: 1
})
// No linkedWidgets — not a control-target widget
plain.linkedWidgets = undefined
graph.add(node)
installWidgetControlHooks(graph)
const plainId = plain.widgetId!
expect(controlFor(store, graph, plainId)).toBeUndefined()
})
it('installs on an empty graph without errors', () => {
expect(() => installWidgetControlHooks(graph)).not.toThrow()
})
it('uninstall removes the onNodeAdded and onNodeRemoved overrides', () => {
const originalAdded = graph.onNodeAdded
const originalRemoved = graph.onNodeRemoved
const uninstall = installWidgetControlHooks(graph)
expect(graph.onNodeAdded).not.toBe(originalAdded)
expect(graph.onNodeRemoved).not.toBe(originalRemoved)
uninstall()
// After uninstall the callbacks should revert to original (undefined or the
// prior value set before install)
expect(graph.onNodeAdded).toBe(originalAdded || undefined)
expect(graph.onNodeRemoved).toBe(originalRemoved || undefined)
})
it('re-syncs the control when an INPUT connection changes', () => {
const node = addSeedNode(graph)
const seedId = node.widgets![0].widgetId!
installWidgetControlHooks(graph)
// Simulate an INPUT connection change on the node
node.onConnectionsChange?.(NodeSlotType.INPUT, 0, true, null as never, null as never)
// Control should still be registered after the re-sync
expect(controlFor(store, graph, seedId)).toBeDefined()
})
it('does not re-sync when an OUTPUT connection changes', () => {
const node = addSeedNode(graph)
const seedId = node.widgets![0].widgetId!
installWidgetControlHooks(graph)
const controlBefore = controlFor(store, graph, seedId)
// Simulate an OUTPUT connection change — should NOT trigger syncNodeControls
node.onConnectionsChange?.(NodeSlotType.OUTPUT, 0, true, null as never, null as never)
// Control state should be unchanged (same reference)
expect(controlFor(store, graph, seedId)).toEqual(controlBefore)
})
})

View File

@@ -0,0 +1,165 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { IS_CONTROL_WIDGET } from '@/core/graph/widgets/control/controlWidgetMarker'
import {
computeNextControlledValue,
isValueControlMode,
isValueControlWidget
} from './valueControl'
const makeNumberWidget = (
value: number,
options: Partial<IBaseWidget['options']> = {}
): IBaseWidget =>
({
type: 'number',
name: 'seed',
value,
options
}) as unknown as IBaseWidget
const makeComboWidget = (value: string, values: string[]): IBaseWidget =>
({
type: 'combo',
name: 'choice',
value,
options: { values }
}) as unknown as IBaseWidget
describe('isValueControlMode', () => {
it('returns true for each valid mode string', () => {
expect(isValueControlMode('fixed')).toBe(true)
expect(isValueControlMode('increment')).toBe(true)
expect(isValueControlMode('increment-wrap')).toBe(true)
expect(isValueControlMode('decrement')).toBe(true)
expect(isValueControlMode('randomize')).toBe(true)
})
it('returns false for an unrecognized string', () => {
expect(isValueControlMode('after')).toBe(false)
expect(isValueControlMode('before')).toBe(false)
expect(isValueControlMode('')).toBe(false)
})
it('returns false for non-string values', () => {
expect(isValueControlMode(undefined)).toBe(false)
expect(isValueControlMode(null)).toBe(false)
expect(isValueControlMode(42)).toBe(false)
expect(isValueControlMode(true)).toBe(false)
expect(isValueControlMode({})).toBe(false)
})
})
describe('isValueControlWidget', () => {
it('returns true for a marked widget', () => {
const widget = {
[IS_CONTROL_WIDGET]: true
} as unknown as IBaseWidget
expect(isValueControlWidget(widget)).toBe(true)
})
it('returns false when the marker symbol is missing', () => {
const widget = {} as unknown as IBaseWidget
expect(isValueControlWidget(widget)).toBe(false)
})
})
describe('computeNextControlledValue (number)', () => {
it('returns undefined for fixed mode', () => {
expect(
computeNextControlledValue(makeNumberWidget(5), 'fixed')
).toBeUndefined()
})
it('increments by step2', () => {
const widget = makeNumberWidget(5, { min: 0, max: 100, step2: 2 })
expect(computeNextControlledValue(widget, 'increment')).toBe(7)
})
it('decrements by step2', () => {
const widget = makeNumberWidget(5, { min: 0, max: 100, step2: 3 })
expect(computeNextControlledValue(widget, 'decrement')).toBe(2)
})
it('clamps to max on increment', () => {
const widget = makeNumberWidget(99, { min: 0, max: 100, step2: 5 })
expect(computeNextControlledValue(widget, 'increment')).toBe(100)
})
it('clamps to min on decrement', () => {
const widget = makeNumberWidget(1, { min: 0, max: 100, step2: 5 })
expect(computeNextControlledValue(widget, 'decrement')).toBe(0)
})
it('randomizes within range using a seeded random', () => {
const widget = makeNumberWidget(0, { min: 10, max: 20, step2: 1 })
vi.spyOn(Math, 'random').mockReturnValue(0.5)
expect(computeNextControlledValue(widget, 'randomize')).toBe(15)
})
it('returns undefined when target value is not numeric', () => {
const widget = {
type: 'number',
name: 'seed',
value: 'not a number',
options: {}
} as unknown as IBaseWidget
expect(computeNextControlledValue(widget, 'increment')).toBeUndefined()
})
})
describe('computeNextControlledValue (combo)', () => {
it('cycles to the next value on increment', () => {
const widget = makeComboWidget('a', ['a', 'b', 'c'])
expect(computeNextControlledValue(widget, 'increment')).toBe('b')
})
it('clamps at the end on increment without wrap', () => {
const widget = makeComboWidget('c', ['a', 'b', 'c'])
expect(computeNextControlledValue(widget, 'increment')).toBe('c')
})
it('wraps to first value on increment-wrap past the end', () => {
const widget = makeComboWidget('c', ['a', 'b', 'c'])
expect(computeNextControlledValue(widget, 'increment-wrap')).toBe('a')
})
it('cycles to the previous value on decrement', () => {
const widget = makeComboWidget('b', ['a', 'b', 'c'])
expect(computeNextControlledValue(widget, 'decrement')).toBe('a')
})
it('randomizes by index', () => {
const widget = makeComboWidget('a', ['a', 'b', 'c', 'd'])
vi.spyOn(Math, 'random').mockReturnValue(0.6)
expect(computeNextControlledValue(widget, 'randomize')).toBe('c')
})
it('applies a substring filter', () => {
const widget = makeComboWidget('apple', ['apple', 'banana', 'apricot'])
expect(
computeNextControlledValue(widget, 'increment', { comboFilter: 'ap' })
).toBe('apricot')
})
it('applies a regex filter when wrapped in slashes', () => {
const widget = makeComboWidget('foo1', ['foo1', 'bar', 'foo2'])
expect(
computeNextControlledValue(widget, 'increment', { comboFilter: '/foo/' })
).toBe('foo2')
})
it('returns undefined when the filter eliminates all values', () => {
const widget = makeComboWidget('a', ['a', 'b'])
expect(
computeNextControlledValue(widget, 'increment', { comboFilter: 'zzz' })
).toBeUndefined()
})
afterEach(() => {
vi.restoreAllMocks()
})
})

View File

@@ -0,0 +1,264 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { widgetId } from '@/types/widgetId'
import { runWidgetControl } from './widgetControlSystem'
const controlMode = vi.hoisted(() => ({ value: 'after' as 'before' | 'after' }))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: (key: string) =>
key === 'Comfy.WidgetControlMode' ? controlMode.value : undefined
})
}))
const GRAPH = 'graph'
function seedSetup(
mode: string,
{ value = 1 }: { value?: number } = {}
): { targetId: ReturnType<typeof widgetId> } {
const store = useWidgetValueStore()
const targetId = widgetId(GRAPH, '1', 'seed')
const controlId = widgetId(GRAPH, '1', 'control_after_generate')
store.registerWidget(targetId, {
type: 'number',
value,
options: { min: 0, max: 1_000_000, step2: 1 }
})
store.registerWidget(controlId, {
type: 'combo',
value: mode,
options: {}
})
store.registerWidgetControl(targetId, { controlWidgetId: controlId })
return { targetId }
}
describe('runWidgetControl', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
controlMode.value = 'after'
})
it('increments a controlled value after queueing', () => {
const store = useWidgetValueStore()
const { targetId } = seedSetup('increment')
runWidgetControl(GRAPH, 'after')
expect(store.getWidget(targetId)?.value).toBe(2)
})
it('leaves the value unchanged when the mode is fixed', () => {
const store = useWidgetValueStore()
const { targetId } = seedSetup('fixed')
runWidgetControl(GRAPH, 'after')
expect(store.getWidget(targetId)?.value).toBe(1)
})
it('does not run on a target whose input is link-fed', () => {
const store = useWidgetValueStore()
const { targetId } = seedSetup('increment')
store.setInputLinked(targetId, true)
runWidgetControl(GRAPH, 'after')
expect(store.getWidget(targetId)?.value).toBe(1)
})
it('does not run during partial execution', () => {
const store = useWidgetValueStore()
const { targetId } = seedSetup('increment')
runWidgetControl(GRAPH, 'after', { isPartialExecution: true })
expect(store.getWidget(targetId)?.value).toBe(1)
})
it('skips the first queue in before mode, then advances', () => {
controlMode.value = 'before'
const store = useWidgetValueStore()
const { targetId } = seedSetup('increment')
runWidgetControl(GRAPH, 'before')
expect(store.getWidget(targetId)?.value).toBe(1)
runWidgetControl(GRAPH, 'before')
expect(store.getWidget(targetId)?.value).toBe(2)
})
it('ignores after-phase work when in before mode', () => {
controlMode.value = 'before'
const store = useWidgetValueStore()
const { targetId } = seedSetup('increment')
runWidgetControl(GRAPH, 'after')
expect(store.getWidget(targetId)?.value).toBe(1)
})
it('applies a combo filter when advancing a combo value', () => {
const store = useWidgetValueStore()
const targetId = widgetId(GRAPH, '1', 'ckpt')
const controlId = widgetId(GRAPH, '1', 'control_after_generate')
const filterId = widgetId(GRAPH, '1', 'control_filter_list')
store.registerWidget(targetId, {
type: 'combo',
value: 'a.safetensors',
options: { values: ['a.safetensors', 'b.ckpt', 'c.safetensors'] }
})
store.registerWidget(controlId, {
type: 'combo',
value: 'increment',
options: {}
})
store.registerWidget(filterId, {
type: 'string',
value: 'safetensors',
options: {}
})
store.registerWidgetControl(targetId, {
controlWidgetId: controlId,
filterWidgetId: filterId
})
runWidgetControl(GRAPH, 'after')
expect(store.getWidget(targetId)?.value).toBe('c.safetensors')
})
it('only advances controls belonging to the queued graph', () => {
const store = useWidgetValueStore()
const { targetId } = seedSetup('increment')
const otherTarget = widgetId('other-graph', '1', 'seed')
const otherControl = widgetId('other-graph', '1', 'control_after_generate')
store.registerWidget(otherTarget, {
type: 'number',
value: 1,
options: { min: 0, max: 1_000_000, step2: 1 }
})
store.registerWidget(otherControl, {
type: 'combo',
value: 'increment',
options: {}
})
store.registerWidgetControl(otherTarget, { controlWidgetId: otherControl })
runWidgetControl(GRAPH, 'after')
expect(store.getWidget(targetId)?.value).toBe(2)
expect(store.getWidget(otherTarget)?.value).toBe(1)
})
it('preserves the before-mode skip across re-registration', () => {
controlMode.value = 'before'
const store = useWidgetValueStore()
const { targetId } = seedSetup('increment')
const controlId = widgetId(GRAPH, '1', 'control_after_generate')
runWidgetControl(GRAPH, 'before')
expect(store.getWidget(targetId)?.value).toBe(1)
store.registerWidgetControl(targetId, { controlWidgetId: controlId })
runWidgetControl(GRAPH, 'before')
expect(store.getWidget(targetId)?.value).toBe(2)
})
it('skips a target whose control widget has an invalid mode string', () => {
const store = useWidgetValueStore()
const { targetId } = seedSetup('not-a-valid-mode')
runWidgetControl(GRAPH, 'after')
// Value should remain unchanged since 'not-a-valid-mode' is not a ValueControlMode
expect(store.getWidget(targetId)?.value).toBe(1)
})
it('skips a target whose control widget is missing from the store', () => {
const store = useWidgetValueStore()
const targetId = widgetId(GRAPH, '1', 'seed')
const missingControlId = widgetId(GRAPH, '1', 'nonexistent_control')
store.registerWidget(targetId, {
type: 'number',
value: 5,
options: { min: 0, max: 100, step2: 1 }
})
store.registerWidgetControl(targetId, { controlWidgetId: missingControlId })
runWidgetControl(GRAPH, 'after')
expect(store.getWidget(targetId)?.value).toBe(5)
})
it('decrements a controlled value after queueing', () => {
const store = useWidgetValueStore()
const { targetId } = seedSetup('decrement', { value: 5 })
runWidgetControl(GRAPH, 'after')
expect(store.getWidget(targetId)?.value).toBe(4)
})
it('ignores a non-string filter widget value', () => {
const store = useWidgetValueStore()
const targetId = widgetId(GRAPH, '1', 'ckpt')
const controlId = widgetId(GRAPH, '1', 'control_after_generate')
const filterId = widgetId(GRAPH, '1', 'control_filter_list')
store.registerWidget(targetId, {
type: 'combo',
value: 'a',
options: { values: ['a', 'b', 'c'] }
})
store.registerWidget(controlId, {
type: 'combo',
value: 'increment',
options: {}
})
// Register filter with a numeric (non-string) value
store.registerWidget(filterId, {
type: 'number',
value: 42,
options: {}
})
store.registerWidgetControl(targetId, {
controlWidgetId: controlId,
filterWidgetId: filterId
})
runWidgetControl(GRAPH, 'after')
// Non-string filter is ignored, so increment advances normally from 'a' to 'b'
expect(store.getWidget(targetId)?.value).toBe('b')
})
it('advances multiple independent controls in the same graph', () => {
const store = useWidgetValueStore()
const { targetId: target1 } = seedSetup('increment', { value: 10 })
const target2 = widgetId(GRAPH, '2', 'cfg')
const control2 = widgetId(GRAPH, '2', 'control_after_generate')
store.registerWidget(target2, {
type: 'number',
value: 20,
options: { min: 0, max: 100, step2: 5 }
})
store.registerWidget(control2, {
type: 'combo',
value: 'decrement',
options: {}
})
store.registerWidgetControl(target2, { controlWidgetId: control2 })
runWidgetControl(GRAPH, 'after')
expect(store.getWidget(target1)?.value).toBe(11)
expect(store.getWidget(target2)?.value).toBe(15)
})
})

View File

@@ -5194,7 +5194,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

@@ -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",

View File

@@ -88,23 +88,20 @@ const { t } = useI18n()
onMounted(() => {
// Impression event — uses trackUiButtonClicked as no dedicated impression tracker exists
useTelemetry()?.trackUiButtonClicked({
button_id: 'cloud_notification_modal_impression',
element_group: 'cloud_notification'
button_id: 'cloud_notification_modal_impression'
})
})
function onDismiss() {
useTelemetry()?.trackUiButtonClicked({
button_id: 'cloud_notification_continue_locally_clicked',
element_group: 'cloud_notification'
button_id: 'cloud_notification_continue_locally_clicked'
})
useDialogStore().closeDialog()
}
function onExplore() {
useTelemetry()?.trackUiButtonClicked({
button_id: 'cloud_notification_explore_cloud_clicked',
element_group: 'cloud_notification'
button_id: 'cloud_notification_explore_cloud_clicked'
})
const params = new URLSearchParams({

View File

@@ -21,7 +21,6 @@ import type {
PageVisibilityMetadata,
SettingChangedMetadata,
SharedWorkflowRunMetadata,
ShellLayoutMetadata,
SubscriptionMetadata,
SubscriptionSuccessMetadata,
SurveyResponses,
@@ -197,10 +196,6 @@ export class TelemetryRegistry implements TelemetryDispatcher {
this.dispatch((provider) => provider.trackTabCount?.(metadata))
}
trackShellLayout(metadata: ShellLayoutMetadata): void {
this.dispatch((provider) => provider.trackShellLayout?.(metadata))
}
trackNodeSearch(metadata: NodeSearchMetadata): void {
this.dispatch((provider) => provider.trackNodeSearch?.(metadata))
}

View File

@@ -1,11 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/composables/useAppMode', () => ({
useAppMode: () => ({
mode: { value: 'app' },
isAppMode: { value: true }
})
}))
import { beforeEach, describe, expect, it } from 'vitest'
import { GtmTelemetryProvider } from './GtmTelemetryProvider'
@@ -25,7 +18,6 @@ describe('GtmTelemetryProvider', () => {
window.dataLayer = undefined
window.gtag = undefined
document.head.innerHTML = ''
localStorage.clear()
})
it('injects the GTM runtime script', () => {
@@ -192,15 +184,11 @@ describe('GtmTelemetryProvider', () => {
it('pushes run_workflow with trigger_source', () => {
const provider = createInitializedProvider()
localStorage.setItem('Comfy.MenuPosition.Docked', 'false')
provider.trackRunButton({ trigger_source: 'button' })
expect(lastDataLayerEntry()).toMatchObject({
event: 'run_workflow',
trigger_source: 'button',
subscribe_to_run: false,
view_mode: 'app',
is_app_mode: true,
dock_state: 'floating'
subscribe_to_run: false
})
})
@@ -335,33 +323,16 @@ describe('GtmTelemetryProvider', () => {
provider.trackShareFlow({
step: 'link_copied',
source: 'app_mode',
view_mode: 'app',
is_app_mode: true,
share_id: 'share-1'
})
expect(lastDataLayerEntry()).toMatchObject({
event: 'share_flow',
step: 'link_copied',
source: 'app_mode',
view_mode: 'app',
is_app_mode: true
source: 'app_mode'
})
expect(lastDataLayerEntry()).not.toHaveProperty('share_id')
})
it('pushes ui_button_click with element_group', () => {
const provider = createInitializedProvider()
provider.trackUiButtonClicked({
button_id: 'sidebar_settings_button_clicked',
element_group: 'sidebar'
})
expect(lastDataLayerEntry()).toMatchObject({
event: 'ui_button_click',
button_id: 'sidebar_settings_button_clicked',
element_group: 'sidebar'
})
})
it('omits share_id from workflow import events', () => {
const provider = createInitializedProvider()
provider.trackWorkflowImported({

View File

@@ -29,8 +29,6 @@ import type {
WorkflowImportMetadata,
WorkflowSavedMetadata
} from '../../types'
import { useAppMode } from '@/composables/useAppMode'
import { getActionbarDockState } from '../../utils/getActionbarDockState'
/**
* Google Tag Manager telemetry provider.
@@ -187,14 +185,9 @@ export class GtmTelemetryProvider implements TelemetryProvider {
subscribe_to_run?: boolean
trigger_source?: ExecutionTriggerSource
}): void {
const { mode, isAppMode } = useAppMode()
this.pushEvent('run_workflow', {
subscribe_to_run: options?.subscribe_to_run ?? false,
trigger_source: options?.trigger_source ?? 'unknown',
view_mode: mode.value,
is_app_mode: isAppMode.value,
dock_state: getActionbarDockState()
trigger_source: options?.trigger_source ?? 'unknown'
})
}
@@ -294,9 +287,7 @@ export class GtmTelemetryProvider implements TelemetryProvider {
trackShareFlow(metadata: ShareFlowMetadata): void {
this.pushEvent('share_flow', {
step: metadata.step,
source: metadata.source,
view_mode: metadata.view_mode,
is_app_mode: metadata.is_app_mode
source: metadata.source
})
}
@@ -342,8 +333,7 @@ export class GtmTelemetryProvider implements TelemetryProvider {
trackUiButtonClicked(metadata: UiButtonClickMetadata): void {
this.pushEvent('ui_button_click', {
button_id: metadata.button_id,
element_group: metadata.element_group
button_id: metadata.button_id
})
}

View File

@@ -19,7 +19,7 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({
vi.mock('@/composables/useAppMode', () => ({
useAppMode: () => ({
mode: { value: 'graph' },
mode: { value: 'workflow' },
isAppMode: { value: false }
})
}))
@@ -60,9 +60,7 @@ import type {
DefaultViewSetMetadata,
EnterLinearMetadata,
ShareFlowMetadata,
ShellLayoutMetadata,
SurveyResponses,
TemplateFilterMetadata,
TemplateLibraryClosedMetadata,
TemplateLibraryMetadata,
TemplateMetadata,
@@ -76,10 +74,6 @@ const waitForMixpanelInit = () =>
type ConfigWindow = { __CONFIG__?: { mixpanel_token?: string } }
beforeEach(() => {
localStorage.clear()
})
describe('MixpanelTelemetryProvider — without configured token', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -171,44 +165,6 @@ describe('MixpanelTelemetryProvider — with configured token', () => {
expect(mockMixpanel.track).not.toHaveBeenCalled()
})
it('tracks enabled funnel events by default', async () => {
const provider = new MixpanelTelemetryProvider()
await waitForMixpanelInit()
mockMixpanel.track.mockClear()
const templateFilterMetadata: TemplateFilterMetadata = {
selected_models: [],
selected_use_cases: [],
selected_runs_on: [],
sort_by: 'default',
filtered_count: 1,
total_count: 2
}
provider.trackSettingChanged({ setting_id: 'theme' })
provider.trackTemplateFilterChanged(templateFilterMetadata)
provider.trackUiButtonClicked({
button_id: 'sidebar_settings_button_clicked',
element_group: 'sidebar'
})
expect(mockMixpanel.track).toHaveBeenCalledWith(
TelemetryEvents.SETTING_CHANGED,
{ setting_id: 'theme' }
)
expect(mockMixpanel.track).toHaveBeenCalledWith(
TelemetryEvents.TEMPLATE_FILTER_CHANGED,
templateFilterMetadata
)
expect(mockMixpanel.track).toHaveBeenCalledWith(
TelemetryEvents.UI_BUTTON_CLICKED,
{
button_id: 'sidebar_settings_button_clicked',
element_group: 'sidebar'
}
)
})
it.for<
[
'opened' | 'requested' | 'completed',
@@ -329,21 +285,7 @@ describe('MixpanelTelemetryProvider — direct event tracking methods', () => {
default_view: 'graph'
}
const enterLinearMetadata: EnterLinearMetadata = {}
const shareFlowMetadata: ShareFlowMetadata = {
step: 'dialog_opened',
view_mode: 'graph',
is_app_mode: false
}
const shellLayoutMetadata: ShellLayoutMetadata = {
view_mode: 'graph',
is_app_mode: false,
dock_state: 'docked',
actionbar_position: 'Top',
active_sidebar_tab: null,
right_side_panel_open: false,
bottom_panel_open: false,
open_workflow_tabs: 1
}
const shareFlowMetadata: ShareFlowMetadata = { step: 'dialog_opened' }
const authMetadata: AuthMetadata = {}
it.for<
@@ -409,11 +351,6 @@ describe('MixpanelTelemetryProvider — direct event tracking methods', () => {
(p) => p.trackShareFlow(shareFlowMetadata),
TelemetryEvents.SHARE_FLOW
],
[
'trackShellLayout',
(p) => p.trackShellLayout(shellLayoutMetadata),
TelemetryEvents.SHELL_LAYOUT
],
[
'trackAuth',
(p) => p.trackAuth(authMetadata),
@@ -454,7 +391,6 @@ describe('MixpanelTelemetryProvider — direct event tracking methods', () => {
const provider = new MixpanelTelemetryProvider()
await waitForMixpanelInit()
mockMixpanel.track.mockClear()
localStorage.setItem('Comfy.MenuPosition.Docked', 'false')
provider.trackRunButton({
subscribe_to_run: true,
@@ -467,9 +403,8 @@ describe('MixpanelTelemetryProvider — direct event tracking methods', () => {
subscribe_to_run: true,
workflow_type: 'custom',
trigger_source: 'button',
view_mode: 'graph',
is_app_mode: false,
dock_state: 'floating'
view_mode: 'workflow',
is_app_mode: false
})
)
})
@@ -489,8 +424,6 @@ describe('MixpanelTelemetryProvider — direct event tracking methods', () => {
provider.trackShareFlow({
step: 'link_copied',
source: 'app_mode',
view_mode: 'app',
is_app_mode: true,
share_id: 'share-1'
})
@@ -510,9 +443,7 @@ describe('MixpanelTelemetryProvider — direct event tracking methods', () => {
TelemetryEvents.SHARE_FLOW,
{
step: 'link_copied',
source: 'app_mode',
view_mode: 'app',
is_app_mode: true
source: 'app_mode'
}
)
})

View File

@@ -28,7 +28,6 @@ import type {
RunButtonProperties,
SettingChangedMetadata,
ShareFlowMetadata,
ShellLayoutMetadata,
SubscriptionMetadata,
SubscriptionSuccessMetadata,
SurveyResponses,
@@ -48,7 +47,6 @@ import type {
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import { TelemetryEvents } from '../../types'
import { getActionbarDockState } from '../../utils/getActionbarDockState'
import { normalizeSurveyResponses } from '../../utils/surveyNormalization'
const DEFAULT_DISABLED_EVENTS = [
@@ -57,10 +55,13 @@ const DEFAULT_DISABLED_EVENTS = [
TelemetryEvents.TAB_COUNT_TRACKING,
TelemetryEvents.NODE_SEARCH,
TelemetryEvents.NODE_SEARCH_RESULT_SELECTED,
TelemetryEvents.TEMPLATE_FILTER_CHANGED,
TelemetryEvents.SETTING_CHANGED,
TelemetryEvents.HELP_CENTER_OPENED,
TelemetryEvents.HELP_RESOURCE_CLICKED,
TelemetryEvents.HELP_CENTER_CLOSED,
TelemetryEvents.WORKFLOW_CREATED
TelemetryEvents.WORKFLOW_CREATED,
TelemetryEvents.UI_BUTTON_CLICKED
] as const satisfies TelemetryEventName[]
const TELEMETRY_EVENT_SET = new Set<TelemetryEventName>(
@@ -296,8 +297,7 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
toolkit_node_names: executionContext.toolkit_node_names,
trigger_source: options?.trigger_source,
view_mode: mode.value,
is_app_mode: isAppMode.value,
dock_state: getActionbarDockState()
is_app_mode: isAppMode.value
}
this.trackEvent(TelemetryEvents.RUN_BUTTON_CLICKED, runButtonProperties)
@@ -397,10 +397,6 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.TAB_COUNT_TRACKING, metadata)
}
trackShellLayout(metadata: ShellLayoutMetadata): void {
this.trackEvent(TelemetryEvents.SHELL_LAYOUT, metadata)
}
trackNodeSearch(metadata: NodeSearchMetadata): void {
this.trackEvent(TelemetryEvents.NODE_SEARCH, metadata)
}

View File

@@ -276,50 +276,25 @@ describe('PostHogTelemetryProvider', () => {
provider.trackShareLinkOpened({
share_id: 'share-1',
is_authenticated: true,
view_mode: 'graph',
is_app_mode: false
})
provider.trackShareFlow({
step: 'link_created',
source: 'app_mode',
share_id: 'share-1',
view_mode: 'app',
is_app_mode: true
is_authenticated: true
})
provider.trackSharedWorkflowRun({
job_id: 'job-1',
share_id: 'share-1',
view_mode: 'app',
is_app_mode: true
share_id: 'share-1'
})
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.SHARE_LINK_OPENED,
{
share_id: 'share-1',
is_authenticated: true,
view_mode: 'graph',
is_app_mode: false
}
)
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.SHARE_FLOW,
{
step: 'link_created',
source: 'app_mode',
share_id: 'share-1',
view_mode: 'app',
is_app_mode: true
is_authenticated: true
}
)
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.SHARED_WORKFLOW_RUN,
{
job_id: 'job-1',
share_id: 'share-1',
view_mode: 'app',
is_app_mode: true
share_id: 'share-1'
}
)
})
@@ -469,71 +444,6 @@ describe('PostHogTelemetryProvider', () => {
{}
)
})
it('captures enabled funnel events by default', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()
provider.trackSettingChanged({ setting_id: 'theme' })
provider.trackTemplateFilterChanged({
selected_models: [],
selected_use_cases: [],
selected_runs_on: [],
sort_by: 'default',
filtered_count: 1,
total_count: 2
})
provider.trackUiButtonClicked({
button_id: 'sidebar_settings_button_clicked',
element_group: 'sidebar'
})
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.SETTING_CHANGED,
{ setting_id: 'theme' }
)
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.TEMPLATE_FILTER_CHANGED,
{
selected_models: [],
selected_use_cases: [],
selected_runs_on: [],
sort_by: 'default',
filtered_count: 1,
total_count: 2
}
)
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.UI_BUTTON_CLICKED,
{
button_id: 'sidebar_settings_button_clicked',
element_group: 'sidebar'
}
)
})
it('captures shell layout snapshots', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()
const shellLayoutMetadata = {
view_mode: 'graph',
is_app_mode: false,
dock_state: 'floating',
actionbar_position: 'Top',
active_sidebar_tab: 'node-library',
right_side_panel_open: true,
bottom_panel_open: false,
open_workflow_tabs: 2
} as const
provider.trackShellLayout(shellLayoutMetadata)
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.SHELL_LAYOUT,
shellLayoutMetadata
)
})
})
describe('survey tracking', () => {

View File

@@ -28,7 +28,6 @@ import type {
RunButtonProperties,
SettingChangedMetadata,
SharedWorkflowRunMetadata,
ShellLayoutMetadata,
SubscriptionMetadata,
SubscriptionSuccessMetadata,
SurveyResponses,
@@ -46,7 +45,6 @@ import type {
WorkflowSavedMetadata
} from '../../types'
import { TelemetryEvents } from '../../types'
import { getActionbarDockState } from '../../utils/getActionbarDockState'
import { getExecutionContext } from '../../utils/getExecutionContext'
import { normalizeSurveyResponses } from '../../utils/surveyNormalization'
@@ -56,10 +54,13 @@ const DEFAULT_DISABLED_EVENTS = [
TelemetryEvents.TAB_COUNT_TRACKING,
TelemetryEvents.NODE_SEARCH,
TelemetryEvents.NODE_SEARCH_RESULT_SELECTED,
TelemetryEvents.TEMPLATE_FILTER_CHANGED,
TelemetryEvents.SETTING_CHANGED,
TelemetryEvents.HELP_CENTER_OPENED,
TelemetryEvents.HELP_RESOURCE_CLICKED,
TelemetryEvents.HELP_CENTER_CLOSED,
TelemetryEvents.WORKFLOW_CREATED
TelemetryEvents.WORKFLOW_CREATED,
TelemetryEvents.UI_BUTTON_CLICKED
] as const satisfies TelemetryEventName[]
const TELEMETRY_EVENT_SET = new Set<TelemetryEventName>(
@@ -394,8 +395,7 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
toolkit_node_names: executionContext.toolkit_node_names,
trigger_source: options?.trigger_source,
view_mode: mode.value,
is_app_mode: isAppMode.value,
dock_state: getActionbarDockState()
is_app_mode: isAppMode.value
}
this.trackEvent(TelemetryEvents.RUN_BUTTON_CLICKED, runButtonProperties)
@@ -497,10 +497,6 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.TAB_COUNT_TRACKING, metadata)
}
trackShellLayout(metadata: ShellLayoutMetadata): void {
this.trackEvent(TelemetryEvents.SHELL_LAYOUT, metadata)
}
trackNodeSearch(metadata: NodeSearchMetadata): void {
this.trackEvent(TelemetryEvents.NODE_SEARCH, metadata)
}

View File

@@ -12,7 +12,6 @@
* 3. Check dist/assets/*.js files contain no tracking code
*/
import type { AppMode } from '@/composables/useAppMode'
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
@@ -71,9 +70,8 @@ export interface RunButtonProperties {
has_toolkit_nodes: boolean
toolkit_node_names: string[]
trigger_source?: ExecutionTriggerSource
view_mode: AppMode
is_app_mode: boolean
dock_state: ActionbarDockState
view_mode?: string
is_app_mode?: boolean
}
/**
@@ -122,12 +120,8 @@ export interface ExecutionSuccessMetadata {
export interface SharedWorkflowRunMetadata {
job_id: string
share_id: string
view_mode: AppMode
is_app_mode: boolean
}
export type ActionbarDockState = 'docked' | 'floating'
/**
* Template metadata for workflow tracking
*/
@@ -203,15 +197,11 @@ export interface ShareFlowMetadata {
step: ShareFlowStep
source?: 'app_mode' | 'graph_mode'
share_id?: string
view_mode: AppMode
is_app_mode: boolean
}
export interface ShareLinkOpenedMetadata {
share_id: string
is_authenticated: boolean
view_mode: AppMode
is_app_mode: boolean
}
/**
@@ -253,20 +243,6 @@ export interface TabCountMetadata {
tab_count: number
}
/**
* Shell layout snapshot, sent once per session when the app is ready
*/
export interface ShellLayoutMetadata {
view_mode: AppMode
is_app_mode: boolean
dock_state: ActionbarDockState
actionbar_position: string
active_sidebar_tab: string | null
right_side_panel_open: boolean
bottom_panel_open: boolean
open_workflow_tabs: number
}
/**
* Settings change metadata
*/
@@ -351,8 +327,8 @@ export interface TemplateFilterMetadata {
* UI button click tracking metadata
*/
export interface UiButtonClickMetadata {
/** Canonical identifier for the button (e.g., "comfy_logo") */
button_id: string
element_group: string
}
/**
@@ -522,9 +498,6 @@ export interface TelemetryProvider {
// Tab tracking events
trackTabCount?(metadata: TabCountMetadata): void
// Shell layout snapshot events
trackShellLayout?(metadata: ShellLayoutMetadata): void
// Node search analytics events
trackNodeSearch?(metadata: NodeSearchMetadata): void
trackNodeSearchResultSelected?(metadata: NodeSearchResultMetadata): void
@@ -620,9 +593,6 @@ export const TelemetryEvents = {
// Tab Tracking
TAB_COUNT_TRACKING: 'app:tab_count_tracking',
// Shell Layout
SHELL_LAYOUT: 'app:shell_layout',
// Node Search Analytics
NODE_SEARCH: 'app:node_search',
NODE_SEARCH_RESULT_SELECTED: 'app:node_search_result_selected',
@@ -685,7 +655,6 @@ export type TelemetryEventProperties =
| TemplateLibraryClosedMetadata
| PageVisibilityMetadata
| TabCountMetadata
| ShellLayoutMetadata
| NodeSearchMetadata
| NodeSearchResultMetadata
| SearchQueryMetadata

View File

@@ -1,23 +0,0 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { getActionbarDockState } from './getActionbarDockState'
describe('getActionbarDockState', () => {
beforeEach(() => {
localStorage.clear()
})
it('returns docked when no preference is stored', () => {
expect(getActionbarDockState()).toBe('docked')
})
it('returns docked when the stored preference is true', () => {
localStorage.setItem('Comfy.MenuPosition.Docked', 'true')
expect(getActionbarDockState()).toBe('docked')
})
it('returns floating when the stored preference is false', () => {
localStorage.setItem('Comfy.MenuPosition.Docked', 'false')
expect(getActionbarDockState()).toBe('floating')
})
})

View File

@@ -1,7 +0,0 @@
import type { ActionbarDockState } from '@/platform/telemetry/types'
export function getActionbarDockState(): ActionbarDockState {
return localStorage.getItem('Comfy.MenuPosition.Docked') === 'false'
? 'floating'
: 'docked'
}

View File

@@ -1,88 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const state = vi.hoisted(() => ({
settings: {} as Record<string, unknown>,
activeSidebarTabId: null as string | null,
rightSidePanelOpen: false,
bottomPanelVisible: false,
openWorkflows: [] as unknown[],
mode: { value: 'graph' },
isAppMode: { value: false }
}))
vi.mock('@/composables/useAppMode', () => ({
useAppMode: () => ({ mode: state.mode, isAppMode: state.isAppMode })
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({ get: (key: string) => state.settings[key] })
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({ openWorkflows: state.openWorkflows })
}))
vi.mock('@/stores/workspace/bottomPanelStore', () => ({
useBottomPanelStore: () => ({
bottomPanelVisible: state.bottomPanelVisible
})
}))
vi.mock('@/stores/workspace/rightSidePanelStore', () => ({
useRightSidePanelStore: () => ({ isOpen: state.rightSidePanelOpen })
}))
vi.mock('@/stores/workspace/sidebarTabStore', () => ({
useSidebarTabStore: () => ({
activeSidebarTabId: state.activeSidebarTabId
})
}))
import { getShellLayoutSnapshot } from './getShellLayoutSnapshot'
describe('getShellLayoutSnapshot', () => {
beforeEach(() => {
localStorage.clear()
state.settings = { 'Comfy.UseNewMenu': 'Top' }
state.activeSidebarTabId = null
state.rightSidePanelOpen = false
state.bottomPanelVisible = false
state.openWorkflows = []
state.mode.value = 'graph'
state.isAppMode.value = false
})
it('captures the default layout', () => {
expect(getShellLayoutSnapshot()).toEqual({
view_mode: 'graph',
is_app_mode: false,
dock_state: 'docked',
actionbar_position: 'Top',
active_sidebar_tab: null,
right_side_panel_open: false,
bottom_panel_open: false,
open_workflow_tabs: 0
})
})
it('captures a customized layout', () => {
localStorage.setItem('Comfy.MenuPosition.Docked', 'false')
state.activeSidebarTabId = 'node-library'
state.rightSidePanelOpen = true
state.bottomPanelVisible = true
state.openWorkflows = [{}, {}, {}]
state.mode.value = 'app'
state.isAppMode.value = true
expect(getShellLayoutSnapshot()).toEqual({
view_mode: 'app',
is_app_mode: true,
dock_state: 'floating',
actionbar_position: 'Top',
active_sidebar_tab: 'node-library',
right_side_panel_open: true,
bottom_panel_open: true,
open_workflow_tabs: 3
})
})
})

View File

@@ -1,23 +0,0 @@
import { useAppMode } from '@/composables/useAppMode'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import type { ShellLayoutMetadata } from '../types'
import { getActionbarDockState } from './getActionbarDockState'
export function getShellLayoutSnapshot(): ShellLayoutMetadata {
const { mode, isAppMode } = useAppMode()
return {
view_mode: mode.value,
is_app_mode: isAppMode.value,
dock_state: getActionbarDockState(),
actionbar_position: useSettingStore().get('Comfy.UseNewMenu'),
active_sidebar_tab: useSidebarTabStore().activeSidebarTabId,
right_side_panel_open: useRightSidePanelStore().isOpen,
bottom_panel_open: useBottomPanelStore().bottomPanelVisible,
open_workflow_tabs: useWorkflowStore().openWorkflows.length
}
}

View File

@@ -26,9 +26,9 @@ import { refAutoReset } from '@vueuse/core'
import Button from '@/components/ui/button/Button.vue'
import Input from '@/components/ui/input/Input.vue'
import { useAppMode } from '@/composables/useAppMode'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { useTelemetry } from '@/platform/telemetry'
import { useShareFlowContext } from '@/platform/workflow/sharing/composables/useShareFlowContext'
const { url, shareId } = defineProps<{
url: string
@@ -36,7 +36,7 @@ const { url, shareId } = defineProps<{
}>()
const { copyToClipboard } = useCopyToClipboard()
const shareFlowContext = useShareFlowContext()
const { isAppMode } = useAppMode()
const copied = refAutoReset(false, 2000)
async function handleCopy() {
@@ -44,7 +44,7 @@ async function handleCopy() {
copied.value = true
useTelemetry()?.trackShareFlow({
step: 'link_copied',
...shareFlowContext.value,
source: isAppMode.value ? 'app_mode' : 'graph_mode',
share_id: shareId
})
}

View File

@@ -387,8 +387,6 @@ describe('ShareWorkflowDialogContent', () => {
expect(mockTrackShareFlow).toHaveBeenCalledWith({
step: 'link_created',
source: 'graph_mode',
view_mode: 'graph',
is_app_mode: false,
share_id: 'test-123'
})
})
@@ -409,8 +407,6 @@ describe('ShareWorkflowDialogContent', () => {
expect(mockTrackShareFlow).toHaveBeenCalledWith({
step: 'link_copied',
source: 'graph_mode',
view_mode: 'graph',
is_app_mode: false,
share_id: 'copy-123'
})
})

View File

@@ -206,7 +206,7 @@ import type {
import { useWorkflowShareService } from '@/platform/workflow/sharing/services/workflowShareService'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useShareFlowContext } from '@/platform/workflow/sharing/composables/useShareFlowContext'
import { useAppMode } from '@/composables/useAppMode'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useTelemetry } from '@/platform/telemetry'
import { appendJsonExt } from '@/utils/formatUtil'
@@ -223,7 +223,11 @@ const publishDialog = useComfyHubPublishDialog()
const shareService = useWorkflowShareService()
const workflowStore = useWorkflowStore()
const workflowService = useWorkflowService()
const shareFlowContext = useShareFlowContext()
const { isAppMode } = useAppMode()
function getShareSource() {
return isAppMode.value ? 'app_mode' : ('graph_mode' as const)
}
type DialogState = 'loading' | 'unsaved' | 'ready' | 'shared' | 'stale'
type DialogMode = 'shareLink' | 'publishToHub'
@@ -351,7 +355,7 @@ async function refreshDialogState() {
dialogState.value = 'unsaved'
useTelemetry()?.trackShareFlow({
step: 'save_prompted',
...shareFlowContext.value
source: getShareSource()
})
if (workflow) {
workflowName.value = stripJsonExtension(workflow.filename)
@@ -436,7 +440,7 @@ const {
acknowledged.value = false
useTelemetry()?.trackShareFlow({
step: 'link_created',
...shareFlowContext.value,
source: getShareSource(),
share_id: result.shareId
})

View File

@@ -1,5 +1,5 @@
import ShareWorkflowDialogContent from '@/platform/workflow/sharing/components/ShareWorkflowDialogContent.vue'
import { useShareFlowContext } from '@/platform/workflow/sharing/composables/useShareFlowContext'
import { useAppMode } from '@/composables/useAppMode'
import { useTelemetry } from '@/platform/telemetry'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
@@ -15,7 +15,7 @@ export function useShareDialog() {
const dialogStore = useDialogStore()
const { pruneLinearData } = useAppModeStore()
const workflowStore = useWorkflowStore()
const shareFlowContext = useShareFlowContext()
const { isAppMode } = useAppMode()
function hide() {
dialogStore.closeDialog({ key: DIALOG_KEY })
@@ -54,10 +54,14 @@ export function useShareDialog() {
share()
}
function getShareSource() {
return isAppMode.value ? 'app_mode' : ('graph_mode' as const)
}
function showShareDialog() {
useTelemetry()?.trackShareFlow({
step: 'dialog_opened',
...shareFlowContext.value
source: getShareSource()
})
dialogService.showLayoutDialog({
key: DIALOG_KEY,

View File

@@ -1,18 +0,0 @@
import { computed } from 'vue'
import { useAppMode } from '@/composables/useAppMode'
import type { ShareFlowMetadata } from '@/platform/telemetry/types'
type ShareFlowContext = Pick<
ShareFlowMetadata,
'source' | 'view_mode' | 'is_app_mode'
>
export function useShareFlowContext() {
const { mode, isAppMode } = useAppMode()
return computed<ShareFlowContext>(() => ({
source: isAppMode.value ? 'app_mode' : 'graph_mode',
view_mode: mode.value,
is_app_mode: isAppMode.value
}))
}

View File

@@ -38,13 +38,6 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({
})
}))
vi.mock('@/composables/useAppMode', () => ({
useAppMode: () => ({
mode: { value: 'graph' },
isAppMode: { value: false }
})
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({
trackShareLinkOpened: mockTrackShareLinkOpened
@@ -262,9 +255,7 @@ describe('useSharedWorkflowUrlLoader', () => {
)
expect(mockTrackShareLinkOpened).toHaveBeenCalledWith({
share_id: 'share-id-1',
is_authenticated: false,
view_mode: 'graph',
is_app_mode: false
is_authenticated: false
})
expect(preservedQueryMocks.capturePreservedQuery).toHaveBeenCalledWith(
'share_auth',
@@ -290,9 +281,7 @@ describe('useSharedWorkflowUrlLoader', () => {
expect(loaded).toBe('loaded')
expect(mockTrackShareLinkOpened).toHaveBeenCalledWith({
share_id: 'share-id-1',
is_authenticated: true,
view_mode: 'graph',
is_app_mode: false
is_authenticated: true
})
expect(preservedQueryMocks.capturePreservedQuery).not.toHaveBeenCalled()
})

View File

@@ -3,7 +3,6 @@ import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useAppMode } from '@/composables/useAppMode'
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
import { useTelemetry } from '@/platform/telemetry'
import OpenSharedWorkflowDialogContent from '@/platform/workflow/sharing/components/OpenSharedWorkflowDialogContent.vue'
@@ -46,7 +45,6 @@ export function useSharedWorkflowUrlLoader() {
const dialogStore = useDialogStore()
const templateSelectorDialog = useWorkflowTemplateSelectorDialog()
const { isLoggedIn } = useCurrentUser()
const { mode, isAppMode } = useAppMode()
const SHARE_NAMESPACE = PRESERVED_QUERY_NAMESPACES.SHARE
function isValidParameter(param: string): boolean {
@@ -148,9 +146,7 @@ export function useSharedWorkflowUrlLoader() {
useTelemetry()?.trackShareLinkOpened({
share_id: shareParam,
is_authenticated: isLoggedIn.value,
view_mode: mode.value,
is_app_mode: isAppMode.value
is_authenticated: isLoggedIn.value
})
if (!isLoggedIn.value) {
capturePreservedQuery(

View File

@@ -57,8 +57,7 @@ async function runButtonClick(e: Event) {
if (batchCount.value > 1) {
useTelemetry()?.trackUiButtonClicked({
button_id: 'queue_run_multiple_batches_submitted',
element_group: 'app_mode'
button_id: 'queue_run_multiple_batches_submitted'
})
}
await commandStore.execute(commandId, {

View File

@@ -674,8 +674,7 @@ const handleToggleAdvanced = () => {
const handleEnterSubgraph = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'graph_node_open_subgraph_clicked',
element_group: 'graph_node'
button_id: 'graph_node_open_subgraph_clicked'
})
const graph = app.rootGraph
if (!graph) {

View File

@@ -1,226 +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()
})
it('does not cancel when no session is held', () => {
startDrag()
expect(cancelLinkRelease).not.toHaveBeenCalled()
expect(mockAdapter.beginFromOutput).toHaveBeenCalled()
})
})

View File

@@ -32,7 +32,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 {
@@ -606,13 +605,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

@@ -103,8 +103,7 @@ export const useDialogService = () => {
size: 'lg',
onClose: () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'error_dialog_closed',
element_group: 'error_dialog'
button_id: 'error_dialog_closed'
})
}
}
@@ -170,8 +169,7 @@ export const useDialogService = () => {
size: 'lg',
onClose: () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'error_dialog_closed',
element_group: 'error_dialog'
button_id: 'error_dialog_closed'
})
}
}

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,
@@ -945,39 +944,9 @@ export const useLitegraphService = () => {
if (!graph || !node) return null
graph.add(node, addOptions)
if (!addOptions?.ghost) {
resolveOverlap(node, graph)
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

@@ -29,25 +29,6 @@ const {
mockTrackExecutionSuccess: vi.fn(),
mockTrackSharedWorkflowRun: vi.fn()
}))
const mockAppModeState = vi.hoisted(() => ({
mode: { value: 'graph' },
isAppMode: { value: false }
}))
vi.mock('@/composables/useAppMode', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>
return {
...actual,
useAppMode: () => mockAppModeState
}
})
beforeEach(() => {
mockAppModeState.mode.value = 'graph'
mockAppModeState.isAppMode.value = false
})
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
import { createTestingPinia } from '@pinia/testing'
@@ -1147,9 +1128,7 @@ describe('useExecutionStore - WebSocket event handlers', () => {
})
expect(mockTrackSharedWorkflowRun).toHaveBeenCalledWith({
job_id: 'job-1',
share_id: 'share-1',
view_mode: 'graph',
is_app_mode: false
share_id: 'share-1'
})
})
@@ -1169,56 +1148,7 @@ describe('useExecutionStore - WebSocket event handlers', () => {
expect(mockTrackSharedWorkflowRun).toHaveBeenCalledWith({
job_id: 'job-1',
share_id: 'share-1',
view_mode: 'graph',
is_app_mode: false
})
})
it('attributes shared workflow run to queue-time mode, not completion-time mode', () => {
const workflow = createQueuedWorkflow()
workflow.shareId = 'share-1'
store.storeJob({
nodes: ['a'],
id: 'job-1',
promptOutput: {
a: createPromptNode('Node A', 'NodeA')
},
workflow
})
mockAppModeState.mode.value = 'app'
mockAppModeState.isAppMode.value = true
fire('execution_success', { prompt_id: 'job-1', timestamp: 0 })
expect(mockTrackSharedWorkflowRun).toHaveBeenCalledWith({
job_id: 'job-1',
share_id: 'share-1',
view_mode: 'graph',
is_app_mode: false
})
})
it('attributes shared workflow run to the queued workflow, not the active one', () => {
const workflow = createQueuedWorkflow()
workflow.shareId = 'share-1'
workflow.activeMode = 'app'
store.storeJob({
nodes: ['a'],
id: 'job-1',
promptOutput: {
a: createPromptNode('Node A', 'NodeA')
},
workflow
})
fire('execution_success', { prompt_id: 'job-1', timestamp: 0 })
expect(mockTrackSharedWorkflowRun).toHaveBeenCalledWith({
job_id: 'job-1',
share_id: 'share-1',
view_mode: 'app',
is_app_mode: true
share_id: 'share-1'
})
})
})

View File

@@ -2,12 +2,6 @@ import { defineStore } from 'pinia'
import { computed, ref, shallowRef } from 'vue'
import { useNodeProgressText } from '@/composables/node/useNodeProgressText'
import type { AppMode } from '@/composables/useAppMode'
import {
getWorkflowMode,
isAppModeValue,
useAppMode
} from '@/composables/useAppMode'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
@@ -66,12 +60,6 @@ interface QueuedJob {
* `workflow.shareId`, which can gain attribution after the job was queued.
*/
shareId?: string
/**
* View-mode attribution snapshotted at queue time, so mode switches during
* the run don't misattribute completion events.
*/
viewMode?: AppMode
isAppMode?: boolean
}
function buildExecutionNodeLookup(
@@ -99,7 +87,6 @@ export const useExecutionStore = defineStore('execution', () => {
const workflowStore = useWorkflowStore()
const canvasStore = useCanvasStore()
const executionErrorStore = useExecutionErrorStore()
const { mode, isAppMode } = useAppMode()
const clientId = ref<string | null>(null)
const activeJobId = ref<JobId | null>(null)
@@ -323,9 +310,7 @@ export const useExecutionStore = defineStore('execution', () => {
if (queuedJob.shareId) {
telemetry?.trackSharedWorkflowRun({
job_id: jobId,
share_id: queuedJob.shareId,
view_mode: queuedJob.viewMode ?? mode.value,
is_app_mode: queuedJob.isAppMode ?? isAppMode.value
share_id: queuedJob.shareId
})
}
}
@@ -609,9 +594,6 @@ export const useExecutionStore = defineStore('execution', () => {
queuedJob.nodeLookup = buildExecutionNodeLookup(promptOutput)
queuedJob.workflow = workflow
queuedJob.shareId = workflow?.shareId
const queuedMode = getWorkflowMode(workflow)
queuedJob.viewMode = queuedMode
queuedJob.isAppMode = isAppModeValue(queuedMode)
const wid = workflow?.activeState?.id ?? workflow?.initialState?.id
if (wid) {
jobIdToWorkflowId.value.set(id, wid)

View File

@@ -3,24 +3,25 @@ import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import type { UUID } from '@/utils/uuid'
import type { WidgetState } from './widgetValueStore'
import { widgetId } from '@/types/widgetId'
import type { WidgetState } from '@/types/widgetState'
import { useWidgetValueStore } from './widgetValueStore'
function widget<T>(
nodeId: string,
name: string,
function state<T>(
type: string,
value: T,
extra: Partial<
Omit<WidgetState<T>, 'nodeId' | 'name' | 'type' | 'value'>
> = {}
): WidgetState<T> {
return { nodeId, name, type, value, options: {}, ...extra }
extra: Partial<Omit<WidgetState<T>, 'type' | 'value'>> = {}
): Omit<WidgetState<T>, 'nodeId' | 'name' | 'y'> & { y?: number } {
return { type, value, options: {}, ...extra }
}
describe('useWidgetValueStore', () => {
const graphA = 'graph-a' as UUID
const graphB = 'graph-b' as UUID
const seedA = widgetId(graphA, 'node-1', 'seed')
const seedB = widgetId(graphB, 'node-1', 'seed')
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
@@ -28,62 +29,92 @@ describe('useWidgetValueStore', () => {
describe('widgetState.value access', () => {
it('getWidget returns undefined for unregistered widget', () => {
const store = useWidgetValueStore()
expect(store.getWidget(graphA, 'missing', 'widget')).toBeUndefined()
expect(store.getWidget(seedA)).toBeUndefined()
})
it('widgetState.value can be read and written directly', () => {
const store = useWidgetValueStore()
const state = store.registerWidget(
graphA,
widget('node-1', 'seed', 'number', 100)
)
expect(state.value).toBe(100)
const registered = store.registerWidget(seedA, state('number', 100))
expect(registered.value).toBe(100)
state.value = 200
expect(store.getWidget(graphA, 'node-1', 'seed')?.value).toBe(200)
registered.value = 200
expect(store.getWidget(seedA)?.value).toBe(200)
})
it('stores different value types', () => {
const store = useWidgetValueStore()
store.registerWidget(graphA, widget('node-1', 'text', 'string', 'hello'))
store.registerWidget(graphA, widget('node-1', 'number', 'number', 42))
store.registerWidget(graphA, widget('node-1', 'boolean', 'toggle', true))
store.registerWidget(
graphA,
widget('node-1', 'array', 'combo', [1, 2, 3])
widgetId(graphA, 'node-1', 'text'),
state('string', 'hello')
)
store.registerWidget(
widgetId(graphA, 'node-1', 'number'),
state('number', 42)
)
store.registerWidget(
widgetId(graphA, 'node-1', 'boolean'),
state('toggle', true)
)
store.registerWidget(
widgetId(graphA, 'node-1', 'array'),
state('combo', [1, 2, 3])
)
expect(store.getWidget(graphA, 'node-1', 'text')?.value).toBe('hello')
expect(store.getWidget(graphA, 'node-1', 'number')?.value).toBe(42)
expect(store.getWidget(graphA, 'node-1', 'boolean')?.value).toBe(true)
expect(store.getWidget(graphA, 'node-1', 'array')?.value).toEqual([
1, 2, 3
])
expect(store.getWidget(widgetId(graphA, 'node-1', 'text'))?.value).toBe(
'hello'
)
expect(store.getWidget(widgetId(graphA, 'node-1', 'number'))?.value).toBe(
42
)
expect(
store.getWidget(widgetId(graphA, 'node-1', 'boolean'))?.value
).toBe(true)
expect(
store.getWidget(widgetId(graphA, 'node-1', 'array'))?.value
).toEqual([1, 2, 3])
})
})
describe('widget registration', () => {
it('registers a widget with minimal properties', () => {
const store = useWidgetValueStore()
const state = store.registerWidget(
graphA,
widget('node-1', 'seed', 'number', 12345)
const registered = store.registerWidget(seedA, state('number', 12345))
expect(registered.nodeId).toBe('node-1')
expect(registered.name).toBe('seed')
expect(registered.type).toBe('number')
expect(registered.value).toBe(12345)
expect(registered.disabled).toBeUndefined()
expect(registered.serialize).toBeUndefined()
expect(registered.options).toEqual({})
expect(registered.y).toBe(0)
})
it('registers explicit widget layout y', () => {
const store = useWidgetValueStore()
const registered = store.registerWidget(
seedA,
state('number', 12345, { y: 42 })
)
expect(state.nodeId).toBe('node-1')
expect(state.name).toBe('seed')
expect(state.type).toBe('number')
expect(state.value).toBe(12345)
expect(state.disabled).toBeUndefined()
expect(state.serialize).toBeUndefined()
expect(state.options).toEqual({})
expect(registered.y).toBe(42)
})
it('registerWidget is idempotent and does not overwrite existing state', () => {
const store = useWidgetValueStore()
const first = store.registerWidget(seedA, state('number', 11))
first.value = 99
const second = store.registerWidget(seedA, state('number', 11))
expect(second).toBe(first)
expect(second.value).toBe(99)
})
it('registers a widget with all properties', () => {
const store = useWidgetValueStore()
const state = store.registerWidget(
graphA,
widget('node-1', 'prompt', 'string', 'test', {
const registered = store.registerWidget(
seedA,
state('string', 'test', {
label: 'Prompt Text',
disabled: true,
serialize: false,
@@ -91,34 +122,38 @@ describe('useWidgetValueStore', () => {
})
)
expect(state.label).toBe('Prompt Text')
expect(state.disabled).toBe(true)
expect(state.serialize).toBe(false)
expect(state.options).toEqual({ multiline: true })
expect(registered.label).toBe('Prompt Text')
expect(registered.disabled).toBe(true)
expect(registered.serialize).toBe(false)
expect(registered.options).toEqual({ multiline: true })
})
})
describe('widget getters', () => {
it('getWidget returns widget state', () => {
const store = useWidgetValueStore()
store.registerWidget(graphA, widget('node-1', 'seed', 'number', 100))
store.registerWidget(seedA, state('number', 100))
const state = store.getWidget(graphA, 'node-1', 'seed')
expect(state).toBeDefined()
expect(state?.name).toBe('seed')
expect(state?.value).toBe(100)
})
it('getWidget returns undefined for missing widget', () => {
const store = useWidgetValueStore()
expect(store.getWidget(graphA, 'missing', 'widget')).toBeUndefined()
const registered = store.getWidget(seedA)
expect(registered).toBeDefined()
expect(registered?.name).toBe('seed')
expect(registered?.value).toBe(100)
})
it('getNodeWidgets returns all widgets for a node', () => {
const store = useWidgetValueStore()
store.registerWidget(graphA, widget('node-1', 'seed', 'number', 1))
store.registerWidget(graphA, widget('node-1', 'steps', 'number', 20))
store.registerWidget(graphA, widget('node-2', 'cfg', 'number', 7))
store.registerWidget(
widgetId(graphA, 'node-1', 'seed'),
state('number', 1)
)
store.registerWidget(
widgetId(graphA, 'node-1', 'steps'),
state('number', 20)
)
store.registerWidget(
widgetId(graphA, 'node-2', 'cfg'),
state('number', 7)
)
const widgets = store.getNodeWidgets(graphA, 'node-1')
expect(widgets).toHaveLength(2)
@@ -126,54 +161,231 @@ describe('useWidgetValueStore', () => {
})
})
describe('value mutation', () => {
it('setValue updates registered widgets and reports missing widgets', () => {
const store = useWidgetValueStore()
store.registerWidget(seedA, state('number', 100))
expect(store.setValue(seedA, 200)).toBe(true)
expect(store.getWidget(seedA)?.value).toBe(200)
expect(store.setValue(widgetId(graphA, 'missing', 'seed'), 1)).toBe(false)
})
it('deleteWidget removes registered widgets', () => {
const store = useWidgetValueStore()
store.registerWidget(seedA, state('number', 100))
expect(store.deleteWidget(seedA)).toBe(true)
expect(store.getWidget(seedA)).toBeUndefined()
expect(store.deleteWidget(seedA)).toBe(false)
})
})
describe('direct property mutation', () => {
it('disabled can be set directly via getWidget', () => {
const store = useWidgetValueStore()
const state = store.registerWidget(
graphA,
widget('node-1', 'seed', 'number', 100)
)
const registered = store.registerWidget(seedA, state('number', 100))
state.disabled = true
expect(store.getWidget(graphA, 'node-1', 'seed')?.disabled).toBe(true)
registered.disabled = true
expect(store.getWidget(seedA)?.disabled).toBe(true)
})
it('label can be set directly via getWidget', () => {
const store = useWidgetValueStore()
const state = store.registerWidget(
graphA,
widget('node-1', 'seed', 'number', 100)
)
const registered = store.registerWidget(seedA, state('number', 100))
state.label = 'Random Seed'
expect(store.getWidget(graphA, 'node-1', 'seed')?.label).toBe(
'Random Seed'
)
registered.label = 'Random Seed'
expect(store.getWidget(seedA)?.label).toBe('Random Seed')
state.label = undefined
expect(store.getWidget(graphA, 'node-1', 'seed')?.label).toBeUndefined()
registered.label = undefined
expect(store.getWidget(seedA)?.label).toBeUndefined()
})
})
describe('graph isolation', () => {
it('isolates widget states by graph', () => {
const store = useWidgetValueStore()
store.registerWidget(graphA, widget('node-1', 'seed', 'number', 1))
store.registerWidget(graphB, widget('node-1', 'seed', 'number', 2))
store.registerWidget(seedA, state('number', 1))
store.registerWidget(seedB, state('number', 2))
expect(store.getWidget(graphA, 'node-1', 'seed')?.value).toBe(1)
expect(store.getWidget(graphB, 'node-1', 'seed')?.value).toBe(2)
expect(store.getWidget(seedA)?.value).toBe(1)
expect(store.getWidget(seedB)?.value).toBe(2)
})
it('clearGraph only removes one graph namespace', () => {
const store = useWidgetValueStore()
store.registerWidget(graphA, widget('node-1', 'seed', 'number', 1))
store.registerWidget(graphB, widget('node-1', 'seed', 'number', 2))
store.registerWidget(seedA, state('number', 1))
store.registerWidget(seedB, state('number', 2))
store.clearGraph(graphA)
expect(store.getWidget(graphA, 'node-1', 'seed')).toBeUndefined()
expect(store.getWidget(graphB, 'node-1', 'seed')?.value).toBe(2)
expect(store.getWidget(seedA)).toBeUndefined()
expect(store.getWidget(seedB)?.value).toBe(2)
})
})
describe('setInputLinked', () => {
it('sets inputLinked to true on a registered widget', () => {
const store = useWidgetValueStore()
store.registerWidget(seedA, state('number', 1))
store.setInputLinked(seedA, true)
expect(store.getWidget(seedA)?.inputLinked).toBe(true)
})
it('sets inputLinked to false on a previously linked widget', () => {
const store = useWidgetValueStore()
store.registerWidget(seedA, state('number', 1))
store.setInputLinked(seedA, true)
store.setInputLinked(seedA, false)
expect(store.getWidget(seedA)?.inputLinked).toBe(false)
})
it('is a no-op when the widget is not registered', () => {
const store = useWidgetValueStore()
// Should not throw even for an unknown id
expect(() => store.setInputLinked(seedA, true)).not.toThrow()
})
})
describe('registerWidgetControl / getWidgetControls / deleteWidgetControl', () => {
const controlId = widgetId(graphA, 'node-1', 'control_after_generate')
it('registers a control component and returns the live state', () => {
const store = useWidgetValueStore()
store.registerWidget(seedA, state('number', 1))
const ctrl = store.registerWidgetControl(seedA, {
controlWidgetId: controlId
})
expect(ctrl.controlWidgetId).toBe(controlId)
expect(ctrl.filterWidgetId).toBeUndefined()
expect(ctrl.hasExecuted).toBe(false)
})
it('includes the control in getWidgetControls for the same graph', () => {
const store = useWidgetValueStore()
store.registerWidget(seedA, state('number', 1))
store.registerWidgetControl(seedA, { controlWidgetId: controlId })
const controls = store.getWidgetControls(graphA)
expect(controls).toHaveLength(1)
expect(controls[0][0]).toBe(seedA)
expect(controls[0][1].controlWidgetId).toBe(controlId)
})
it('returns an empty array when no controls are registered', () => {
const store = useWidgetValueStore()
expect(store.getWidgetControls(graphA)).toHaveLength(0)
})
it('preserves hasExecuted when re-registering the same control and filter ids', () => {
const store = useWidgetValueStore()
store.registerWidget(seedA, state('number', 1))
const ctrl = store.registerWidgetControl(seedA, {
controlWidgetId: controlId
})
ctrl.hasExecuted = true
const ctrl2 = store.registerWidgetControl(seedA, {
controlWidgetId: controlId
})
expect(ctrl2.hasExecuted).toBe(true)
})
it('resets hasExecuted when re-registering with a different control widget id', () => {
const store = useWidgetValueStore()
const otherControlId = widgetId(graphA, 'node-1', 'other_control')
store.registerWidget(seedA, state('number', 1))
const ctrl = store.registerWidgetControl(seedA, {
controlWidgetId: controlId
})
ctrl.hasExecuted = true
const ctrl2 = store.registerWidgetControl(seedA, {
controlWidgetId: otherControlId
})
expect(ctrl2.hasExecuted).toBe(false)
})
it('stores and returns optional filterWidgetId', () => {
const store = useWidgetValueStore()
const filterId = widgetId(graphA, 'node-1', 'filter')
store.registerWidget(seedA, state('number', 1))
const ctrl = store.registerWidgetControl(seedA, {
controlWidgetId: controlId,
filterWidgetId: filterId
})
expect(ctrl.filterWidgetId).toBe(filterId)
})
it('deleteWidgetControl removes the control and returns true', () => {
const store = useWidgetValueStore()
store.registerWidget(seedA, state('number', 1))
store.registerWidgetControl(seedA, { controlWidgetId: controlId })
expect(store.deleteWidgetControl(seedA)).toBe(true)
expect(store.getWidgetControls(graphA)).toHaveLength(0)
})
it('deleteWidgetControl returns false for an unregistered target', () => {
const store = useWidgetValueStore()
expect(store.deleteWidgetControl(seedA)).toBe(false)
})
it('isolates controls across graphs', () => {
const store = useWidgetValueStore()
const controlB = widgetId(graphB, 'node-1', 'control_after_generate')
store.registerWidget(seedA, state('number', 1))
store.registerWidget(seedB, state('number', 2))
store.registerWidgetControl(seedA, { controlWidgetId: controlId })
store.registerWidgetControl(seedB, { controlWidgetId: controlB })
expect(store.getWidgetControls(graphA)).toHaveLength(1)
expect(store.getWidgetControls(graphB)).toHaveLength(1)
})
})
describe('deleteWidget also removes the associated control', () => {
it('removes the widget control entry when the widget is deleted', () => {
const store = useWidgetValueStore()
const controlId = widgetId(graphA, 'node-1', 'control_after_generate')
store.registerWidget(seedA, state('number', 1))
store.registerWidgetControl(seedA, { controlWidgetId: controlId })
expect(store.getWidgetControls(graphA)).toHaveLength(1)
store.deleteWidget(seedA)
expect(store.getWidgetControls(graphA)).toHaveLength(0)
})
})
describe('clearGraph also removes controls', () => {
it('removes both widget states and control entries for the cleared graph', () => {
const store = useWidgetValueStore()
const controlId = widgetId(graphA, 'node-1', 'control_after_generate')
const controlB = widgetId(graphB, 'node-1', 'control_after_generate')
store.registerWidget(seedA, state('number', 1))
store.registerWidget(seedB, state('number', 2))
store.registerWidgetControl(seedA, { controlWidgetId: controlId })
store.registerWidgetControl(seedB, { controlWidgetId: controlB })
store.clearGraph(graphA)
expect(store.getWidget(seedA)).toBeUndefined()
expect(store.getWidgetControls(graphA)).toHaveLength(0)
// Other graph should be unaffected
expect(store.getWidget(seedB)?.value).toBe(2)
expect(store.getWidgetControls(graphB)).toHaveLength(1)
})
})
})

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

@@ -68,7 +68,6 @@ import DesktopCloudNotificationController from '@/platform/cloud/notification/co
import { isCloud, isDesktop } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { getShellLayoutSnapshot } from '@/platform/telemetry/utils/getShellLayoutSnapshot'
import { useFrontendVersionMismatchWarning } from '@/platform/updates/common/useFrontendVersionMismatchWarning'
import { useVersionCompatibilityStore } from '@/platform/updates/common/versionCompatibilityStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
@@ -352,11 +351,6 @@ const onGraphReady = () => {
tabCountChannel.postMessage({ type: 'heartbeat', tabId: currentTabId })
}
// Shell layout snapshot, once per session (cloud only)
if (isCloud && telemetry) {
telemetry.trackShellLayout(getShellLayoutSnapshot())
}
// Setting values now available after comfyApp.setup.
// Load keybindings.
wrapWithErrorHandling(useKeybindingService().registerUserKeybindings)()