mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-13 09:48:22 +00:00
Compare commits
1 Commits
uy/node-se
...
coderabbit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc11de54fa |
@@ -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 }) => {
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -40,8 +40,7 @@ function handleOpen(open: boolean) {
|
||||
if (open) {
|
||||
markAsSeen()
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: source,
|
||||
element_group: 'workflow_actions'
|
||||
button_id: source
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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') }
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
175
src/core/graph/widgets/control/useWidgetControlHooks.test.ts
Normal file
175
src/core/graph/widgets/control/useWidgetControlHooks.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
165
src/core/graph/widgets/control/valueControl.test.ts
Normal file
165
src/core/graph/widgets/control/valueControl.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
264
src/core/graph/widgets/widgetControlSystem.test.ts
Normal file
264
src/core/graph/widgets/widgetControlSystem.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -170,7 +170,6 @@ export type { TWidgetType, TWidgetValue, IWidgetOptions } from './types/widgets'
|
||||
export {
|
||||
findUsedSubgraphIds,
|
||||
getDirectSubgraphIds,
|
||||
isNodeSlot,
|
||||
isSubgraphInput,
|
||||
isSubgraphOutput
|
||||
} from './subgraph/subgraphUtils'
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -1,7 +0,0 @@
|
||||
import type { ActionbarDockState } from '@/platform/telemetry/types'
|
||||
|
||||
export function getActionbarDockState(): ActionbarDockState {
|
||||
return localStorage.getItem('Comfy.MenuPosition.Docked') === 'false'
|
||||
? 'floating'
|
||||
: 'docked'
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}))
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)()
|
||||
|
||||
Reference in New Issue
Block a user