Compare commits
34 Commits
cloud/mode
...
core/1.38
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
841cf55fbd | ||
|
|
43b66ec5e5 | ||
|
|
73e51572a0 | ||
|
|
fdd1bd3406 | ||
|
|
94101d81d7 | ||
|
|
f741fb51e7 | ||
|
|
7dfadb5f42 | ||
|
|
8d9243e841 | ||
|
|
b226b6db22 | ||
|
|
35e5f37221 | ||
|
|
c8fd9a5374 | ||
|
|
ded366008e | ||
|
|
d48e99db7c | ||
|
|
c138670bf6 | ||
|
|
e4f1950af5 | ||
|
|
44e630d00f | ||
|
|
d27f9faa9e | ||
|
|
c902869b2c | ||
|
|
ff9823e8f0 | ||
|
|
bc4e060e92 | ||
|
|
bc31970939 | ||
|
|
6bab72feb9 | ||
|
|
390deac188 | ||
|
|
e4d1554b80 | ||
|
|
94956089f1 | ||
|
|
af3f96c0ca | ||
|
|
ada3145c2d | ||
|
|
89c76f6861 | ||
|
|
b660638f22 | ||
|
|
a96938a495 | ||
|
|
f6b571013d | ||
|
|
54e8775acb | ||
|
|
13e8aa7466 | ||
|
|
7a224efaa0 |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 80 KiB |
@@ -11,7 +11,6 @@ This guide covers patterns and examples for unit testing utilities, composables,
|
||||
5. [Mocking Utility Functions](#mocking-utility-functions)
|
||||
6. [Testing with Debounce and Throttle](#testing-with-debounce-and-throttle)
|
||||
7. [Mocking Node Definitions](#mocking-node-definitions)
|
||||
8. [Mocking Composables with Reactive State](#mocking-composables-with-reactive-state)
|
||||
|
||||
## Testing Vue Composables with Reactivity
|
||||
|
||||
@@ -254,79 +253,3 @@ it('should validate node definition', () => {
|
||||
expect(validateComfyNodeDef(EXAMPLE_NODE_DEF)).not.toBeNull()
|
||||
})
|
||||
```
|
||||
|
||||
## Mocking Composables with Reactive State
|
||||
|
||||
When mocking composables that return reactive refs, define the mock implementation inline in `vi.mock()`'s factory function. This ensures stable singleton instances across all test invocations.
|
||||
|
||||
### Rules
|
||||
|
||||
1. **Define mocks in the factory function** — Create `vi.fn()` and `ref()` instances directly inside `vi.mock()`, not in `beforeEach`
|
||||
2. **Use singleton pattern** — The factory runs once; all calls to the composable return the same mock object
|
||||
3. **Access mocks per-test** — Call the composable directly in each test to get the singleton instance rather than storing in a shared variable
|
||||
4. **Wrap in `vi.mocked()` for type safety** — Use `vi.mocked(service.method).mockResolvedValue(...)` when configuring
|
||||
5. **Rely on `vi.resetAllMocks()`** — Resets call counts without recreating instances; ref values may need manual reset if mutated
|
||||
|
||||
### Pattern
|
||||
|
||||
```typescript
|
||||
// Example from: src/platform/updates/common/releaseStore.test.ts
|
||||
import { ref } from 'vue'
|
||||
|
||||
vi.mock('@/path/to/composable', () => {
|
||||
const doSomething = vi.fn()
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
return {
|
||||
useMyComposable: () => ({
|
||||
doSomething,
|
||||
isLoading,
|
||||
error
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('MyStore', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should call the composable method', async () => {
|
||||
const service = useMyComposable()
|
||||
vi.mocked(service.doSomething).mockResolvedValue({ data: 'test' })
|
||||
|
||||
await store.initialize()
|
||||
|
||||
expect(service.doSomething).toHaveBeenCalledWith(expectedArgs)
|
||||
})
|
||||
|
||||
it('should handle errors from the composable', async () => {
|
||||
const service = useMyComposable()
|
||||
vi.mocked(service.doSomething).mockResolvedValue(null)
|
||||
service.error.value = 'Something went wrong'
|
||||
|
||||
await store.initialize()
|
||||
|
||||
expect(store.error).toBe('Something went wrong')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Anti-patterns
|
||||
|
||||
```typescript
|
||||
// ❌ Don't configure mock return values in beforeEach with shared variable
|
||||
let mockService: { doSomething: Mock }
|
||||
beforeEach(() => {
|
||||
mockService = { doSomething: vi.fn() }
|
||||
vi.mocked(useMyComposable).mockReturnValue(mockService)
|
||||
})
|
||||
|
||||
// ❌ Don't auto-mock then override — reactive refs won't work correctly
|
||||
vi.mock('@/path/to/composable')
|
||||
vi.mocked(useMyComposable).mockReturnValue({ isLoading: ref(false) })
|
||||
```
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
|
||||
42
index.html
@@ -35,18 +35,6 @@
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
#vue-app:has(#loading-logo) {
|
||||
display: contents;
|
||||
color: var(--fg-color);
|
||||
& #loading-logo {
|
||||
place-self: center;
|
||||
font-size: clamp(2px, 1vw, 6px);
|
||||
line-height: 1;
|
||||
overflow: hidden;
|
||||
max-width: 100vw;
|
||||
border-radius: 20ch;
|
||||
}
|
||||
}
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
@@ -65,36 +53,6 @@
|
||||
<body class="litegraph grid">
|
||||
<div id="vue-app">
|
||||
<span class="visually-hidden" role="status">Loading ComfyUI...</span>
|
||||
<svg
|
||||
width="520"
|
||||
height="520"
|
||||
viewBox="0 0 520 520"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
id="loading-logo"
|
||||
>
|
||||
<mask
|
||||
id="mask0_227_285"
|
||||
style="mask-type: alpha"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="0"
|
||||
width="520"
|
||||
height="520"
|
||||
>
|
||||
<path
|
||||
d="M0 184.335C0 119.812 0 87.5502 12.5571 62.9055C23.6026 41.2274 41.2274 23.6026 62.9055 12.5571C87.5502 0 119.812 0 184.335 0H335.665C400.188 0 432.45 0 457.094 12.5571C478.773 23.6026 496.397 41.2274 507.443 62.9055C520 87.5502 520 119.812 520 184.335V335.665C520 400.188 520 432.45 507.443 457.094C496.397 478.773 478.773 496.397 457.094 507.443C432.45 520 400.188 520 335.665 520H184.335C119.812 520 87.5502 520 62.9055 507.443C41.2274 496.397 23.6026 478.773 12.5571 457.094C0 432.45 0 400.188 0 335.665V184.335Z"
|
||||
fill="#EEFF30"
|
||||
/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_227_285)">
|
||||
<rect y="0.751831" width="520" height="520" fill="#172DD7" />
|
||||
<path
|
||||
d="M176.484 428.831C168.649 428.831 162.327 425.919 158.204 420.412C153.966 414.755 152.861 406.857 155.171 398.749L164.447 366.178C165.187 363.585 164.672 360.794 163.059 358.636C161.446 356.483 158.921 355.216 156.241 355.216H129.571C121.731 355.216 115.409 352.308 111.289 346.802C107.051 341.14 105.946 333.242 108.258 325.134L140.124 213.748L143.642 201.51C148.371 184.904 165.62 171.407 182.097 171.407H214.009C217.817 171.407 221.167 168.868 222.215 165.183L232.769 128.135C237.494 111.545 254.742 98.048 271.219 98.048L339.468 97.9264L389.431 97.9221C397.268 97.9221 403.59 100.831 407.711 106.337C411.949 111.994 413.054 119.892 410.744 128L396.457 178.164C391.734 194.75 374.485 208.242 358.009 208.242L289.607 208.372H257.706C253.902 208.372 250.557 210.907 249.502 214.588L222.903 307.495C222.159 310.093 222.673 312.892 224.291 315.049C225.904 317.202 228.428 318.469 231.107 318.469C231.113 318.469 276.307 318.381 276.307 318.381H326.122C333.959 318.381 340.281 321.29 344.402 326.796C348.639 332.457 349.744 340.355 347.433 348.463L333.146 398.619C328.423 415.209 311.174 428.701 294.698 428.701L226.299 428.831H176.484Z"
|
||||
fill="#F0FF41"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<script type="module" src="src/main.ts"></script>
|
||||
</body>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.39.0",
|
||||
"version": "1.38.14",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
17
src/App.vue
@@ -1,11 +1,13 @@
|
||||
<template>
|
||||
<router-view />
|
||||
<ProgressSpinner
|
||||
v-if="isLoading"
|
||||
class="absolute inset-0 flex h-[unset] items-center justify-center"
|
||||
/>
|
||||
<GlobalDialog />
|
||||
<BlockUI full-screen :blocked="isLoading" />
|
||||
<WorkspaceAuthGate>
|
||||
<router-view />
|
||||
<ProgressSpinner
|
||||
v-if="isLoading"
|
||||
class="absolute inset-0 flex h-[unset] items-center justify-center"
|
||||
/>
|
||||
<GlobalDialog />
|
||||
<BlockUI full-screen :blocked="isLoading" />
|
||||
</WorkspaceAuthGate>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -14,6 +16,7 @@ import BlockUI from 'primevue/blockui'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { computed, onMounted } from 'vue'
|
||||
|
||||
import WorkspaceAuthGate from '@/components/auth/WorkspaceAuthGate.vue'
|
||||
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
|
||||
import config from '@/config'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
|
||||
@@ -2,8 +2,7 @@ import { createTestingPinia } from '@pinia/testing'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, defineComponent, h, nextTick, onMounted } from 'vue'
|
||||
import type { Component } from 'vue'
|
||||
import { computed, nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import TopMenuSection from '@/components/TopMenuSection.vue'
|
||||
@@ -15,7 +14,6 @@ import type {
|
||||
} from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
import { isElectron } from '@/utils/envUtil'
|
||||
@@ -38,17 +36,7 @@ vi.mock('@/stores/firebaseAuthStore', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
type WrapperOptions = {
|
||||
pinia?: ReturnType<typeof createTestingPinia>
|
||||
stubs?: Record<string, boolean | Component>
|
||||
attachTo?: HTMLElement
|
||||
}
|
||||
|
||||
function createWrapper({
|
||||
pinia = createTestingPinia({ createSpy: vi.fn }),
|
||||
stubs = {},
|
||||
attachTo
|
||||
}: WrapperOptions = {}) {
|
||||
function createWrapper(pinia = createTestingPinia({ createSpy: vi.fn })) {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
@@ -67,21 +55,18 @@ function createWrapper({
|
||||
})
|
||||
|
||||
return mount(TopMenuSection, {
|
||||
attachTo,
|
||||
global: {
|
||||
plugins: [pinia, i18n],
|
||||
stubs: {
|
||||
SubgraphBreadcrumb: true,
|
||||
QueueProgressOverlay: true,
|
||||
QueueInlineProgressSummary: true,
|
||||
CurrentUserButton: true,
|
||||
LoginButton: true,
|
||||
ContextMenu: {
|
||||
name: 'ContextMenu',
|
||||
props: ['model'],
|
||||
template: '<div />'
|
||||
},
|
||||
...stubs
|
||||
}
|
||||
},
|
||||
directives: {
|
||||
tooltip: () => {}
|
||||
@@ -106,7 +91,6 @@ function createTask(id: string, status: JobStatus): TaskItemImpl {
|
||||
describe('TopMenuSection', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
describe('authentication state', () => {
|
||||
@@ -167,7 +151,7 @@ describe('TopMenuSection', () => {
|
||||
vi.mocked(settingStore.get).mockImplementation((key) =>
|
||||
key === 'Comfy.Queue.QPOV2' ? true : undefined
|
||||
)
|
||||
const wrapper = createWrapper({ pinia })
|
||||
const wrapper = createWrapper(pinia)
|
||||
|
||||
await nextTick()
|
||||
|
||||
@@ -185,7 +169,7 @@ describe('TopMenuSection', () => {
|
||||
vi.mocked(settingStore.get).mockImplementation((key) =>
|
||||
key === 'Comfy.Queue.QPOV2' ? false : undefined
|
||||
)
|
||||
const wrapper = createWrapper({ pinia })
|
||||
const wrapper = createWrapper(pinia)
|
||||
const commandStore = useCommandStore(pinia)
|
||||
|
||||
await wrapper.find('[data-testid="queue-overlay-toggle"]').trigger('click')
|
||||
@@ -201,7 +185,7 @@ describe('TopMenuSection', () => {
|
||||
vi.mocked(settingStore.get).mockImplementation((key) =>
|
||||
key === 'Comfy.Queue.QPOV2' ? true : undefined
|
||||
)
|
||||
const wrapper = createWrapper({ pinia })
|
||||
const wrapper = createWrapper(pinia)
|
||||
const sidebarTabStore = useSidebarTabStore(pinia)
|
||||
|
||||
await wrapper.find('[data-testid="queue-overlay-toggle"]').trigger('click')
|
||||
@@ -215,7 +199,7 @@ describe('TopMenuSection', () => {
|
||||
vi.mocked(settingStore.get).mockImplementation((key) =>
|
||||
key === 'Comfy.Queue.QPOV2' ? true : undefined
|
||||
)
|
||||
const wrapper = createWrapper({ pinia })
|
||||
const wrapper = createWrapper(pinia)
|
||||
const sidebarTabStore = useSidebarTabStore(pinia)
|
||||
const toggleButton = wrapper.find('[data-testid="queue-overlay-toggle"]')
|
||||
|
||||
@@ -226,84 +210,6 @@ describe('TopMenuSection', () => {
|
||||
expect(sidebarTabStore.activeSidebarTabId).toBe(null)
|
||||
})
|
||||
|
||||
describe('inline progress summary', () => {
|
||||
const configureSettings = (
|
||||
pinia: ReturnType<typeof createTestingPinia>,
|
||||
qpoV2Enabled: boolean
|
||||
) => {
|
||||
const settingStore = useSettingStore(pinia)
|
||||
vi.mocked(settingStore.get).mockImplementation((key) => {
|
||||
if (key === 'Comfy.Queue.QPOV2') return qpoV2Enabled
|
||||
if (key === 'Comfy.UseNewMenu') return 'Top'
|
||||
return undefined
|
||||
})
|
||||
}
|
||||
|
||||
it('renders inline progress summary when QPO V2 is enabled', async () => {
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn })
|
||||
configureSettings(pinia, true)
|
||||
|
||||
const wrapper = createWrapper({ pinia })
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(
|
||||
wrapper.findComponent({ name: 'QueueInlineProgressSummary' }).exists()
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('does not render inline progress summary when QPO V2 is disabled', async () => {
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn })
|
||||
configureSettings(pinia, false)
|
||||
|
||||
const wrapper = createWrapper({ pinia })
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(
|
||||
wrapper.findComponent({ name: 'QueueInlineProgressSummary' }).exists()
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('teleports inline progress summary when actionbar is floating', async () => {
|
||||
localStorage.setItem('Comfy.MenuPosition.Docked', 'false')
|
||||
const actionbarTarget = document.createElement('div')
|
||||
document.body.appendChild(actionbarTarget)
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn })
|
||||
configureSettings(pinia, true)
|
||||
const executionStore = useExecutionStore(pinia)
|
||||
executionStore.activePromptId = 'prompt-1'
|
||||
|
||||
const ComfyActionbarStub = defineComponent({
|
||||
name: 'ComfyActionbar',
|
||||
setup(_, { emit }) {
|
||||
onMounted(() => {
|
||||
emit('update:progressTarget', actionbarTarget)
|
||||
})
|
||||
return () => h('div')
|
||||
}
|
||||
})
|
||||
|
||||
const wrapper = createWrapper({
|
||||
pinia,
|
||||
attachTo: document.body,
|
||||
stubs: {
|
||||
ComfyActionbar: ComfyActionbarStub,
|
||||
QueueInlineProgressSummary: false
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
await nextTick()
|
||||
|
||||
expect(actionbarTarget.querySelector('[role="status"]')).not.toBeNull()
|
||||
} finally {
|
||||
wrapper.unmount()
|
||||
actionbarTarget.remove()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('disables the clear queue context menu item when no queued jobs exist', () => {
|
||||
const wrapper = createWrapper()
|
||||
const menu = wrapper.findComponent({ name: 'ContextMenu' })
|
||||
|
||||
@@ -1,130 +1,101 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="!workspaceStore.focusMode"
|
||||
class="ml-1 flex flex-col gap-1 pt-1"
|
||||
class="ml-1 flex gap-x-0.5 pt-1"
|
||||
@mouseenter="isTopMenuHovered = true"
|
||||
@mouseleave="isTopMenuHovered = false"
|
||||
>
|
||||
<div class="flex gap-x-0.5">
|
||||
<div class="min-w-0 flex-1">
|
||||
<SubgraphBreadcrumb />
|
||||
</div>
|
||||
|
||||
<div class="mx-1 flex flex-col items-end gap-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
v-if="managerState.shouldShowManagerButtons.value"
|
||||
class="pointer-events-auto flex h-12 shrink-0 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
|
||||
>
|
||||
<Button
|
||||
v-tooltip.bottom="customNodesManagerTooltipConfig"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
:aria-label="t('menu.customNodesManager')"
|
||||
class="relative"
|
||||
@click="openCustomNodeManager"
|
||||
>
|
||||
<i class="icon-[lucide--puzzle] size-4" />
|
||||
<span
|
||||
v-if="shouldShowRedDot"
|
||||
class="absolute top-0.5 right-1 size-2 rounded-full bg-red-500"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref="actionbarContainerRef"
|
||||
class="actionbar-container relative pointer-events-auto flex gap-2 h-12 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
|
||||
>
|
||||
<ActionBarButtons />
|
||||
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
|
||||
<div
|
||||
ref="legacyCommandsContainerRef"
|
||||
class="[&:not(:has(*>*:not(:empty)))]:hidden"
|
||||
></div>
|
||||
<ComfyActionbar
|
||||
:top-menu-container="actionbarContainerRef"
|
||||
:queue-overlay-expanded="isQueueOverlayExpanded"
|
||||
@update:progress-target="updateProgressTarget"
|
||||
/>
|
||||
<Button
|
||||
v-tooltip.bottom="queueHistoryTooltipConfig"
|
||||
type="destructive"
|
||||
size="md"
|
||||
:aria-pressed="
|
||||
isQueuePanelV2Enabled
|
||||
? activeSidebarTabId === 'assets'
|
||||
: isQueueProgressOverlayEnabled
|
||||
? isQueueOverlayExpanded
|
||||
: undefined
|
||||
"
|
||||
class="px-3"
|
||||
data-testid="queue-overlay-toggle"
|
||||
@click="toggleQueueOverlay"
|
||||
@contextmenu.stop.prevent="showQueueContextMenu"
|
||||
>
|
||||
<span class="text-sm font-normal tabular-nums">
|
||||
{{ activeJobsLabel }}
|
||||
</span>
|
||||
<span class="sr-only">
|
||||
{{
|
||||
isQueuePanelV2Enabled
|
||||
? t('sideToolbar.queueProgressOverlay.viewJobHistory')
|
||||
: t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
|
||||
}}
|
||||
</span>
|
||||
</Button>
|
||||
<ContextMenu
|
||||
ref="queueContextMenu"
|
||||
:model="queueContextMenuItems"
|
||||
/>
|
||||
<CurrentUserButton
|
||||
v-if="isLoggedIn && !isIntegratedTabBar"
|
||||
class="shrink-0"
|
||||
/>
|
||||
<LoginButton v-else-if="isDesktop && !isIntegratedTabBar" />
|
||||
<Button
|
||||
v-if="!isRightSidePanelOpen"
|
||||
v-tooltip.bottom="rightSidePanelTooltipConfig"
|
||||
type="secondary"
|
||||
size="icon"
|
||||
:aria-label="t('rightSidePanel.togglePanel')"
|
||||
@click="rightSidePanelStore.togglePanel"
|
||||
>
|
||||
<i class="icon-[lucide--panel-right] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<QueueProgressOverlay
|
||||
v-if="isQueueProgressOverlayEnabled"
|
||||
v-model:expanded="isQueueOverlayExpanded"
|
||||
:menu-hovered="isTopMenuHovered"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<SubgraphBreadcrumb />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Teleport
|
||||
v-if="inlineProgressSummaryTarget"
|
||||
:to="inlineProgressSummaryTarget"
|
||||
>
|
||||
<div class="mx-1 flex flex-col items-end gap-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="pointer-events-none absolute left-0 right-0 top-full mt-1 flex justify-end pr-1"
|
||||
v-if="managerState.shouldShowManagerButtons.value"
|
||||
class="pointer-events-auto flex h-12 shrink-0 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
|
||||
>
|
||||
<QueueInlineProgressSummary :hidden="isQueueOverlayExpanded" />
|
||||
<Button
|
||||
v-tooltip.bottom="customNodesManagerTooltipConfig"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
:aria-label="t('menu.customNodesManager')"
|
||||
class="relative"
|
||||
@click="openCustomNodeManager"
|
||||
>
|
||||
<i class="icon-[lucide--puzzle] size-4" />
|
||||
<span
|
||||
v-if="shouldShowRedDot"
|
||||
class="absolute top-0.5 right-1 size-2 rounded-full bg-red-500"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</Teleport>
|
||||
<QueueInlineProgressSummary
|
||||
v-else-if="shouldShowInlineProgressSummary && !isActionbarFloating"
|
||||
class="pr-1"
|
||||
:hidden="isQueueOverlayExpanded"
|
||||
|
||||
<div
|
||||
class="actionbar-container pointer-events-auto flex gap-2 h-12 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
|
||||
>
|
||||
<ActionBarButtons />
|
||||
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
|
||||
<div
|
||||
ref="legacyCommandsContainerRef"
|
||||
class="[&:not(:has(*>*:not(:empty)))]:hidden"
|
||||
></div>
|
||||
<ComfyActionbar />
|
||||
<Button
|
||||
v-tooltip.bottom="queueHistoryTooltipConfig"
|
||||
type="destructive"
|
||||
size="md"
|
||||
:aria-pressed="
|
||||
isQueuePanelV2Enabled
|
||||
? activeSidebarTabId === 'assets'
|
||||
: isQueueProgressOverlayEnabled
|
||||
? isQueueOverlayExpanded
|
||||
: undefined
|
||||
"
|
||||
class="px-3"
|
||||
data-testid="queue-overlay-toggle"
|
||||
@click="toggleQueueOverlay"
|
||||
@contextmenu.stop.prevent="showQueueContextMenu"
|
||||
>
|
||||
<span class="text-sm font-normal tabular-nums">
|
||||
{{ activeJobsLabel }}
|
||||
</span>
|
||||
<span class="sr-only">
|
||||
{{
|
||||
isQueuePanelV2Enabled
|
||||
? t('sideToolbar.queueProgressOverlay.viewJobHistory')
|
||||
: t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
|
||||
}}
|
||||
</span>
|
||||
</Button>
|
||||
<ContextMenu ref="queueContextMenu" :model="queueContextMenuItems" />
|
||||
<CurrentUserButton
|
||||
v-if="isLoggedIn && !isIntegratedTabBar"
|
||||
class="shrink-0"
|
||||
/>
|
||||
<LoginButton v-else-if="isDesktop && !isIntegratedTabBar" />
|
||||
<Button
|
||||
v-if="!isRightSidePanelOpen"
|
||||
v-tooltip.bottom="rightSidePanelTooltipConfig"
|
||||
type="secondary"
|
||||
size="icon"
|
||||
:aria-label="t('rightSidePanel.togglePanel')"
|
||||
@click="rightSidePanelStore.togglePanel"
|
||||
>
|
||||
<i class="icon-[lucide--panel-right] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<QueueProgressOverlay
|
||||
v-if="isQueueProgressOverlayEnabled"
|
||||
v-model:expanded="isQueueOverlayExpanded"
|
||||
:menu-hovered="isTopMenuHovered"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import ContextMenu from 'primevue/contextmenu'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
@@ -133,7 +104,6 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ComfyActionbar from '@/components/actionbar/ComfyActionbar.vue'
|
||||
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
|
||||
import QueueInlineProgressSummary from '@/components/queue/QueueInlineProgressSummary.vue'
|
||||
import QueueProgressOverlay from '@/components/queue/QueueProgressOverlay.vue'
|
||||
import ActionBarButtons from '@/components/topbar/ActionBarButtons.vue'
|
||||
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
|
||||
@@ -177,15 +147,6 @@ const { shouldShowRedDot: showReleaseRedDot } = storeToRefs(releaseStore)
|
||||
const { shouldShowRedDot: shouldShowConflictRedDot } =
|
||||
useConflictAcknowledgment()
|
||||
const isTopMenuHovered = ref(false)
|
||||
const actionbarContainerRef = ref<HTMLElement>()
|
||||
const isActionbarDocked = useLocalStorage('Comfy.MenuPosition.Docked', true)
|
||||
const actionbarPosition = computed(() => settingStore.get('Comfy.UseNewMenu'))
|
||||
const isActionbarEnabled = computed(
|
||||
() => actionbarPosition.value !== 'Disabled'
|
||||
)
|
||||
const isActionbarFloating = computed(
|
||||
() => isActionbarEnabled.value && !isActionbarDocked.value
|
||||
)
|
||||
const activeJobsLabel = computed(() => {
|
||||
const count = activeJobsCount.value
|
||||
return t(
|
||||
@@ -203,19 +164,6 @@ const isQueuePanelV2Enabled = computed(() =>
|
||||
const isQueueProgressOverlayEnabled = computed(
|
||||
() => !isQueuePanelV2Enabled.value
|
||||
)
|
||||
const shouldShowInlineProgressSummary = computed(
|
||||
() => isQueuePanelV2Enabled.value && isActionbarEnabled.value
|
||||
)
|
||||
const progressTarget = ref<HTMLElement | null>(null)
|
||||
function updateProgressTarget(target: HTMLElement | null) {
|
||||
progressTarget.value = target
|
||||
}
|
||||
const inlineProgressSummaryTarget = computed(() => {
|
||||
if (!shouldShowInlineProgressSummary.value || !isActionbarFloating.value) {
|
||||
return null
|
||||
}
|
||||
return progressTarget.value
|
||||
})
|
||||
const queueHistoryTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
|
||||
)
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
</div>
|
||||
|
||||
<Panel
|
||||
ref="panelRef"
|
||||
class="pointer-events-auto"
|
||||
:style="style"
|
||||
:class="panelClass"
|
||||
@@ -19,7 +18,7 @@
|
||||
content: { class: isDocked ? 'p-0' : 'p-1' }
|
||||
}"
|
||||
>
|
||||
<div class="relative flex items-center select-none gap-2">
|
||||
<div ref="panelRef" class="flex items-center select-none gap-2">
|
||||
<span
|
||||
ref="dragHandleRef"
|
||||
:class="
|
||||
@@ -44,14 +43,6 @@
|
||||
</Button>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Teleport v-if="inlineProgressTarget" :to="inlineProgressTarget">
|
||||
<QueueInlineProgress
|
||||
:hidden="queueOverlayExpanded"
|
||||
:radius-class="cn(isDocked ? 'rounded-[7px]' : 'rounded-[5px]')"
|
||||
data-testid="queue-inline-progress"
|
||||
/>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -60,17 +51,14 @@ import {
|
||||
useDraggable,
|
||||
useEventListener,
|
||||
useLocalStorage,
|
||||
unrefElement,
|
||||
watchDebounced
|
||||
} from '@vueuse/core'
|
||||
import { clamp } from 'es-toolkit/compat'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import Panel from 'primevue/panel'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import type { ComponentPublicInstance } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import QueueInlineProgress from '@/components/queue/QueueInlineProgress.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
@@ -81,15 +69,6 @@ import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import ComfyRunButton from './ComfyRunButton'
|
||||
|
||||
const { topMenuContainer, queueOverlayExpanded = false } = defineProps<{
|
||||
topMenuContainer?: HTMLElement | null
|
||||
queueOverlayExpanded?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:progressTarget', target: HTMLElement | null): void
|
||||
}>()
|
||||
|
||||
const settingsStore = useSettingStore()
|
||||
const commandStore = useCommandStore()
|
||||
const { t } = useI18n()
|
||||
@@ -97,22 +76,15 @@ const { isIdle: isExecutionIdle } = storeToRefs(useExecutionStore())
|
||||
|
||||
const position = computed(() => settingsStore.get('Comfy.UseNewMenu'))
|
||||
const visible = computed(() => position.value !== 'Disabled')
|
||||
const isQueuePanelV2Enabled = computed(() =>
|
||||
settingsStore.get('Comfy.Queue.QPOV2')
|
||||
)
|
||||
|
||||
const panelRef = ref<ComponentPublicInstance | null>(null)
|
||||
const panelElement = computed<HTMLElement | null>(() => {
|
||||
const element = unrefElement(panelRef)
|
||||
return element instanceof HTMLElement ? element : null
|
||||
})
|
||||
const panelRef = ref<HTMLElement | null>(null)
|
||||
const dragHandleRef = ref<HTMLElement | null>(null)
|
||||
const isDocked = useLocalStorage('Comfy.MenuPosition.Docked', true)
|
||||
const storedPosition = useLocalStorage('Comfy.MenuPosition.Floating', {
|
||||
x: 0,
|
||||
y: 0
|
||||
})
|
||||
const { x, y, style, isDragging } = useDraggable(panelElement, {
|
||||
const { x, y, style, isDragging } = useDraggable(panelRef, {
|
||||
initialValue: { x: 0, y: 0 },
|
||||
handle: dragHandleRef,
|
||||
containerElement: document.body
|
||||
@@ -129,12 +101,11 @@ watchDebounced(
|
||||
|
||||
// Set initial position to bottom center
|
||||
const setInitialPosition = () => {
|
||||
const panel = panelElement.value
|
||||
if (panel) {
|
||||
if (panelRef.value) {
|
||||
const screenWidth = window.innerWidth
|
||||
const screenHeight = window.innerHeight
|
||||
const menuWidth = panel.offsetWidth
|
||||
const menuHeight = panel.offsetHeight
|
||||
const menuWidth = panelRef.value.offsetWidth
|
||||
const menuHeight = panelRef.value.offsetHeight
|
||||
|
||||
if (menuWidth === 0 || menuHeight === 0) {
|
||||
return
|
||||
@@ -210,12 +181,11 @@ watch(
|
||||
)
|
||||
|
||||
const adjustMenuPosition = () => {
|
||||
const panel = panelElement.value
|
||||
if (panel) {
|
||||
if (panelRef.value) {
|
||||
const screenWidth = window.innerWidth
|
||||
const screenHeight = window.innerHeight
|
||||
const menuWidth = panel.offsetWidth
|
||||
const menuHeight = panel.offsetHeight
|
||||
const menuWidth = panelRef.value.offsetWidth
|
||||
const menuHeight = panelRef.value.offsetHeight
|
||||
|
||||
// Calculate distances to all edges
|
||||
const distanceLeft = lastDragState.value.x
|
||||
@@ -282,19 +252,6 @@ const onMouseLeaveDropZone = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const inlineProgressTarget = computed(() => {
|
||||
if (!visible.value || !isQueuePanelV2Enabled.value) return null
|
||||
if (isDocked.value) return topMenuContainer ?? null
|
||||
return panelElement.value
|
||||
})
|
||||
watch(
|
||||
panelElement,
|
||||
(target) => {
|
||||
emit('update:progressTarget', target)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Handle drag state changes
|
||||
watch(isDragging, (dragging) => {
|
||||
if (dragging) {
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
import { promiseTimeout, until } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
@@ -120,10 +120,7 @@ async function initializeWorkspaceMode(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize on mount. This gate should be placed on the authenticated layout
|
||||
// (LayoutDefault) so it mounts fresh after login and unmounts on logout.
|
||||
// The router guard ensures only authenticated users reach this layout.
|
||||
onMounted(() => {
|
||||
void initialize()
|
||||
})
|
||||
// Start initialization immediately during component setup
|
||||
// (not in onMounted, so initialization starts before DOM is ready)
|
||||
void initialize()
|
||||
</script>
|
||||
|
||||
@@ -14,12 +14,7 @@
|
||||
</template>
|
||||
|
||||
<template #header>
|
||||
<SearchBox
|
||||
v-model="searchQuery"
|
||||
size="lg"
|
||||
class="max-w-[384px]"
|
||||
autofocus
|
||||
/>
|
||||
<SearchBox v-model="searchQuery" size="lg" class="max-w-[384px]" />
|
||||
</template>
|
||||
|
||||
<template #header-right-area>
|
||||
@@ -772,7 +767,7 @@ useIntersectionObserver(loadTrigger, () => {
|
||||
// Reset pagination when filters change
|
||||
watch(
|
||||
[
|
||||
searchQuery,
|
||||
filteredTemplates,
|
||||
selectedNavItem,
|
||||
sortBy,
|
||||
selectedModels,
|
||||
|
||||
@@ -265,18 +265,15 @@ function cancelEdit() {
|
||||
}
|
||||
|
||||
async function saveKeybinding() {
|
||||
if (currentEditingCommand.value && newBindingKeyCombo.value) {
|
||||
const updated = keybindingStore.updateKeybindingOnCommand(
|
||||
new KeybindingImpl({
|
||||
commandId: currentEditingCommand.value.id,
|
||||
combo: newBindingKeyCombo.value
|
||||
})
|
||||
)
|
||||
if (updated) {
|
||||
await keybindingService.persistUserKeybindings()
|
||||
}
|
||||
}
|
||||
const commandId = currentEditingCommand.value?.id
|
||||
const combo = newBindingKeyCombo.value
|
||||
cancelEdit()
|
||||
if (!combo || commandId == undefined) return
|
||||
|
||||
const updated = keybindingStore.updateKeybindingOnCommand(
|
||||
new KeybindingImpl({ commandId, combo })
|
||||
)
|
||||
if (updated) await keybindingService.persistUserKeybindings()
|
||||
}
|
||||
|
||||
async function resetKeybinding(commandData: ICommandData) {
|
||||
|
||||
@@ -87,6 +87,7 @@
|
||||
<template v-if="comfyAppReady">
|
||||
<TitleEditor />
|
||||
<SelectionToolbox v-if="selectionToolboxEnabled" />
|
||||
<NodeContextMenu />
|
||||
<!-- Render legacy DOM widgets only when Vue nodes are disabled -->
|
||||
<DomWidgets v-if="!shouldRenderVueNodes" />
|
||||
</template>
|
||||
@@ -112,6 +113,7 @@ import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
|
||||
import DomWidgets from '@/components/graph/DomWidgets.vue'
|
||||
import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue'
|
||||
import NodeTooltip from '@/components/graph/NodeTooltip.vue'
|
||||
import NodeContextMenu from '@/components/graph/NodeContextMenu.vue'
|
||||
import SelectionToolbox from '@/components/graph/SelectionToolbox.vue'
|
||||
import TitleEditor from '@/components/graph/TitleEditor.vue'
|
||||
import NodePropertiesPanel from '@/components/rightSidePanel/RightSidePanel.vue'
|
||||
@@ -149,7 +151,7 @@ import { app as comfyApp } from '@/scripts/app'
|
||||
import { ChangeTracker } from '@/scripts/changeTracker'
|
||||
import { IS_CONTROL_WIDGET, updateControlWidgetLabel } from '@/scripts/widgets'
|
||||
import { useColorPaletteService } from '@/services/colorPaletteService'
|
||||
import { useNewUserService } from '@/services/useNewUserService'
|
||||
import { newUserService } from '@/services/newUserService'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
import { useBootstrapStore } from '@/stores/bootstrapStore'
|
||||
@@ -457,9 +459,11 @@ onMounted(async () => {
|
||||
// Register core settings immediately after settings are ready
|
||||
CORE_SETTINGS.forEach(settingStore.addSetting)
|
||||
|
||||
// Wait for both i18n and newUserService in parallel
|
||||
// (newUserService only needs settings, not i18n)
|
||||
await Promise.all([
|
||||
until(() => isI18nReady.value || !!i18nError.value).toBe(true),
|
||||
useNewUserService().initializeIfNewUser()
|
||||
newUserService().initializeIfNewUser(settingStore)
|
||||
])
|
||||
if (i18nError.value) {
|
||||
console.warn(
|
||||
|
||||
@@ -13,8 +13,6 @@ import {
|
||||
createMockCanvas,
|
||||
createMockPositionable
|
||||
} from '@/utils/__tests__/litegraphTestUtils'
|
||||
import * as litegraphUtil from '@/utils/litegraphUtil'
|
||||
import * as nodeFilterUtil from '@/utils/nodeFilterUtil'
|
||||
|
||||
function createMockExtensionService(): ReturnType<typeof useExtensionService> {
|
||||
return {
|
||||
@@ -291,8 +289,9 @@ describe('SelectionToolbox', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should show mask editor only for single image nodes', () => {
|
||||
const isImageNodeSpy = vi.spyOn(litegraphUtil, 'isImageNode')
|
||||
it('should show mask editor only for single image nodes', async () => {
|
||||
const mockUtils = await import('@/utils/litegraphUtil')
|
||||
const isImageNodeSpy = vi.spyOn(mockUtils, 'isImageNode')
|
||||
|
||||
// Single image node
|
||||
isImageNodeSpy.mockReturnValue(true)
|
||||
@@ -308,8 +307,9 @@ describe('SelectionToolbox', () => {
|
||||
expect(wrapper2.find('.mask-editor-button').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('should show Color picker button only for single Load3D nodes', () => {
|
||||
const isLoad3dNodeSpy = vi.spyOn(litegraphUtil, 'isLoad3dNode')
|
||||
it('should show Color picker button only for single Load3D nodes', async () => {
|
||||
const mockUtils = await import('@/utils/litegraphUtil')
|
||||
const isLoad3dNodeSpy = vi.spyOn(mockUtils, 'isLoad3dNode')
|
||||
|
||||
// Single Load3D node
|
||||
isLoad3dNodeSpy.mockReturnValue(true)
|
||||
@@ -325,9 +325,13 @@ describe('SelectionToolbox', () => {
|
||||
expect(wrapper2.find('.load-3d-viewer-button').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('should show ExecuteButton only when output nodes are selected', () => {
|
||||
const isOutputNodeSpy = vi.spyOn(nodeFilterUtil, 'isOutputNode')
|
||||
const filterOutputNodesSpy = vi.spyOn(nodeFilterUtil, 'filterOutputNodes')
|
||||
it('should show ExecuteButton only when output nodes are selected', async () => {
|
||||
const mockNodeFilterUtil = await import('@/utils/nodeFilterUtil')
|
||||
const isOutputNodeSpy = vi.spyOn(mockNodeFilterUtil, 'isOutputNode')
|
||||
const filterOutputNodesSpy = vi.spyOn(
|
||||
mockNodeFilterUtil,
|
||||
'filterOutputNodes'
|
||||
)
|
||||
|
||||
// With output node selected
|
||||
isOutputNodeSpy.mockReturnValue(true)
|
||||
|
||||
@@ -42,7 +42,6 @@
|
||||
</Panel>
|
||||
</Transition>
|
||||
</div>
|
||||
<NodeContextMenu />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -69,7 +68,6 @@ import { useExtensionService } from '@/services/extensionService'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import type { ComfyCommandImpl } from '@/stores/commandStore'
|
||||
|
||||
import NodeContextMenu from './NodeContextMenu.vue'
|
||||
import FrameNodes from './selectionToolbox/FrameNodes.vue'
|
||||
import NodeOptionsButton from './selectionToolbox/NodeOptionsButton.vue'
|
||||
import VerticalDivider from './selectionToolbox/VerticalDivider.vue'
|
||||
|
||||
@@ -7,7 +7,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import ExecuteButton from '@/components/graph/selectionToolbox/ExecuteButton.vue'
|
||||
import { useSelectionState } from '@/composables/graph/useSelectionState'
|
||||
import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
@@ -48,7 +47,7 @@ describe('ExecuteButton', () => {
|
||||
}
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
// Set up Pinia with testing utilities
|
||||
setActivePinia(
|
||||
createTestingPinia({
|
||||
@@ -72,7 +71,10 @@ describe('ExecuteButton', () => {
|
||||
vi.spyOn(commandStore, 'execute').mockResolvedValue()
|
||||
|
||||
// Update the useSelectionState mock
|
||||
vi.mocked(useSelectionState).mockReturnValue({
|
||||
const { useSelectionState } = vi.mocked(
|
||||
await import('@/composables/graph/useSelectionState')
|
||||
)
|
||||
useSelectionState.mockReturnValue({
|
||||
selectedNodes: {
|
||||
value: mockSelectedNodes
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<!-- Help Center Popup positioned within canvas area -->
|
||||
<Teleport to="#graph-canvas-container">
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="isHelpCenterVisible"
|
||||
class="help-center-popup"
|
||||
|
||||
@@ -76,14 +76,6 @@ describe('NodePreview', () => {
|
||||
expect(wrapper.find('._sb_preview_badge').text()).toBe('Preview')
|
||||
})
|
||||
|
||||
it('applies text-ellipsis class to node header for text truncation', () => {
|
||||
const wrapper = mountComponent()
|
||||
const nodeHeader = wrapper.find('.node_header')
|
||||
|
||||
expect(nodeHeader.classes()).toContain('text-ellipsis')
|
||||
expect(nodeHeader.classes()).toContain('mr-4')
|
||||
})
|
||||
|
||||
it('sets title attribute on node header with full display name', () => {
|
||||
const wrapper = mountComponent()
|
||||
const nodeHeader = wrapper.find('.node_header')
|
||||
|
||||
@@ -10,7 +10,7 @@ https://github.com/Nuked88/ComfyUI-N-Sidebar/blob/7ae7da4a9761009fb6629bc04c6830
|
||||
<div v-else class="_sb_node_preview bg-component-node-background">
|
||||
<div class="_sb_table">
|
||||
<div
|
||||
class="node_header mr-4 text-ellipsis"
|
||||
class="node_header text-ellipsis"
|
||||
:title="nodeDef.display_name"
|
||||
:style="{
|
||||
backgroundColor: litegraphColors.NODE_DEFAULT_COLOR,
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import QueueInlineProgress from '@/components/queue/QueueInlineProgress.vue'
|
||||
|
||||
const mockProgress = vi.hoisted(() => ({
|
||||
totalPercent: null as unknown as Ref<number>,
|
||||
currentNodePercent: null as unknown as Ref<number>
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/queue/useQueueProgress', () => ({
|
||||
useQueueProgress: () => ({
|
||||
totalPercent: mockProgress.totalPercent,
|
||||
currentNodePercent: mockProgress.currentNodePercent
|
||||
})
|
||||
}))
|
||||
|
||||
const createWrapper = (props: { hidden?: boolean } = {}) =>
|
||||
mount(QueueInlineProgress, { props })
|
||||
|
||||
describe('QueueInlineProgress', () => {
|
||||
beforeEach(() => {
|
||||
mockProgress.totalPercent = ref(0)
|
||||
mockProgress.currentNodePercent = ref(0)
|
||||
})
|
||||
|
||||
it('renders when total progress is non-zero', () => {
|
||||
mockProgress.totalPercent.value = 12
|
||||
|
||||
const wrapper = createWrapper()
|
||||
|
||||
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders when current node progress is non-zero', () => {
|
||||
mockProgress.currentNodePercent.value = 33
|
||||
|
||||
const wrapper = createWrapper()
|
||||
|
||||
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('does not render when hidden', () => {
|
||||
mockProgress.totalPercent.value = 45
|
||||
|
||||
const wrapper = createWrapper({ hidden: true })
|
||||
|
||||
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows when progress becomes non-zero', async () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(false)
|
||||
|
||||
mockProgress.totalPercent.value = 10
|
||||
await nextTick()
|
||||
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('hides when progress returns to zero', async () => {
|
||||
mockProgress.totalPercent.value = 10
|
||||
|
||||
const wrapper = createWrapper()
|
||||
|
||||
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(true)
|
||||
|
||||
mockProgress.totalPercent.value = 0
|
||||
mockProgress.currentNodePercent.value = 0
|
||||
await nextTick()
|
||||
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,36 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="shouldShow"
|
||||
aria-hidden="true"
|
||||
:class="
|
||||
cn('pointer-events-none absolute inset-0 overflow-hidden', radiusClass)
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="pointer-events-none absolute bottom-0 left-0 h-[3px] bg-interface-panel-job-progress-primary transition-[width]"
|
||||
:style="{ width: `${totalPercent}%` }"
|
||||
/>
|
||||
<div
|
||||
class="pointer-events-none absolute bottom-0 left-0 h-[3px] bg-interface-panel-job-progress-secondary transition-[width]"
|
||||
:style="{ width: `${currentNodePercent}%` }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { hidden = false, radiusClass = 'rounded-[7px]' } = defineProps<{
|
||||
hidden?: boolean
|
||||
radiusClass?: string
|
||||
}>()
|
||||
|
||||
const { totalPercent, currentNodePercent } = useQueueProgress()
|
||||
|
||||
const shouldShow = computed(
|
||||
() => !hidden && (totalPercent.value > 0 || currentNodePercent.value > 0)
|
||||
)
|
||||
</script>
|
||||
@@ -1,70 +0,0 @@
|
||||
<template>
|
||||
<div v-if="shouldShow" class="flex justify-end">
|
||||
<div
|
||||
class="flex items-center whitespace-nowrap text-[0.75rem] leading-[normal] drop-shadow-[1px_1px_8px_rgba(0,0,0,0.4)]"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
>
|
||||
<div class="flex items-center text-base-foreground">
|
||||
<span class="font-normal">
|
||||
{{ t('sideToolbar.queueProgressOverlay.inlineTotalLabel') }}:
|
||||
</span>
|
||||
<span class="w-[5ch] shrink-0 text-right font-bold tabular-nums">
|
||||
{{ totalPercentFormatted }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center text-muted-foreground">
|
||||
<span
|
||||
class="w-[16ch] shrink-0 truncate text-right"
|
||||
:title="currentNodeName"
|
||||
>
|
||||
{{ currentNodeName }}:
|
||||
</span>
|
||||
<span class="w-[5ch] shrink-0 text-right tabular-nums">
|
||||
{{ currentNodePercentFormatted }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { st } from '@/i18n'
|
||||
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
|
||||
|
||||
const props = defineProps<{
|
||||
hidden?: boolean
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const executionStore = useExecutionStore()
|
||||
const {
|
||||
totalPercent,
|
||||
totalPercentFormatted,
|
||||
currentNodePercent,
|
||||
currentNodePercentFormatted
|
||||
} = useQueueProgress()
|
||||
|
||||
const currentNodeName = computed(() => {
|
||||
return resolveNodeDisplayName(executionStore.executingNode, {
|
||||
emptyLabel: t('g.emDash'),
|
||||
untitledLabel: t('g.untitled'),
|
||||
st
|
||||
})
|
||||
})
|
||||
|
||||
const shouldShow = computed(
|
||||
() =>
|
||||
!props.hidden &&
|
||||
(!executionStore.isIdle ||
|
||||
totalPercent.value > 0 ||
|
||||
currentNodePercent.value > 0)
|
||||
)
|
||||
</script>
|
||||
@@ -8,14 +8,12 @@ import Tab from '@/components/tab/Tab.vue'
|
||||
import TabList from '@/components/tab/TabList.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useGraphHierarchy } from '@/composables/graph/useGraphHierarchy'
|
||||
import { st } from '@/i18n'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import type { RightSidePanelTab } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import TabInfo from './info/TabInfo.vue'
|
||||
@@ -148,12 +146,9 @@ function resolveTitle() {
|
||||
return groups[0].title || t('rightSidePanel.fallbackGroupTitle')
|
||||
}
|
||||
if (nodes.length === 1) {
|
||||
const fallbackNodeTitle = t('rightSidePanel.fallbackNodeTitle')
|
||||
return resolveNodeDisplayName(nodes[0], {
|
||||
emptyLabel: fallbackNodeTitle,
|
||||
untitledLabel: fallbackNodeTitle,
|
||||
st
|
||||
})
|
||||
return (
|
||||
nodes[0].title || nodes[0].type || t('rightSidePanel.fallbackNodeTitle')
|
||||
)
|
||||
}
|
||||
}
|
||||
return t('rightSidePanel.title', { count: items.length })
|
||||
|
||||
@@ -14,8 +14,6 @@ import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
|
||||
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
|
||||
import { HideLayoutFieldKey } from '@/types/widgetTypes'
|
||||
|
||||
import { GetNodeParentGroupKey } from '../shared'
|
||||
import WidgetItem from './WidgetItem.vue'
|
||||
|
||||
@@ -54,7 +52,7 @@ const rootElement = ref<HTMLElement>()
|
||||
const widgets = shallowRef(widgetsProp)
|
||||
watchEffect(() => (widgets.value = widgetsProp))
|
||||
|
||||
provide(HideLayoutFieldKey, true)
|
||||
provide('hideLayoutField', true)
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, customRef, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import EditableText from '@/components/common/EditableText.vue'
|
||||
import { getSharedWidgetEnhancements } from '@/composables/graph/useGraphNodeManager'
|
||||
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
|
||||
import { st } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
@@ -17,7 +15,6 @@ import {
|
||||
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
|
||||
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
|
||||
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
|
||||
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { renameWidget } from '@/utils/widgetUtil'
|
||||
|
||||
@@ -41,7 +38,6 @@ const {
|
||||
isShownOnParents?: boolean
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const canvasStore = useCanvasStore()
|
||||
const favoritedWidgetsStore = useFavoritedWidgetsStore()
|
||||
const isEditing = ref(false)
|
||||
@@ -63,13 +59,7 @@ const sourceNodeName = computed((): string | null => {
|
||||
const { graph, nodeId } = widget._overlay
|
||||
sourceNode = getNodeByExecutionId(graph, nodeId)
|
||||
}
|
||||
if (!sourceNode) return null
|
||||
const fallbackNodeTitle = t('rightSidePanel.fallbackNodeTitle')
|
||||
return resolveNodeDisplayName(sourceNode, {
|
||||
emptyLabel: fallbackNodeTitle,
|
||||
untitledLabel: fallbackNodeTitle,
|
||||
st
|
||||
})
|
||||
return sourceNode ? sourceNode.title || sourceNode.type : null
|
||||
})
|
||||
|
||||
const hasParents = computed(() => parents?.length > 0)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="flex h-full flex-col">
|
||||
<!-- Active Jobs Grid -->
|
||||
<div
|
||||
v-if="activeJobItems.length"
|
||||
v-if="isQueuePanelV2Enabled && activeJobItems.length"
|
||||
class="grid max-h-[50%] scrollbar-custom overflow-y-auto"
|
||||
:style="gridStyle"
|
||||
>
|
||||
@@ -65,6 +65,7 @@ import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { isActiveJobState } from '@/utils/queueUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
|
||||
const {
|
||||
assets,
|
||||
@@ -90,6 +91,11 @@ const emit = defineEmits<{
|
||||
|
||||
const { t } = useI18n()
|
||||
const { jobItems } = useJobList()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const isQueuePanelV2Enabled = computed(() =>
|
||||
settingStore.get('Comfy.Queue.QPOV2')
|
||||
)
|
||||
|
||||
type AssetGridItem = { key: string; asset: AssetItem }
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col">
|
||||
<div
|
||||
v-if="activeJobItems.length"
|
||||
v-if="isQueuePanelV2Enabled && activeJobItems.length"
|
||||
class="flex max-h-[50%] scrollbar-custom flex-col gap-2 overflow-y-auto px-2"
|
||||
>
|
||||
<AssetsListItem
|
||||
@@ -133,6 +133,7 @@ import {
|
||||
} from '@/utils/formatUtil'
|
||||
import { iconForJobState } from '@/utils/queueDisplay'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
|
||||
const {
|
||||
assets,
|
||||
@@ -154,6 +155,11 @@ const emit = defineEmits<{
|
||||
|
||||
const { t } = useI18n()
|
||||
const { jobItems } = useJobList()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const isQueuePanelV2Enabled = computed(() =>
|
||||
settingStore.get('Comfy.Queue.QPOV2')
|
||||
)
|
||||
const hoveredJobId = ref<string | null>(null)
|
||||
const hoveredAssetId = ref<string | null>(null)
|
||||
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'comfy-vue-side-bar-container group/sidebar-tab flex h-full flex-col',
|
||||
props.class
|
||||
)
|
||||
"
|
||||
class="comfy-vue-side-bar-container group/sidebar-tab flex h-full flex-col"
|
||||
:class="props.class"
|
||||
>
|
||||
<div class="comfy-vue-side-bar-header flex flex-col gap-2">
|
||||
<Toolbar
|
||||
@@ -39,8 +35,6 @@
|
||||
import ScrollPanel from 'primevue/scrollpanel'
|
||||
import Toolbar from 'primevue/toolbar'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
class?: string
|
||||
|
||||
@@ -13,10 +13,7 @@
|
||||
severity="danger"
|
||||
/>
|
||||
</template>
|
||||
<template
|
||||
v-if="nodeDef.name.startsWith(useSubgraphStore().typePrefix)"
|
||||
#actions
|
||||
>
|
||||
<template v-if="isUserBlueprint" #actions>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="icon-sm"
|
||||
@@ -128,8 +125,18 @@ const editBlueprint = async () => {
|
||||
await useSubgraphStore().editBlueprint(props.node.data.name)
|
||||
}
|
||||
const menu = ref<InstanceType<typeof ContextMenu> | null>(null)
|
||||
const subgraphStore = useSubgraphStore()
|
||||
const isUserBlueprint = computed(() => {
|
||||
const name = nodeDef.value.name
|
||||
if (!name.startsWith(subgraphStore.typePrefix)) return false
|
||||
return !subgraphStore.isGlobalBlueprint(
|
||||
name.slice(subgraphStore.typePrefix.length)
|
||||
)
|
||||
})
|
||||
const menuItems = computed<MenuItem[]>(() => {
|
||||
const items: MenuItem[] = [
|
||||
if (!isUserBlueprint.value) return []
|
||||
|
||||
return [
|
||||
{
|
||||
label: t('g.delete'),
|
||||
icon: 'pi pi-trash',
|
||||
@@ -137,15 +144,14 @@ const menuItems = computed<MenuItem[]>(() => {
|
||||
command: deleteBlueprint
|
||||
}
|
||||
]
|
||||
return items
|
||||
})
|
||||
function handleContextMenu(event: Event) {
|
||||
if (!nodeDef.value.name.startsWith(useSubgraphStore().typePrefix)) return
|
||||
if (!isUserBlueprint.value) return
|
||||
menu.value?.show(event)
|
||||
}
|
||||
function deleteBlueprint() {
|
||||
if (!props.node.data) return
|
||||
void useSubgraphStore().deleteBlueprint(props.node.data.name)
|
||||
void subgraphStore.deleteBlueprint(props.node.data.name)
|
||||
}
|
||||
|
||||
const nodePreviewStyle = ref<CSSProperties>({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { breakpointsTailwind, useBreakpoints, whenever } from '@vueuse/core'
|
||||
import { whenever } from '@vueuse/core'
|
||||
import { useTemplateRef } from 'vue'
|
||||
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
@@ -10,7 +10,6 @@ defineProps<{
|
||||
}>()
|
||||
|
||||
const feedbackRef = useTemplateRef('feedbackRef')
|
||||
const isMobile = useBreakpoints(breakpointsTailwind).smaller('md')
|
||||
|
||||
whenever(feedbackRef, () => {
|
||||
const scriptEl = document.createElement('script')
|
||||
@@ -19,20 +18,9 @@ whenever(feedbackRef, () => {
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<Button
|
||||
v-if="isMobile"
|
||||
as="a"
|
||||
:href="`https://form.typeform.com/to/${dataTfWidget}`"
|
||||
target="_blank"
|
||||
variant="inverted"
|
||||
class="rounded-full size-12"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<i class="icon-[lucide--circle-question-mark] size-6" />
|
||||
</Button>
|
||||
<Popover v-else>
|
||||
<Popover>
|
||||
<template #button>
|
||||
<Button variant="inverted" class="rounded-full size-12" v-bind="$attrs">
|
||||
<Button variant="inverted" class="rounded-full size-12">
|
||||
<i class="icon-[lucide--circle-question-mark] size-6" />
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
@@ -134,6 +134,27 @@ describe('contextMenuConverter', () => {
|
||||
// Node Info (section 4) should come before or with Color (section 4)
|
||||
expect(getIndex('Node Info')).toBeLessThanOrEqual(getIndex('Color'))
|
||||
})
|
||||
|
||||
it('should recognize Frame Nodes as a core menu item', () => {
|
||||
const options: MenuOption[] = [
|
||||
{ label: 'Rename', source: 'vue' },
|
||||
{ label: 'Frame Nodes', source: 'vue' },
|
||||
{ label: 'Custom Extension', source: 'vue' }
|
||||
]
|
||||
|
||||
const result = buildStructuredMenu(options)
|
||||
|
||||
// Frame Nodes should appear in the core items section (before Extensions)
|
||||
const frameNodesIndex = result.findIndex(
|
||||
(opt) => opt.label === 'Frame Nodes'
|
||||
)
|
||||
const extensionsCategoryIndex = result.findIndex(
|
||||
(opt) => opt.label === 'Extensions' && opt.type === 'category'
|
||||
)
|
||||
|
||||
// Frame Nodes should come before Extensions category
|
||||
expect(frameNodesIndex).toBeLessThan(extensionsCategoryIndex)
|
||||
})
|
||||
})
|
||||
|
||||
describe('convertContextMenuToOptions', () => {
|
||||
|
||||
@@ -44,6 +44,7 @@ const CORE_MENU_ITEMS = new Set([
|
||||
// Structure operations
|
||||
'Convert to Subgraph',
|
||||
'Frame selection',
|
||||
'Frame Nodes',
|
||||
'Minimize Node',
|
||||
'Expand',
|
||||
'Collapse',
|
||||
@@ -103,7 +104,8 @@ function isDuplicateItem(label: string, existingItems: MenuOption[]): boolean {
|
||||
shape: ['shape', 'shapes'],
|
||||
pin: ['pin', 'unpin'],
|
||||
delete: ['remove', 'delete'],
|
||||
duplicate: ['clone', 'duplicate']
|
||||
duplicate: ['clone', 'duplicate'],
|
||||
frame: ['frame selection', 'frame nodes']
|
||||
}
|
||||
|
||||
return existingItems.some((item) => {
|
||||
@@ -226,6 +228,7 @@ const MENU_ORDER: string[] = [
|
||||
// Section 3: Structure operations
|
||||
'Convert to Subgraph',
|
||||
'Frame selection',
|
||||
'Frame Nodes',
|
||||
'Minimize Node',
|
||||
'Expand',
|
||||
'Collapse',
|
||||
|
||||
@@ -2,10 +2,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useSelectionMenuOptions } from '@/composables/graph/useSelectionMenuOptions'
|
||||
|
||||
const subgraphMocks = vi.hoisted(() => ({
|
||||
const mocks = vi.hoisted(() => ({
|
||||
convertToSubgraph: vi.fn(),
|
||||
unpackSubgraph: vi.fn(),
|
||||
addSubgraphToLibrary: vi.fn(),
|
||||
frameNodes: vi.fn(),
|
||||
createI18nMock: vi.fn(() => ({
|
||||
global: {
|
||||
t: vi.fn(),
|
||||
@@ -19,7 +20,7 @@ vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
}),
|
||||
createI18n: subgraphMocks.createI18nMock
|
||||
createI18n: mocks.createI18nMock
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useSelectionOperations', () => ({
|
||||
@@ -42,18 +43,46 @@ vi.mock('@/composables/graph/useNodeArrangement', () => ({
|
||||
|
||||
vi.mock('@/composables/graph/useSubgraphOperations', () => ({
|
||||
useSubgraphOperations: () => ({
|
||||
convertToSubgraph: subgraphMocks.convertToSubgraph,
|
||||
unpackSubgraph: subgraphMocks.unpackSubgraph,
|
||||
addSubgraphToLibrary: subgraphMocks.addSubgraphToLibrary
|
||||
convertToSubgraph: mocks.convertToSubgraph,
|
||||
unpackSubgraph: mocks.unpackSubgraph,
|
||||
addSubgraphToLibrary: mocks.addSubgraphToLibrary
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useFrameNodes', () => ({
|
||||
useFrameNodes: () => ({
|
||||
frameNodes: vi.fn()
|
||||
frameNodes: mocks.frameNodes
|
||||
})
|
||||
}))
|
||||
|
||||
describe('useSelectionMenuOptions - multiple nodes options', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('returns Frame Nodes option that invokes frameNodes when called', () => {
|
||||
const { getMultipleNodesOptions } = useSelectionMenuOptions()
|
||||
const options = getMultipleNodesOptions()
|
||||
|
||||
const frameOption = options.find((opt) => opt.label === 'g.frameNodes')
|
||||
expect(frameOption).toBeDefined()
|
||||
expect(frameOption?.action).toBeDefined()
|
||||
|
||||
frameOption?.action?.()
|
||||
expect(mocks.frameNodes).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('returns Convert to Group Node option from getMultipleNodesOptions', () => {
|
||||
const { getMultipleNodesOptions } = useSelectionMenuOptions()
|
||||
const options = getMultipleNodesOptions()
|
||||
|
||||
const groupNodeOption = options.find(
|
||||
(opt) => opt.label === 'contextMenu.Convert to Group Node'
|
||||
)
|
||||
expect(groupNodeOption).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useSelectionMenuOptions - subgraph options', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -68,7 +97,7 @@ describe('useSelectionMenuOptions - subgraph options', () => {
|
||||
|
||||
expect(options).toHaveLength(1)
|
||||
expect(options[0]?.label).toBe('contextMenu.Convert to Subgraph')
|
||||
expect(options[0]?.action).toBe(subgraphMocks.convertToSubgraph)
|
||||
expect(options[0]?.action).toBe(mocks.convertToSubgraph)
|
||||
})
|
||||
|
||||
it('includes convert, add to library, and unpack when subgraphs are selected', () => {
|
||||
@@ -86,7 +115,7 @@ describe('useSelectionMenuOptions - subgraph options', () => {
|
||||
const convertOption = options.find(
|
||||
(option) => option.label === 'contextMenu.Convert to Subgraph'
|
||||
)
|
||||
expect(convertOption?.action).toBe(subgraphMocks.convertToSubgraph)
|
||||
expect(convertOption?.action).toBe(mocks.convertToSubgraph)
|
||||
})
|
||||
|
||||
it('hides convert option when only a single subgraph is selected', () => {
|
||||
|
||||
@@ -87,6 +87,25 @@ describe('useSelectionState', () => {
|
||||
const { hasAnySelection } = useSelectionState()
|
||||
expect(hasAnySelection.value).toBe(true)
|
||||
})
|
||||
|
||||
test('hasMultipleSelection should be true when 2+ items selected', () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
const node1 = createMockLGraphNode({ id: 1 })
|
||||
const node2 = createMockLGraphNode({ id: 2 })
|
||||
canvasStore.$state.selectedItems = [node1, node2]
|
||||
|
||||
const { hasMultipleSelection } = useSelectionState()
|
||||
expect(hasMultipleSelection.value).toBe(true)
|
||||
})
|
||||
|
||||
test('hasMultipleSelection should be false when only 1 item selected', () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
const node1 = createMockLGraphNode({ id: 1 })
|
||||
canvasStore.$state.selectedItems = [node1]
|
||||
|
||||
const { hasMultipleSelection } = useSelectionState()
|
||||
expect(hasMultipleSelection.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Node Type Filtering', () => {
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
isToday,
|
||||
isYesterday
|
||||
} from '@/utils/dateTimeUtil'
|
||||
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
import { buildJobDisplay } from '@/utils/queueDisplay'
|
||||
import { jobStateFromTask } from '@/utils/queueUtil'
|
||||
|
||||
@@ -185,11 +185,13 @@ export function useJobList() {
|
||||
executionStore.isPromptInitializing(promptId)
|
||||
|
||||
const currentNodeName = computed(() => {
|
||||
return resolveNodeDisplayName(executionStore.executingNode, {
|
||||
emptyLabel: t('g.emDash'),
|
||||
untitledLabel: t('g.untitled'),
|
||||
st
|
||||
})
|
||||
const node = executionStore.executingNode
|
||||
if (!node) return t('g.emDash')
|
||||
const title = (node.title ?? '').toString().trim()
|
||||
if (title) return title
|
||||
const nodeType = (node.type ?? '').toString().trim() || t('g.untitled')
|
||||
const key = `nodeDefs.${normalizeI18nKey(nodeType)}.display_name`
|
||||
return st(key, nodeType)
|
||||
})
|
||||
|
||||
const selectedJobTab = ref<JobTab>('All')
|
||||
|
||||
@@ -4,7 +4,6 @@ import { nextTick, ref } from 'vue'
|
||||
import type { IFuseOptions } from 'fuse.js'
|
||||
|
||||
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
|
||||
import { useTemplateFiltering } from '@/composables/useTemplateFiltering'
|
||||
|
||||
const defaultSettingStore = {
|
||||
get: vi.fn((key: string) => {
|
||||
@@ -51,6 +50,9 @@ vi.mock('@/scripts/api', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
const { useTemplateFiltering } =
|
||||
await import('@/composables/useTemplateFiltering')
|
||||
|
||||
describe('useTemplateFiltering', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { refThrottled, watchDebounced } from '@vueuse/core'
|
||||
import { refDebounced, watchDebounced } from '@vueuse/core'
|
||||
import Fuse from 'fuse.js'
|
||||
import type { IFuseOptions } from 'fuse.js'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
@@ -119,7 +119,7 @@ export function useTemplateFiltering(
|
||||
)
|
||||
})
|
||||
|
||||
const debouncedSearchQuery = refThrottled(searchQuery, 50)
|
||||
const debouncedSearchQuery = refDebounced(searchQuery, 150)
|
||||
|
||||
const filteredBySearch = computed(() => {
|
||||
if (!debouncedSearchQuery.value.trim()) {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { remove } from 'es-toolkit'
|
||||
import { shallowReactive } from 'vue'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import type {
|
||||
@@ -345,7 +344,6 @@ function applyMatchType(node: LGraphNode, inputSpec: InputSpecV2) {
|
||||
requestAnimationFrame(() => {
|
||||
const input = node.inputs[index]
|
||||
if (!input) return
|
||||
node.inputs[index] = shallowReactive(input)
|
||||
node.onConnectionsChange?.(
|
||||
LiteGraph.INPUT,
|
||||
index,
|
||||
|
||||
@@ -16,16 +16,15 @@ useExtensionService().registerExtension({
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
const { isActiveSubscription } = useSubscription()
|
||||
|
||||
// Refresh config when auth or subscription status changes
|
||||
// Primary auth refresh is handled by WorkspaceAuthGate on mount
|
||||
// This watcher handles subscription changes and acts as a backup for auth
|
||||
// Refresh config when subscription status changes
|
||||
// Initial auth-aware refresh happens in WorkspaceAuthGate before app renders
|
||||
watchDebounced(
|
||||
[isLoggedIn, isActiveSubscription],
|
||||
() => {
|
||||
if (!isLoggedIn.value) return
|
||||
void refreshRemoteConfig()
|
||||
},
|
||||
{ debounce: 256, immediate: true }
|
||||
{ debounce: 256 }
|
||||
)
|
||||
|
||||
// Poll for config updates every 10 minutes (with auth)
|
||||
|
||||
@@ -157,11 +157,9 @@ class Load3d {
|
||||
}
|
||||
|
||||
private initResizeObserver(container: Element | HTMLElement): void {
|
||||
if (typeof ResizeObserver === 'undefined') return
|
||||
|
||||
this.resizeObserver?.disconnect()
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
this.handleResize()
|
||||
this.forceRender()
|
||||
})
|
||||
this.resizeObserver.observe(container)
|
||||
}
|
||||
@@ -524,6 +522,7 @@ class Load3d {
|
||||
this.viewHelperManager.recreateViewHelper()
|
||||
|
||||
this.handleResize()
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
getCurrentCameraType(): 'perspective' | 'orthographic' {
|
||||
@@ -585,6 +584,7 @@ class Load3d {
|
||||
}
|
||||
|
||||
this.handleResize()
|
||||
this.forceRender()
|
||||
|
||||
this.loadingPromise = null
|
||||
}
|
||||
@@ -618,6 +618,7 @@ class Load3d {
|
||||
this.targetHeight = height
|
||||
this.targetAspectRatio = width / height
|
||||
this.handleResize()
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
addEventListener<T>(event: string, callback: EventCallback<T>): void {
|
||||
@@ -630,6 +631,7 @@ class Load3d {
|
||||
|
||||
refreshViewport(): void {
|
||||
this.handleResize()
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
handleResize(): void {
|
||||
|
||||
@@ -10,17 +10,8 @@ import { NodeSlot } from '@/lib/litegraph/src/node/NodeSlot'
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
import type {
|
||||
IBaseWidget,
|
||||
IWidgetAssetOptions,
|
||||
TWidgetValue
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
|
||||
import {
|
||||
assetFilenameSchema,
|
||||
assetItemSchema
|
||||
} from '@/platform/assets/schemas/assetSchema'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import { getAssetFilename } from '@/platform/assets/utils/assetMetadataUtils'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import type { InputSpec } from '@/schemas/nodeDefSchema'
|
||||
import { app } from '@/scripts/app'
|
||||
import {
|
||||
@@ -28,10 +19,10 @@ import {
|
||||
addValueControlWidgets,
|
||||
isValidWidgetType
|
||||
} from '@/scripts/widgets'
|
||||
import { isPrimitiveNode } from '@/renderer/utils/nodeTypeGuards'
|
||||
import { CONFIG, GET_CONFIG } from '@/services/litegraphService'
|
||||
import { mergeInputSpec } from '@/utils/nodeDefUtil'
|
||||
import { applyTextReplacements } from '@/utils/searchAndReplace'
|
||||
import { isPrimitiveNode } from '@/renderer/utils/nodeTypeGuards'
|
||||
|
||||
const replacePropertyName = 'Run widget replace on values'
|
||||
export class PrimitiveNode extends LGraphNode {
|
||||
@@ -237,20 +228,6 @@ export class PrimitiveNode extends LGraphNode {
|
||||
// Store current size as addWidget resizes the node
|
||||
const [oldWidth, oldHeight] = this.size
|
||||
let widget: IBaseWidget
|
||||
|
||||
// Cloud: Use asset widget for model-eligible inputs
|
||||
if (isCloud && type === 'COMBO') {
|
||||
const isEligible = assetService.isAssetBrowserEligible(
|
||||
node.comfyClass,
|
||||
widgetName
|
||||
)
|
||||
if (isEligible) {
|
||||
widget = this.#createAssetWidget(node, widgetName, inputData)
|
||||
this.#finalizeWidget(widget, oldWidth, oldHeight, recreating)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (isValidWidgetType(type)) {
|
||||
widget = (ComfyWidgets[type](this, 'value', inputData, app) || {}).widget
|
||||
} else {
|
||||
@@ -300,84 +277,20 @@ export class PrimitiveNode extends LGraphNode {
|
||||
}
|
||||
}
|
||||
|
||||
this.#finalizeWidget(widget, oldWidth, oldHeight, recreating)
|
||||
}
|
||||
|
||||
#createAssetWidget(
|
||||
targetNode: LGraphNode,
|
||||
widgetName: string,
|
||||
inputData: InputSpec
|
||||
): IBaseWidget {
|
||||
const defaultValue = inputData[1]?.default as string | undefined
|
||||
const assetBrowserDialog = useAssetBrowserDialog()
|
||||
|
||||
const openModal = async (widget: IBaseWidget) => {
|
||||
await assetBrowserDialog.show({
|
||||
nodeType: targetNode.comfyClass ?? '',
|
||||
inputName: widgetName,
|
||||
currentValue: widget.value as string,
|
||||
onAssetSelected: (asset) => {
|
||||
const validatedAsset = assetItemSchema.safeParse(asset)
|
||||
if (!validatedAsset.success) {
|
||||
console.error('Invalid asset item:', validatedAsset.error.errors)
|
||||
return
|
||||
}
|
||||
|
||||
const filename = getAssetFilename(validatedAsset.data)
|
||||
const validatedFilename = assetFilenameSchema.safeParse(filename)
|
||||
if (!validatedFilename.success) {
|
||||
console.error(
|
||||
'Invalid asset filename:',
|
||||
validatedFilename.error.errors
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const oldValue = widget.value
|
||||
widget.value = validatedFilename.data
|
||||
widget.callback?.(
|
||||
widget.value,
|
||||
app.canvas,
|
||||
this,
|
||||
app.canvas.graph_mouse,
|
||||
{} as CanvasPointerEvent
|
||||
)
|
||||
this.onWidgetChanged?.(
|
||||
widget.name,
|
||||
validatedFilename.data,
|
||||
oldValue,
|
||||
widget
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const options: IWidgetAssetOptions = { openModal }
|
||||
return this.addWidget(
|
||||
'asset',
|
||||
'value',
|
||||
defaultValue ?? '',
|
||||
() => {},
|
||||
options
|
||||
)
|
||||
}
|
||||
|
||||
#finalizeWidget(
|
||||
widget: IBaseWidget,
|
||||
oldWidth: number,
|
||||
oldHeight: number,
|
||||
recreating: boolean
|
||||
) {
|
||||
// When our value changes, update other widgets to reflect our changes
|
||||
// e.g. so LoadImage shows correct image
|
||||
widget.callback = useChainCallback(widget.callback, () => {
|
||||
this.applyToGraph()
|
||||
})
|
||||
|
||||
// Use the biggest dimensions in case the widgets caused the node to grow
|
||||
this.setSize([
|
||||
Math.max(this.size[0], oldWidth),
|
||||
Math.max(this.size[1], oldHeight)
|
||||
])
|
||||
|
||||
if (!recreating) {
|
||||
// Grow our node more if required
|
||||
const sz = this.computeSize()
|
||||
if (this.size[0] < sz[0]) {
|
||||
this.size[0] = sz[0]
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import DOMPurify from 'dompurify'
|
||||
|
||||
import type {
|
||||
ContextMenuDivElement,
|
||||
IContextMenuOptions,
|
||||
@@ -5,6 +7,38 @@ import type {
|
||||
} from './interfaces'
|
||||
import { LiteGraph } from './litegraph'
|
||||
|
||||
const ALLOWED_TAGS = ['span', 'b', 'i', 'em', 'strong']
|
||||
const ALLOWED_STYLE_PROPS = new Set([
|
||||
'display',
|
||||
'color',
|
||||
'background-color',
|
||||
'padding-left',
|
||||
'border-left'
|
||||
])
|
||||
|
||||
DOMPurify.addHook('uponSanitizeAttribute', (_node, data) => {
|
||||
if (data.attrName === 'style') {
|
||||
const sanitizedStyle = data.attrValue
|
||||
.split(';')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => {
|
||||
const colonIdx = s.indexOf(':')
|
||||
if (colonIdx === -1) return false
|
||||
const prop = s.slice(0, colonIdx).trim().toLowerCase()
|
||||
return ALLOWED_STYLE_PROPS.has(prop)
|
||||
})
|
||||
.join('; ')
|
||||
data.attrValue = sanitizedStyle
|
||||
}
|
||||
})
|
||||
|
||||
function sanitizeMenuHTML(html: string): string {
|
||||
return DOMPurify.sanitize(html, {
|
||||
ALLOWED_TAGS,
|
||||
ALLOWED_ATTR: ['style']
|
||||
})
|
||||
}
|
||||
|
||||
// TODO: Replace this pattern with something more modern.
|
||||
export interface ContextMenu<TValue = unknown> {
|
||||
constructor: new (
|
||||
@@ -123,7 +157,7 @@ export class ContextMenu<TValue = unknown> {
|
||||
if (options.title) {
|
||||
const element = document.createElement('div')
|
||||
element.className = 'litemenu-title'
|
||||
element.innerHTML = options.title
|
||||
element.textContent = options.title
|
||||
root.append(element)
|
||||
}
|
||||
|
||||
@@ -218,11 +252,18 @@ export class ContextMenu<TValue = unknown> {
|
||||
if (value === null) {
|
||||
element.classList.add('separator')
|
||||
} else {
|
||||
const innerHtml = name === null ? '' : String(name)
|
||||
const label = name === null ? '' : String(name)
|
||||
if (typeof value === 'string') {
|
||||
element.innerHTML = innerHtml
|
||||
element.textContent = label
|
||||
} else {
|
||||
element.innerHTML = value?.title ?? innerHtml
|
||||
// Use innerHTML for content that contains HTML tags, textContent otherwise
|
||||
const hasHtmlContent =
|
||||
value?.content !== undefined && /<[a-z][\s\S]*>/i.test(value.content)
|
||||
if (hasHtmlContent) {
|
||||
element.innerHTML = sanitizeMenuHTML(value.content!)
|
||||
} else {
|
||||
element.textContent = value?.title ?? label
|
||||
}
|
||||
|
||||
if (value.disabled) {
|
||||
disabled = true
|
||||
|
||||
@@ -46,9 +46,12 @@ describe('LGraph', () => {
|
||||
expect(graph.extra).toBe('TestGraph')
|
||||
})
|
||||
|
||||
test('is exactly the same type', ({ expect }) => {
|
||||
// LGraph from barrel export and LiteGraph.LGraph should be the same
|
||||
expect(LiteGraph.LGraph).toBe(LGraph)
|
||||
test('is exactly the same type', async ({ expect }) => {
|
||||
const directImport = await import('@/lib/litegraph/src/LGraph')
|
||||
const entryPointImport = await import('@/lib/litegraph/src/litegraph')
|
||||
|
||||
expect(LiteGraph.LGraph).toBe(directImport.LGraph)
|
||||
expect(LiteGraph.LGraph).toBe(entryPointImport.LGraph)
|
||||
})
|
||||
|
||||
test('populates optional values', ({ expect, minimalSerialisableGraph }) => {
|
||||
|
||||
210
src/lib/litegraph/src/LGraphCanvas.slotHitDetection.test.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
LGraph,
|
||||
LGraphCanvas,
|
||||
LGraphNode,
|
||||
LiteGraph
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
|
||||
vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
|
||||
layoutStore: {
|
||||
querySlotAtPoint: vi.fn(),
|
||||
queryRerouteAtPoint: vi.fn(),
|
||||
getNodeLayoutRef: vi.fn(() => ({ value: null })),
|
||||
getSlotLayout: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
describe('LGraphCanvas slot hit detection', () => {
|
||||
let graph: LGraph
|
||||
let canvas: LGraphCanvas
|
||||
let node: LGraphNode
|
||||
let canvasElement: HTMLCanvasElement
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
canvasElement = document.createElement('canvas')
|
||||
canvasElement.width = 800
|
||||
canvasElement.height = 600
|
||||
|
||||
const ctx = {
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
translate: vi.fn(),
|
||||
scale: vi.fn(),
|
||||
fillRect: vi.fn(),
|
||||
strokeRect: vi.fn(),
|
||||
fillText: vi.fn(),
|
||||
measureText: vi.fn().mockReturnValue({ width: 50 }),
|
||||
beginPath: vi.fn(),
|
||||
moveTo: vi.fn(),
|
||||
lineTo: vi.fn(),
|
||||
stroke: vi.fn(),
|
||||
fill: vi.fn(),
|
||||
closePath: vi.fn(),
|
||||
arc: vi.fn(),
|
||||
rect: vi.fn(),
|
||||
clip: vi.fn(),
|
||||
clearRect: vi.fn(),
|
||||
setTransform: vi.fn(),
|
||||
roundRect: vi.fn(),
|
||||
getTransform: vi
|
||||
.fn()
|
||||
.mockReturnValue({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }),
|
||||
font: '',
|
||||
fillStyle: '',
|
||||
strokeStyle: '',
|
||||
lineWidth: 1,
|
||||
globalAlpha: 1,
|
||||
textAlign: 'left' as CanvasTextAlign,
|
||||
textBaseline: 'alphabetic' as CanvasTextBaseline
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
|
||||
canvasElement.getContext = vi.fn().mockReturnValue(ctx)
|
||||
canvasElement.getBoundingClientRect = vi.fn().mockReturnValue({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 800,
|
||||
height: 600
|
||||
})
|
||||
|
||||
graph = new LGraph()
|
||||
canvas = new LGraphCanvas(canvasElement, graph, {
|
||||
skip_render: true
|
||||
})
|
||||
|
||||
// Create a test node with an output slot
|
||||
node = new LGraphNode('Test Node')
|
||||
node.pos = [100, 100]
|
||||
node.size = [150, 80]
|
||||
node.addOutput('output', 'number')
|
||||
graph.add(node)
|
||||
|
||||
// Enable Vue nodes mode for the test
|
||||
LiteGraph.vueNodesMode = true
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
LiteGraph.vueNodesMode = false
|
||||
})
|
||||
|
||||
describe('processMouseDown slot fallback in Vue nodes mode', () => {
|
||||
it('should query layoutStore.querySlotAtPoint when clicking outside node bounds', () => {
|
||||
// Click position outside node bounds (node is at 100,100 with size 150x80)
|
||||
// So node covers x: 100-250, y: 100-180
|
||||
// Click at x=255 is outside the right edge
|
||||
const clickX = 255
|
||||
const clickY = 120
|
||||
|
||||
// Verify the click is outside the node bounds
|
||||
expect(node.isPointInside(clickX, clickY)).toBe(false)
|
||||
expect(graph.getNodeOnPos(clickX, clickY)).toBeNull()
|
||||
|
||||
// Mock the slot query to return our node's slot
|
||||
vi.mocked(layoutStore.querySlotAtPoint).mockReturnValue({
|
||||
nodeId: String(node.id),
|
||||
index: 0,
|
||||
type: 'output',
|
||||
position: { x: 252, y: 120 },
|
||||
bounds: { x: 246, y: 110, width: 20, height: 20 }
|
||||
})
|
||||
|
||||
// Call processMouseDown - this should trigger the slot fallback
|
||||
canvas.processMouseDown(
|
||||
new MouseEvent('pointerdown', {
|
||||
button: 1, // Middle button
|
||||
clientX: clickX,
|
||||
clientY: clickY
|
||||
})
|
||||
)
|
||||
|
||||
// The fix should query the layout store when no node is found at click position
|
||||
expect(layoutStore.querySlotAtPoint).toHaveBeenCalledWith({
|
||||
x: clickX,
|
||||
y: clickY
|
||||
})
|
||||
})
|
||||
|
||||
it('should NOT query layoutStore when node is found directly at click position', () => {
|
||||
// Initialize node's bounding rect
|
||||
node.updateArea()
|
||||
|
||||
// Populate visible_nodes (normally done during render)
|
||||
canvas.visible_nodes = [node]
|
||||
|
||||
// Click inside the node bounds
|
||||
const clickX = 150
|
||||
const clickY = 140
|
||||
|
||||
// Verify the click is inside the node bounds
|
||||
expect(node.isPointInside(clickX, clickY)).toBe(true)
|
||||
expect(graph.getNodeOnPos(clickX, clickY)).toBe(node)
|
||||
|
||||
// Call processMouseDown
|
||||
canvas.processMouseDown(
|
||||
new MouseEvent('pointerdown', {
|
||||
button: 1,
|
||||
clientX: clickX,
|
||||
clientY: clickY
|
||||
})
|
||||
)
|
||||
|
||||
// Should NOT query the layout store since node was found directly
|
||||
expect(layoutStore.querySlotAtPoint).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should NOT query layoutStore when not in Vue nodes mode', () => {
|
||||
LiteGraph.vueNodesMode = false
|
||||
|
||||
const clickX = 255
|
||||
const clickY = 120
|
||||
|
||||
// Call processMouseDown
|
||||
canvas.processMouseDown(
|
||||
new MouseEvent('pointerdown', {
|
||||
button: 1,
|
||||
clientX: clickX,
|
||||
clientY: clickY
|
||||
})
|
||||
)
|
||||
|
||||
// Should NOT query the layout store in non-Vue mode
|
||||
expect(layoutStore.querySlotAtPoint).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should find node via slot query for input slots extending beyond left edge', () => {
|
||||
node.addInput('input', 'number')
|
||||
|
||||
// Click position left of node (node starts at x=100)
|
||||
const clickX = 95
|
||||
const clickY = 140
|
||||
|
||||
// Verify outside bounds
|
||||
expect(node.isPointInside(clickX, clickY)).toBe(false)
|
||||
|
||||
vi.mocked(layoutStore.querySlotAtPoint).mockReturnValue({
|
||||
nodeId: String(node.id),
|
||||
index: 0,
|
||||
type: 'input',
|
||||
position: { x: 98, y: 140 },
|
||||
bounds: { x: 88, y: 130, width: 20, height: 20 }
|
||||
})
|
||||
|
||||
canvas.processMouseDown(
|
||||
new MouseEvent('pointerdown', {
|
||||
button: 1,
|
||||
clientX: clickX,
|
||||
clientY: clickY
|
||||
})
|
||||
)
|
||||
|
||||
expect(layoutStore.querySlotAtPoint).toHaveBeenCalledWith({
|
||||
x: clickX,
|
||||
y: clickY
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -2187,9 +2187,21 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
|
||||
if (!is_inside) return
|
||||
|
||||
const node =
|
||||
let node =
|
||||
graph.getNodeOnPos(e.canvasX, e.canvasY, this.visible_nodes) ?? undefined
|
||||
|
||||
// In Vue nodes mode, slots extend beyond node bounds due to CSS transforms.
|
||||
// If no node was found, check if the click is on a slot and use its owning node.
|
||||
if (!node && LiteGraph.vueNodesMode) {
|
||||
const slotLayout = layoutStore.querySlotAtPoint({
|
||||
x: e.canvasX,
|
||||
y: e.canvasY
|
||||
})
|
||||
if (slotLayout) {
|
||||
node = graph.getNodeById(slotLayout.nodeId) ?? undefined
|
||||
}
|
||||
}
|
||||
|
||||
this.mouse[0] = x
|
||||
this.mouse[1] = y
|
||||
this.graph_mouse[0] = e.canvasX
|
||||
|
||||
@@ -73,6 +73,28 @@ describe('LinkConnector SubgraphInput connection validation', () => {
|
||||
expect(toTargetNode.onConnectionsChange).toHaveBeenCalledTimes(1)
|
||||
expect(fromTargetNode.onConnectionsChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('should allow reconnection to same target', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'number_input', type: 'number' }]
|
||||
})
|
||||
|
||||
const node = new LGraphNode('TargetNode')
|
||||
node.addInput('number_in', 'number')
|
||||
subgraph.add(node)
|
||||
|
||||
const link = subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
|
||||
const renderLink = new ToInputFromIoNodeLink(
|
||||
subgraph,
|
||||
subgraph.inputNode,
|
||||
subgraph.inputNode.slots[0],
|
||||
undefined,
|
||||
LinkDirection.CENTER,
|
||||
link
|
||||
)
|
||||
renderLink.connectToInput(node, node.inputs[0], connector.events)
|
||||
expect(node.inputs[0].link).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('MovingOutputLink validation', () => {
|
||||
|
||||
@@ -58,6 +58,12 @@ export class ToInputFromIoNodeLink implements RenderLink {
|
||||
events: CustomEventTarget<LinkConnectorEventMap>
|
||||
) {
|
||||
const { fromSlot, fromReroute, existingLink } = this
|
||||
if (
|
||||
existingLink &&
|
||||
node.id === existingLink.target_id &&
|
||||
node.inputs[existingLink.target_slot] === input
|
||||
)
|
||||
return
|
||||
|
||||
const newLink = fromSlot.connect(input, node, fromReroute?.id)
|
||||
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import { clamp } from 'es-toolkit/compat'
|
||||
import { describe, expect } from 'vitest'
|
||||
import { beforeEach, describe, expect, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
LiteGraphGlobal,
|
||||
LGraphCanvas,
|
||||
LiteGraph,
|
||||
LGraph
|
||||
LiteGraph
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { LGraph as DirectLGraph } from '@/lib/litegraph/src/LGraph'
|
||||
|
||||
import { test } from './__fixtures__/testExtensions'
|
||||
|
||||
describe('Litegraph module', () => {
|
||||
@@ -30,9 +27,22 @@ describe('Litegraph module', () => {
|
||||
})
|
||||
|
||||
describe('Import order dependency', () => {
|
||||
test('Imports reference the same types', ({ expect }) => {
|
||||
// Both imports should reference the same LGraph class
|
||||
expect(LiteGraph.LGraph).toBe(DirectLGraph)
|
||||
expect(LiteGraph.LGraph).toBe(LGraph)
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
test('Imports without error when entry point is imported first', async ({
|
||||
expect
|
||||
}) => {
|
||||
async function importNormally() {
|
||||
const entryPointImport = await import('@/lib/litegraph/src/litegraph')
|
||||
const directImport = await import('@/lib/litegraph/src/LGraph')
|
||||
|
||||
// Sanity check that imports were cleared.
|
||||
expect(Object.is(LiteGraph, entryPointImport.LiteGraph)).toBe(false)
|
||||
expect(Object.is(LiteGraph.LGraph, directImport.LGraph)).toBe(false)
|
||||
}
|
||||
|
||||
await expect(importNormally()).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,261 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
import type { LGraphNode as LGraphNodeType } from '@/lib/litegraph/src/litegraph'
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
import type { IColorWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { ColorWidget as ColorWidgetType } from '@/lib/litegraph/src/widgets/ColorWidget'
|
||||
|
||||
type LGraphCanvasType = InstanceType<typeof LGraphCanvas>
|
||||
|
||||
function createMockWidgetConfig(
|
||||
overrides: Partial<IColorWidget> = {}
|
||||
): IColorWidget {
|
||||
return {
|
||||
type: 'color',
|
||||
name: 'test_color',
|
||||
value: '#ff0000',
|
||||
options: {},
|
||||
y: 0,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function createMockCanvas(): LGraphCanvasType {
|
||||
return {
|
||||
setDirty: vi.fn()
|
||||
} as Partial<LGraphCanvasType> as LGraphCanvasType
|
||||
}
|
||||
|
||||
function createMockEvent(clientX = 100, clientY = 200): CanvasPointerEvent {
|
||||
return { clientX, clientY } as CanvasPointerEvent
|
||||
}
|
||||
|
||||
describe('ColorWidget', () => {
|
||||
let node: LGraphNodeType
|
||||
let widget: ColorWidgetType
|
||||
let mockCanvas: LGraphCanvasType
|
||||
let mockEvent: CanvasPointerEvent
|
||||
let ColorWidget: typeof ColorWidgetType
|
||||
let LGraphNode: typeof LGraphNodeType
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
// Reset modules to get fresh globalColorInput state
|
||||
vi.resetModules()
|
||||
|
||||
const litegraph = await import('@/lib/litegraph/src/litegraph')
|
||||
LGraphNode = litegraph.LGraphNode
|
||||
|
||||
const colorWidgetModule =
|
||||
await import('@/lib/litegraph/src/widgets/ColorWidget')
|
||||
ColorWidget = colorWidgetModule.ColorWidget
|
||||
|
||||
node = new LGraphNode('TestNode')
|
||||
mockCanvas = createMockCanvas()
|
||||
mockEvent = createMockEvent()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
document
|
||||
.querySelectorAll('input[type="color"]')
|
||||
.forEach((el) => el.remove())
|
||||
})
|
||||
|
||||
describe('onClick', () => {
|
||||
it('should create a color input and append it to document body', () => {
|
||||
widget = new ColorWidget(createMockWidgetConfig(), node)
|
||||
|
||||
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
|
||||
|
||||
const input = document.querySelector(
|
||||
'input[type="color"]'
|
||||
) as HTMLInputElement
|
||||
expect(input).toBeTruthy()
|
||||
expect(input.parentElement).toBe(document.body)
|
||||
})
|
||||
|
||||
it('should set input value from widget value', () => {
|
||||
widget = new ColorWidget(
|
||||
createMockWidgetConfig({ value: '#00ff00' }),
|
||||
node
|
||||
)
|
||||
|
||||
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
|
||||
|
||||
const input = document.querySelector(
|
||||
'input[type="color"]'
|
||||
) as HTMLInputElement
|
||||
expect(input.value).toBe('#00ff00')
|
||||
})
|
||||
|
||||
it('should default to #000000 when widget value is empty', () => {
|
||||
widget = new ColorWidget(createMockWidgetConfig({ value: '' }), node)
|
||||
|
||||
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
|
||||
|
||||
const input = document.querySelector(
|
||||
'input[type="color"]'
|
||||
) as HTMLInputElement
|
||||
expect(input.value).toBe('#000000')
|
||||
})
|
||||
|
||||
it('should position input at click coordinates', () => {
|
||||
widget = new ColorWidget(createMockWidgetConfig(), node)
|
||||
const event = createMockEvent(150, 250)
|
||||
|
||||
widget.onClick({ e: event, node, canvas: mockCanvas })
|
||||
|
||||
const input = document.querySelector(
|
||||
'input[type="color"]'
|
||||
) as HTMLInputElement
|
||||
expect(input.style.left).toBe('150px')
|
||||
expect(input.style.top).toBe('250px')
|
||||
})
|
||||
|
||||
it('should click the input on next animation frame', () => {
|
||||
widget = new ColorWidget(createMockWidgetConfig(), node)
|
||||
|
||||
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
|
||||
|
||||
const input = document.querySelector(
|
||||
'input[type="color"]'
|
||||
) as HTMLInputElement
|
||||
const clickSpy = vi.spyOn(input, 'click')
|
||||
|
||||
expect(clickSpy).not.toHaveBeenCalled()
|
||||
vi.runAllTimers()
|
||||
expect(clickSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should reuse the same input element on subsequent clicks', () => {
|
||||
widget = new ColorWidget(createMockWidgetConfig(), node)
|
||||
|
||||
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
|
||||
const firstInput = document.querySelector('input[type="color"]')
|
||||
|
||||
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
|
||||
const secondInput = document.querySelector('input[type="color"]')
|
||||
|
||||
expect(firstInput).toBe(secondInput)
|
||||
expect(document.querySelectorAll('input[type="color"]').length).toBe(1)
|
||||
})
|
||||
|
||||
it('should update input value when clicking with different widget values', () => {
|
||||
const widget1 = new ColorWidget(
|
||||
createMockWidgetConfig({ value: '#ff0000' }),
|
||||
node
|
||||
)
|
||||
const widget2 = new ColorWidget(
|
||||
createMockWidgetConfig({ value: '#0000ff' }),
|
||||
node
|
||||
)
|
||||
|
||||
widget1.onClick({ e: mockEvent, node, canvas: mockCanvas })
|
||||
const input = document.querySelector(
|
||||
'input[type="color"]'
|
||||
) as HTMLInputElement
|
||||
expect(input.value).toBe('#ff0000')
|
||||
|
||||
widget2.onClick({ e: mockEvent, node, canvas: mockCanvas })
|
||||
expect(input.value).toBe('#0000ff')
|
||||
})
|
||||
})
|
||||
|
||||
describe('onChange', () => {
|
||||
it('should call setValue when color input changes', () => {
|
||||
widget = new ColorWidget(
|
||||
createMockWidgetConfig({ value: '#ff0000' }),
|
||||
node
|
||||
)
|
||||
const setValueSpy = vi.spyOn(widget, 'setValue')
|
||||
|
||||
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
|
||||
|
||||
const input = document.querySelector(
|
||||
'input[type="color"]'
|
||||
) as HTMLInputElement
|
||||
input.value = '#00ff00'
|
||||
input.dispatchEvent(new Event('change'))
|
||||
|
||||
expect(setValueSpy).toHaveBeenCalledWith('#00ff00', {
|
||||
e: mockEvent,
|
||||
node,
|
||||
canvas: mockCanvas
|
||||
})
|
||||
})
|
||||
|
||||
it('should call canvas.setDirty after value change', () => {
|
||||
widget = new ColorWidget(createMockWidgetConfig(), node)
|
||||
|
||||
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
|
||||
|
||||
const input = document.querySelector(
|
||||
'input[type="color"]'
|
||||
) as HTMLInputElement
|
||||
input.value = '#00ff00'
|
||||
input.dispatchEvent(new Event('change'))
|
||||
|
||||
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should remove change listener after firing once', () => {
|
||||
widget = new ColorWidget(createMockWidgetConfig(), node)
|
||||
const setValueSpy = vi.spyOn(widget, 'setValue')
|
||||
|
||||
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
|
||||
|
||||
const input = document.querySelector(
|
||||
'input[type="color"]'
|
||||
) as HTMLInputElement
|
||||
|
||||
input.value = '#00ff00'
|
||||
input.dispatchEvent(new Event('change'))
|
||||
input.value = '#0000ff'
|
||||
input.dispatchEvent(new Event('change'))
|
||||
|
||||
// Should only be called once despite two change events
|
||||
expect(setValueSpy).toHaveBeenCalledTimes(1)
|
||||
expect(setValueSpy).toHaveBeenCalledWith('#00ff00', expect.any(Object))
|
||||
})
|
||||
|
||||
it('should register new change listener on subsequent onClick', () => {
|
||||
widget = new ColorWidget(createMockWidgetConfig(), node)
|
||||
const setValueSpy = vi.spyOn(widget, 'setValue')
|
||||
|
||||
// First click and change
|
||||
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
|
||||
const input = document.querySelector(
|
||||
'input[type="color"]'
|
||||
) as HTMLInputElement
|
||||
input.value = '#00ff00'
|
||||
input.dispatchEvent(new Event('change'))
|
||||
|
||||
// Second click and change
|
||||
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
|
||||
input.value = '#0000ff'
|
||||
input.dispatchEvent(new Event('change'))
|
||||
|
||||
expect(setValueSpy).toHaveBeenCalledTimes(2)
|
||||
expect(setValueSpy).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'#00ff00',
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(setValueSpy).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'#0000ff',
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('type', () => {
|
||||
it('should have type "color"', () => {
|
||||
widget = new ColorWidget(createMockWidgetConfig(), node)
|
||||
expect(widget.type).toBe('color')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,26 +1,12 @@
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import type { IColorWidget } from '../types/widgets'
|
||||
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
|
||||
import { BaseWidget } from './BaseWidget'
|
||||
|
||||
// Have one color input to prevent leaking instances
|
||||
// Browsers don't seem to fire any events when the color picker is cancelled
|
||||
let colorInput: HTMLInputElement | null = null
|
||||
|
||||
function getColorInput(): HTMLInputElement {
|
||||
if (!colorInput) {
|
||||
colorInput = document.createElement('input')
|
||||
colorInput.type = 'color'
|
||||
colorInput.style.position = 'absolute'
|
||||
colorInput.style.opacity = '0'
|
||||
colorInput.style.pointerEvents = 'none'
|
||||
colorInput.style.zIndex = '-999'
|
||||
document.body.appendChild(colorInput)
|
||||
}
|
||||
return colorInput
|
||||
}
|
||||
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
|
||||
|
||||
/**
|
||||
* Widget for displaying a color picker using native HTML color input
|
||||
* Widget for displaying a color picker
|
||||
* This is a widget that only has a Vue widgets implementation
|
||||
*/
|
||||
export class ColorWidget
|
||||
extends BaseWidget<IColorWidget>
|
||||
@@ -29,59 +15,35 @@ export class ColorWidget
|
||||
override type = 'color' as const
|
||||
|
||||
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
|
||||
const { fillStyle, strokeStyle, textAlign } = ctx
|
||||
|
||||
this.drawWidgetShape(ctx, options)
|
||||
|
||||
const { width } = options
|
||||
const { height, y } = this
|
||||
const { margin } = BaseWidget
|
||||
const { y, height } = this
|
||||
|
||||
const swatchWidth = 40
|
||||
const swatchHeight = height - 6
|
||||
const swatchRadius = swatchHeight / 2
|
||||
const rightPadding = 10
|
||||
const { fillStyle, strokeStyle, textAlign, textBaseline, font } = ctx
|
||||
|
||||
// Swatch fixed on the right
|
||||
const swatchX = width - margin - rightPadding - swatchWidth
|
||||
const swatchY = y + 3
|
||||
ctx.fillStyle = this.background_color
|
||||
ctx.fillRect(15, y, width - 30, height)
|
||||
|
||||
// Draw color swatch as rounded pill
|
||||
ctx.beginPath()
|
||||
ctx.roundRect(swatchX, swatchY, swatchWidth, swatchHeight, swatchRadius)
|
||||
ctx.fillStyle = this.value || '#000000'
|
||||
ctx.fill()
|
||||
ctx.strokeStyle = this.outline_color
|
||||
ctx.strokeRect(15, y, width - 30, height)
|
||||
|
||||
// Draw label on the left
|
||||
ctx.fillStyle = this.secondary_text_color
|
||||
ctx.textAlign = 'left'
|
||||
ctx.fillText(this.displayName, margin * 2 + 5, y + height * 0.7)
|
||||
|
||||
// Draw hex value to the left of swatch
|
||||
ctx.fillStyle = this.text_color
|
||||
ctx.textAlign = 'right'
|
||||
ctx.fillText(this.value || '#000000', swatchX - 8, y + height * 0.7)
|
||||
ctx.font = '11px monospace'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
|
||||
Object.assign(ctx, { textAlign, strokeStyle, fillStyle })
|
||||
const text = `Color: ${t('widgets.node2only')}`
|
||||
ctx.fillText(text, width / 2, y + height / 2)
|
||||
|
||||
Object.assign(ctx, {
|
||||
fillStyle,
|
||||
strokeStyle,
|
||||
textAlign,
|
||||
textBaseline,
|
||||
font
|
||||
})
|
||||
}
|
||||
|
||||
onClick({ e, node, canvas }: WidgetEventOptions): void {
|
||||
const input = getColorInput()
|
||||
input.value = this.value || '#000000'
|
||||
input.style.left = `${e.clientX}px`
|
||||
input.style.top = `${e.clientY}px`
|
||||
|
||||
input.addEventListener(
|
||||
'change',
|
||||
() => {
|
||||
this.setValue(input.value, { e, node, canvas })
|
||||
canvas.setDirty(true)
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
|
||||
// Wait for next frame else Chrome doesn't render the color picker at the mouse
|
||||
// Firefox always opens it in top left of window on Windows
|
||||
requestAnimationFrame(() => input.click())
|
||||
onClick(_options: WidgetEventOptions): void {
|
||||
// This is a widget that only has a Vue widgets implementation
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,7 +107,6 @@
|
||||
"modelUploaded": "تم استيراد النموذج بنجاح.",
|
||||
"noAssetsFound": "لم يتم العثور على أصول",
|
||||
"noModelsInFolder": "لا توجد {type} متاحة في هذا المجلد",
|
||||
"noResultsCanImport": "حاول تعديل البحث أو عوامل التصفية.\nيمكنك أيضًا إضافة النماذج باستخدام زر \"استيراد\" أعلاه.",
|
||||
"noValidSourceDetected": "لم يتم اكتشاف مصدر استيراد صالح",
|
||||
"notSureLeaveAsIs": "لست متأكدًا؟ فقط اتركه كما هو",
|
||||
"onlyCivitaiUrlsSupported": "يتم دعم روابط Civitai فقط",
|
||||
@@ -1261,10 +1260,8 @@
|
||||
}
|
||||
},
|
||||
"manager": {
|
||||
"actions": "الإجراءات",
|
||||
"allMissingNodesInstalled": "تم تثبيت جميع العقد المفقودة بنجاح",
|
||||
"applyChanges": "تطبيق التغييرات",
|
||||
"basicInfo": "معلومات أساسية",
|
||||
"changingVersion": "تغيير الإصدار من {from} إلى {to}",
|
||||
"clickToFinishSetup": "انقر",
|
||||
"conflicts": {
|
||||
@@ -1327,24 +1324,12 @@
|
||||
"license": "الرخصة",
|
||||
"loadingVersions": "جاري تحميل الإصدارات...",
|
||||
"mixedSelectionMessage": "لا يمكن تنفيذ إجراء جماعي على تحديد مختلط",
|
||||
"nav": {
|
||||
"allExtensions": "جميع الإضافات",
|
||||
"allInWorkflow": "الكل في: {workflowName}",
|
||||
"allInstalled": "جميع المثبتة",
|
||||
"conflicting": "تعارض",
|
||||
"inWorkflowSection": "في سير العمل",
|
||||
"installedSection": "المثبتة",
|
||||
"missingNodes": "عقد مفقودة",
|
||||
"notInstalled": "غير مثبت",
|
||||
"updatesAvailable": "تحديثات متوفرة"
|
||||
},
|
||||
"nightlyVersion": "ليلي",
|
||||
"noDescription": "لا يوجد وصف متاح",
|
||||
"noNodesFound": "لم يتم العثور على عقد",
|
||||
"noNodesFoundDescription": "لم يمكن تحليل عقد الحزمة، أو أن الحزمة هي امتداد للواجهة فقط ولا تحتوي على أي عقد.",
|
||||
"noResultsFound": "لم يتم العثور على نتائج مطابقة لبحثك.",
|
||||
"nodePack": "حزمة العقد",
|
||||
"nodePackInfo": "معلومات حزمة العقد",
|
||||
"notAvailable": "غير متوفر",
|
||||
"packsSelected": "الحزم المحددة",
|
||||
"repository": "المستودع",
|
||||
@@ -1352,7 +1337,6 @@
|
||||
"restartingBackend": "جاري إعادة تشغيل الخلفية لتطبيق التغييرات...",
|
||||
"searchPlaceholder": "بحث",
|
||||
"selectVersion": "اختر الإصدار",
|
||||
"selected": "المحدد",
|
||||
"sort": {
|
||||
"created": "الأحدث",
|
||||
"downloads": "الأكثر شيوعاً",
|
||||
|
||||
@@ -756,7 +756,6 @@
|
||||
"title": "Queue Progress",
|
||||
"total": "Total: {percent}",
|
||||
"colonPercent": ": {percent}",
|
||||
"inlineTotalLabel": "Total",
|
||||
"currentNode": "Current node:",
|
||||
"viewAllJobs": "View all jobs",
|
||||
"viewList": "List view",
|
||||
@@ -994,7 +993,8 @@
|
||||
"showAll": "Show all",
|
||||
"hidden": "Hidden / nested parameters",
|
||||
"hideAll": "Hide all",
|
||||
"showRecommended": "Show recommended widgets"
|
||||
"showRecommended": "Show recommended widgets",
|
||||
"cannotDeleteGlobal": "Cannot delete installed blueprints"
|
||||
},
|
||||
"electronFileDownload": {
|
||||
"inProgress": "In Progress",
|
||||
@@ -2545,7 +2545,7 @@
|
||||
"tagsPlaceholder": "e.g., models, checkpoint",
|
||||
"tryAdjustingFilters": "Try adjusting your search or filters",
|
||||
"unknown": "Unknown",
|
||||
"unsupportedUrlSource": "This URL is not supported. Use a direct model link from {sources}. See the how-to videos below for help.",
|
||||
"unsupportedUrlSource": "Only URLs from {sources} are supported",
|
||||
"upgradeFeatureDescription": "This feature is only available with Creator or Pro plans.",
|
||||
"upgradeToUnlockFeature": "Upgrade to unlock this feature",
|
||||
"upload": "Import",
|
||||
@@ -2825,5 +2825,15 @@
|
||||
"label": "Preview Version",
|
||||
"tooltip": "You are using a nightly version of ComfyUI. Please use the feedback button to share your thoughts about these features."
|
||||
}
|
||||
},
|
||||
"nodeFilters": {
|
||||
"hideDeprecated": "Hide Deprecated Nodes",
|
||||
"hideDeprecatedDescription": "Hides nodes marked as deprecated unless explicitly enabled",
|
||||
"hideExperimental": "Hide Experimental Nodes",
|
||||
"hideExperimentalDescription": "Hides nodes marked as experimental unless explicitly enabled",
|
||||
"hideDevOnly": "Hide Dev-Only Nodes",
|
||||
"hideDevOnlyDescription": "Hides nodes marked as dev-only unless dev mode is enabled",
|
||||
"hideSubgraph": "Hide Subgraph Nodes",
|
||||
"hideSubgraphDescription": "Temporarily hides subgraph nodes from node library and search"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,7 +107,6 @@
|
||||
"modelUploaded": "Modelo importado correctamente.",
|
||||
"noAssetsFound": "No se encontraron recursos",
|
||||
"noModelsInFolder": "No hay {type} disponibles en esta carpeta",
|
||||
"noResultsCanImport": "Intenta ajustar tu búsqueda o filtros.\nTambién puedes añadir modelos usando el botón \"Importar\" de arriba.",
|
||||
"noValidSourceDetected": "No se detectó una fuente de importación válida",
|
||||
"notSureLeaveAsIs": "¿No estás seguro? Déjalo como está",
|
||||
"onlyCivitaiUrlsSupported": "Solo se admiten URLs de Civitai",
|
||||
@@ -1261,10 +1260,8 @@
|
||||
}
|
||||
},
|
||||
"manager": {
|
||||
"actions": "Acciones",
|
||||
"allMissingNodesInstalled": "Todos los nodos faltantes se han instalado exitosamente",
|
||||
"applyChanges": "Aplicar Cambios",
|
||||
"basicInfo": "Información básica",
|
||||
"changingVersion": "Cambiando versión de {from} a {to}",
|
||||
"clickToFinishSetup": "Haz clic",
|
||||
"conflicts": {
|
||||
@@ -1327,24 +1324,12 @@
|
||||
"license": "Licencia",
|
||||
"loadingVersions": "Cargando versiones...",
|
||||
"mixedSelectionMessage": "No se puede realizar acción masiva en selección mixta",
|
||||
"nav": {
|
||||
"allExtensions": "Todas las extensiones",
|
||||
"allInWorkflow": "Todo en: {workflowName}",
|
||||
"allInstalled": "Todo instalado",
|
||||
"conflicting": "En conflicto",
|
||||
"inWorkflowSection": "EN EL FLUJO DE TRABAJO",
|
||||
"installedSection": "INSTALADO",
|
||||
"missingNodes": "Nodos faltantes",
|
||||
"notInstalled": "No instalado",
|
||||
"updatesAvailable": "Actualizaciones disponibles"
|
||||
},
|
||||
"nightlyVersion": "Nocturna",
|
||||
"noDescription": "No hay descripción disponible",
|
||||
"noNodesFound": "No se encontraron nodos",
|
||||
"noNodesFoundDescription": "Los nodos del paquete no se pudieron analizar, o el paquete es solo una extensión de frontend y no tiene ningún nodo.",
|
||||
"noResultsFound": "No se encontraron resultados que coincidan con tu búsqueda.",
|
||||
"nodePack": "Paquete de Nodos",
|
||||
"nodePackInfo": "Información del paquete de nodos",
|
||||
"notAvailable": "No Disponible",
|
||||
"packsSelected": "Paquetes Seleccionados",
|
||||
"repository": "Repositorio",
|
||||
@@ -1352,7 +1337,6 @@
|
||||
"restartingBackend": "Reiniciando backend para aplicar cambios...",
|
||||
"searchPlaceholder": "Buscar",
|
||||
"selectVersion": "Seleccionar Versión",
|
||||
"selected": "Seleccionado",
|
||||
"sort": {
|
||||
"created": "Más reciente",
|
||||
"downloads": "Más Popular",
|
||||
|
||||
@@ -107,7 +107,6 @@
|
||||
"modelUploaded": "مدل با موفقیت وارد شد.",
|
||||
"noAssetsFound": "هیچ داراییای یافت نشد",
|
||||
"noModelsInFolder": "هیچ {type} در این پوشه موجود نیست",
|
||||
"noResultsCanImport": "جستجو یا فیلترهای خود را تغییر دهید.\nهمچنین میتوانید مدلها را با استفاده از دکمه «وارد کردن» در بالا اضافه کنید.",
|
||||
"noValidSourceDetected": "هیچ منبع واردات معتبری شناسایی نشد",
|
||||
"notSureLeaveAsIs": "مطمئن نیستید؟ همین را باقی بگذارید",
|
||||
"onlyCivitaiUrlsSupported": "فقط URLهای Civitai پشتیبانی میشوند",
|
||||
@@ -1261,10 +1260,8 @@
|
||||
}
|
||||
},
|
||||
"manager": {
|
||||
"actions": "اقدامات",
|
||||
"allMissingNodesInstalled": "همه نودهای مفقود با موفقیت نصب شدند",
|
||||
"applyChanges": "اعمال تغییرات",
|
||||
"basicInfo": "اطلاعات پایه",
|
||||
"changingVersion": "تغییر نسخه از {from} به {to}",
|
||||
"clickToFinishSetup": "کلیک کنید",
|
||||
"conflicts": {
|
||||
@@ -1327,24 +1324,12 @@
|
||||
"license": "مجوز",
|
||||
"loadingVersions": "در حال بارگذاری نسخهها...",
|
||||
"mixedSelectionMessage": "امکان انجام عملیات گروهی روی انتخاب ترکیبی وجود ندارد",
|
||||
"nav": {
|
||||
"allExtensions": "همه افزونهها",
|
||||
"allInWorkflow": "همه در: {workflowName}",
|
||||
"allInstalled": "همه نصب شدهها",
|
||||
"conflicting": "دارای تداخل",
|
||||
"inWorkflowSection": "در Workflow",
|
||||
"installedSection": "نصب شده",
|
||||
"missingNodes": "Nodeهای مفقود",
|
||||
"notInstalled": "نصب نشده",
|
||||
"updatesAvailable": "بهروزرسانیهای موجود"
|
||||
},
|
||||
"nightlyVersion": "نسخه nightly",
|
||||
"noDescription": "توضیحی موجود نیست",
|
||||
"noNodesFound": "نودی یافت نشد",
|
||||
"noNodesFoundDescription": "نودهای این بسته قابل تجزیه نبودند یا این بسته فقط یک افزونه فرانتاند است و نودی ندارد.",
|
||||
"noResultsFound": "نتیجهای مطابق با جستجوی شما یافت نشد.",
|
||||
"nodePack": "بسته نود",
|
||||
"nodePackInfo": "اطلاعات Node Pack",
|
||||
"notAvailable": "در دسترس نیست",
|
||||
"packsSelected": "بسته انتخاب شد",
|
||||
"repository": "مخزن",
|
||||
@@ -1352,7 +1337,6 @@
|
||||
"restartingBackend": "در حال راهاندازی مجدد backend برای اعمال تغییرات...",
|
||||
"searchPlaceholder": "جستجو",
|
||||
"selectVersion": "انتخاب نسخه",
|
||||
"selected": "انتخاب شده",
|
||||
"sort": {
|
||||
"created": "جدیدترین",
|
||||
"downloads": "محبوبترین",
|
||||
|
||||
@@ -107,7 +107,6 @@
|
||||
"modelUploaded": "Modèle importé avec succès.",
|
||||
"noAssetsFound": "Aucune ressource trouvée",
|
||||
"noModelsInFolder": "Aucun {type} disponible dans ce dossier",
|
||||
"noResultsCanImport": "Essayez d’ajuster votre recherche ou vos filtres.\nVous pouvez également ajouter des modèles en utilisant le bouton « Importer » ci-dessus.",
|
||||
"noValidSourceDetected": "Aucune source d'importation valide détectée",
|
||||
"notSureLeaveAsIs": "Vous n'êtes pas sûr ? Laissez tel quel",
|
||||
"onlyCivitaiUrlsSupported": "Seules les URL Civitai sont prises en charge",
|
||||
@@ -1261,10 +1260,8 @@
|
||||
}
|
||||
},
|
||||
"manager": {
|
||||
"actions": "Actions",
|
||||
"allMissingNodesInstalled": "Tous les nœuds manquants ont été installés avec succès",
|
||||
"applyChanges": "Appliquer les modifications",
|
||||
"basicInfo": "Informations de base",
|
||||
"changingVersion": "Changement de version de {from} à {to}",
|
||||
"clickToFinishSetup": "Cliquez",
|
||||
"conflicts": {
|
||||
@@ -1327,24 +1324,12 @@
|
||||
"license": "Licence",
|
||||
"loadingVersions": "Chargement des versions...",
|
||||
"mixedSelectionMessage": "Impossible d'effectuer une action groupée sur une sélection mixte",
|
||||
"nav": {
|
||||
"allExtensions": "Toutes les extensions",
|
||||
"allInWorkflow": "Tout dans : {workflowName}",
|
||||
"allInstalled": "Tout installé",
|
||||
"conflicting": "En conflit",
|
||||
"inWorkflowSection": "DANS LE FLUX DE TRAVAIL",
|
||||
"installedSection": "INSTALLÉ",
|
||||
"missingNodes": "Nœuds manquants",
|
||||
"notInstalled": "Non installé",
|
||||
"updatesAvailable": "Mises à jour disponibles"
|
||||
},
|
||||
"nightlyVersion": "Nocturne",
|
||||
"noDescription": "Aucune description disponible",
|
||||
"noNodesFound": "Aucun nœud trouvé",
|
||||
"noNodesFoundDescription": "Les nœuds du pack n'ont pas pu être analysés, ou le pack est une extension frontend uniquement et n'a pas de nœuds.",
|
||||
"noResultsFound": "Aucun résultat trouvé correspondant à votre recherche.",
|
||||
"nodePack": "Pack de Nœuds",
|
||||
"nodePackInfo": "Informations sur le pack de nœuds",
|
||||
"notAvailable": "Non disponible",
|
||||
"packsSelected": "Packs sélectionnés",
|
||||
"repository": "Référentiel",
|
||||
@@ -1352,7 +1337,6 @@
|
||||
"restartingBackend": "Redémarrage du backend pour appliquer les modifications...",
|
||||
"searchPlaceholder": "Recherche",
|
||||
"selectVersion": "Sélectionner la version",
|
||||
"selected": "Sélectionné",
|
||||
"sort": {
|
||||
"created": "Le plus récent",
|
||||
"downloads": "Le plus populaire",
|
||||
|
||||
@@ -107,7 +107,6 @@
|
||||
"modelUploaded": "モデルが正常にインポートされました。",
|
||||
"noAssetsFound": "アセットが見つかりません",
|
||||
"noModelsInFolder": "このフォルダには{type}がありません",
|
||||
"noResultsCanImport": "検索やフィルターを調整してみてください。\nまた、上の「インポート」ボタンからモデルを追加することもできます。",
|
||||
"noValidSourceDetected": "有効なインポート元が検出されませんでした",
|
||||
"notSureLeaveAsIs": "分からない場合はそのままにしてください",
|
||||
"onlyCivitaiUrlsSupported": "CivitaiのURLのみサポートされています",
|
||||
@@ -1261,10 +1260,8 @@
|
||||
}
|
||||
},
|
||||
"manager": {
|
||||
"actions": "アクション",
|
||||
"allMissingNodesInstalled": "すべての不足しているノードが正常にインストールされました",
|
||||
"applyChanges": "変更を適用",
|
||||
"basicInfo": "基本情報",
|
||||
"changingVersion": "バージョンを {from} から {to} に変更",
|
||||
"clickToFinishSetup": "クリック",
|
||||
"conflicts": {
|
||||
@@ -1327,24 +1324,12 @@
|
||||
"license": "ライセンス",
|
||||
"loadingVersions": "バージョンを読み込んでいます...",
|
||||
"mixedSelectionMessage": "混在した選択では一括操作を実行できません",
|
||||
"nav": {
|
||||
"allExtensions": "すべての拡張機能",
|
||||
"allInWorkflow": "{workflowName} 内のすべて",
|
||||
"allInstalled": "すべてインストール済み",
|
||||
"conflicting": "競合",
|
||||
"inWorkflowSection": "ワークフロー内",
|
||||
"installedSection": "インストール済み",
|
||||
"missingNodes": "不足しているノード",
|
||||
"notInstalled": "未インストール",
|
||||
"updatesAvailable": "アップデートあり"
|
||||
},
|
||||
"nightlyVersion": "ナイトリー",
|
||||
"noDescription": "説明はありません",
|
||||
"noNodesFound": "ノードが見つかりません",
|
||||
"noNodesFoundDescription": "パックのノードは解析できなかったか、パックがフロントエンドの拡張機能のみでノードがない可能性があります。",
|
||||
"noResultsFound": "検索に一致する結果が見つかりませんでした。",
|
||||
"nodePack": "ノードパック",
|
||||
"nodePackInfo": "ノードパック情報",
|
||||
"notAvailable": "利用不可",
|
||||
"packsSelected": "選択したパック",
|
||||
"repository": "リポジトリ",
|
||||
@@ -1352,7 +1337,6 @@
|
||||
"restartingBackend": "変更を適用するためにバックエンドを再起動しています...",
|
||||
"searchPlaceholder": "検索",
|
||||
"selectVersion": "バージョンを選択",
|
||||
"selected": "選択済み",
|
||||
"sort": {
|
||||
"created": "最新",
|
||||
"downloads": "最も人気",
|
||||
|
||||
@@ -107,7 +107,6 @@
|
||||
"modelUploaded": "모델이 성공적으로 가져와졌습니다.",
|
||||
"noAssetsFound": "에셋을 찾을 수 없습니다",
|
||||
"noModelsInFolder": "이 폴더에 사용 가능한 {type}이(가) 없습니다",
|
||||
"noResultsCanImport": "검색어나 필터를 조정해보세요.\n또는 위의 \"가져오기\" 버튼을 사용해 모델을 추가할 수 있습니다.",
|
||||
"noValidSourceDetected": "유효한 가져오기 소스를 감지하지 못했습니다",
|
||||
"notSureLeaveAsIs": "잘 모르겠다면 그대로 두세요",
|
||||
"onlyCivitaiUrlsSupported": "Civitai URL만 지원됩니다",
|
||||
@@ -1261,10 +1260,8 @@
|
||||
}
|
||||
},
|
||||
"manager": {
|
||||
"actions": "작업",
|
||||
"allMissingNodesInstalled": "누락된 모든 노드가 성공적으로 설치되었습니다",
|
||||
"applyChanges": "변경사항 적용",
|
||||
"basicInfo": "기본 정보",
|
||||
"changingVersion": "{from}에서 {to}(으)로 버전 변경 중",
|
||||
"clickToFinishSetup": "클릭",
|
||||
"conflicts": {
|
||||
@@ -1327,24 +1324,12 @@
|
||||
"license": "라이선스",
|
||||
"loadingVersions": "버전 로딩 중...",
|
||||
"mixedSelectionMessage": "혼합 선택에 대해 일괄 작업을 수행할 수 없습니다",
|
||||
"nav": {
|
||||
"allExtensions": "모든 확장 프로그램",
|
||||
"allInWorkflow": "모두: {workflowName}",
|
||||
"allInstalled": "모두 설치됨",
|
||||
"conflicting": "충돌",
|
||||
"inWorkflowSection": "워크플로우 내",
|
||||
"installedSection": "설치됨",
|
||||
"missingNodes": "누락된 노드",
|
||||
"notInstalled": "미설치",
|
||||
"updatesAvailable": "업데이트 가능"
|
||||
},
|
||||
"nightlyVersion": "최신 테스트 버전(nightly)",
|
||||
"noDescription": "설명이 없습니다",
|
||||
"noNodesFound": "노드를 찾을 수 없습니다",
|
||||
"noNodesFoundDescription": "팩의 노드를 파싱할 수 없거나, 팩이 프론트엔드 확장만을 가지고 있어서 노드가 없습니다.",
|
||||
"noResultsFound": "검색과 일치하는 결과가 없습니다.",
|
||||
"nodePack": "노드 팩",
|
||||
"nodePackInfo": "노드 팩 정보",
|
||||
"notAvailable": "사용 불가",
|
||||
"packsSelected": "선택한 노드 팩",
|
||||
"repository": "저장소",
|
||||
@@ -1352,7 +1337,6 @@
|
||||
"restartingBackend": "변경사항을 적용하기 위해 백엔드를 다시 시작하는 중...",
|
||||
"searchPlaceholder": "검색",
|
||||
"selectVersion": "버전 선택",
|
||||
"selected": "선택됨",
|
||||
"sort": {
|
||||
"created": "최신",
|
||||
"downloads": "가장 인기 있는",
|
||||
|
||||
@@ -107,7 +107,6 @@
|
||||
"modelUploaded": "Modelo importado com sucesso.",
|
||||
"noAssetsFound": "Nenhum ativo encontrado",
|
||||
"noModelsInFolder": "Nenhum {type} disponível nesta pasta",
|
||||
"noResultsCanImport": "Tente ajustar sua busca ou filtros.\nVocê também pode adicionar modelos usando o botão \"Importar\" acima.",
|
||||
"noValidSourceDetected": "Nenhuma fonte de importação válida detectada",
|
||||
"notSureLeaveAsIs": "Não tem certeza? Deixe como está",
|
||||
"onlyCivitaiUrlsSupported": "Apenas URLs do Civitai são suportadas",
|
||||
@@ -1261,10 +1260,8 @@
|
||||
}
|
||||
},
|
||||
"manager": {
|
||||
"actions": "Ações",
|
||||
"allMissingNodesInstalled": "Todos os nodes ausentes foram instalados com sucesso",
|
||||
"applyChanges": "Aplicar Alterações",
|
||||
"basicInfo": "Informações Básicas",
|
||||
"changingVersion": "Alterando versão de {from} para pt-BR",
|
||||
"clickToFinishSetup": "Clique",
|
||||
"conflicts": {
|
||||
@@ -1327,24 +1324,12 @@
|
||||
"license": "Licença",
|
||||
"loadingVersions": "Carregando versões...",
|
||||
"mixedSelectionMessage": "Não é possível realizar ação em massa em seleção mista",
|
||||
"nav": {
|
||||
"allExtensions": "Todas as Extensões",
|
||||
"allInWorkflow": "Todos em: {workflowName}",
|
||||
"allInstalled": "Todos Instalados",
|
||||
"conflicting": "Conflitante",
|
||||
"inWorkflowSection": "NO FLUXO DE TRABALHO",
|
||||
"installedSection": "INSTALADO",
|
||||
"missingNodes": "Nós Ausentes",
|
||||
"notInstalled": "Não Instalado",
|
||||
"updatesAvailable": "Atualizações Disponíveis"
|
||||
},
|
||||
"nightlyVersion": "Noturna",
|
||||
"noDescription": "Nenhuma descrição disponível",
|
||||
"noNodesFound": "Nenhum node encontrado",
|
||||
"noNodesFoundDescription": "Os nodes do pacote não puderam ser analisados ou o pacote é apenas uma extensão de frontend e não possui nodes.",
|
||||
"noResultsFound": "Nenhum resultado encontrado para sua busca.",
|
||||
"nodePack": "Node Pack",
|
||||
"nodePackInfo": "Informações do Pacote de Nós",
|
||||
"notAvailable": "Não Disponível",
|
||||
"packsSelected": "pacotes selecionados",
|
||||
"repository": "Repositório",
|
||||
@@ -1352,7 +1337,6 @@
|
||||
"restartingBackend": "Reiniciando backend para aplicar as alterações...",
|
||||
"searchPlaceholder": "Buscar",
|
||||
"selectVersion": "Selecionar Versão",
|
||||
"selected": "Selecionado",
|
||||
"sort": {
|
||||
"created": "Mais Novos",
|
||||
"downloads": "Mais Populares",
|
||||
|
||||
@@ -107,7 +107,6 @@
|
||||
"modelUploaded": "Модель успешно импортирована.",
|
||||
"noAssetsFound": "Ресурсы не найдены",
|
||||
"noModelsInFolder": "Нет {type} в этой папке",
|
||||
"noResultsCanImport": "Попробуйте изменить параметры поиска или фильтры.\nВы также можете добавить модели с помощью кнопки «Импортировать» выше.",
|
||||
"noValidSourceDetected": "Не обнаружен действительный источник импорта",
|
||||
"notSureLeaveAsIs": "Не уверены? Просто оставьте как есть",
|
||||
"onlyCivitaiUrlsSupported": "Поддерживаются только ссылки Civitai",
|
||||
@@ -1261,10 +1260,8 @@
|
||||
}
|
||||
},
|
||||
"manager": {
|
||||
"actions": "Действия",
|
||||
"allMissingNodesInstalled": "Все отсутствующие ноды успешно установлены",
|
||||
"applyChanges": "Применить изменения",
|
||||
"basicInfo": "Основная информация",
|
||||
"changingVersion": "Изменение версии с {from} на {to}",
|
||||
"clickToFinishSetup": "Нажмите",
|
||||
"conflicts": {
|
||||
@@ -1327,24 +1324,12 @@
|
||||
"license": "Лицензия",
|
||||
"loadingVersions": "Загрузка версий...",
|
||||
"mixedSelectionMessage": "Невозможно выполнить массовое действие для смешанного выбора",
|
||||
"nav": {
|
||||
"allExtensions": "Все расширения",
|
||||
"allInWorkflow": "Все в: {workflowName}",
|
||||
"allInstalled": "Все установленные",
|
||||
"conflicting": "Конфликтующие",
|
||||
"inWorkflowSection": "В РАБОЧЕМ ПРОЦЕССЕ",
|
||||
"installedSection": "УСТАНОВЛЕНО",
|
||||
"missingNodes": "Отсутствующие узлы",
|
||||
"notInstalled": "Не установлено",
|
||||
"updatesAvailable": "Доступны обновления"
|
||||
},
|
||||
"nightlyVersion": "Ночная",
|
||||
"noDescription": "Описание отсутствует",
|
||||
"noNodesFound": "Узлы не найдены",
|
||||
"noNodesFoundDescription": "Узлы пакета не могут быть проанализированы, или пакет является только расширением интерфейса и не имеет узлов.",
|
||||
"noResultsFound": "По вашему запросу ничего не найдено.",
|
||||
"nodePack": "Пакет Узлов",
|
||||
"nodePackInfo": "Информация о пакете узлов",
|
||||
"notAvailable": "Недоступно",
|
||||
"packsSelected": "Выбрано пакетов",
|
||||
"repository": "Репозиторий",
|
||||
@@ -1352,7 +1337,6 @@
|
||||
"restartingBackend": "Перезапуск бэкенда для применения изменений...",
|
||||
"searchPlaceholder": "Поиск",
|
||||
"selectVersion": "Выберите версию",
|
||||
"selected": "Выбрано",
|
||||
"sort": {
|
||||
"created": "Новейшие",
|
||||
"downloads": "Самые популярные",
|
||||
|
||||
@@ -107,7 +107,6 @@
|
||||
"modelUploaded": "Model başarıyla içe aktarıldı.",
|
||||
"noAssetsFound": "Varlık bulunamadı",
|
||||
"noModelsInFolder": "Bu klasörde {type} mevcut değil",
|
||||
"noResultsCanImport": "Aramanızı veya filtrelerinizi ayarlamayı deneyin.\nAyrıca yukarıdaki \"İçe Aktar\" butonunu kullanarak modeller ekleyebilirsiniz.",
|
||||
"noValidSourceDetected": "Geçerli bir içe aktarma kaynağı tespit edilmedi",
|
||||
"notSureLeaveAsIs": "Emin değil misiniz? Olduğu gibi bırakın",
|
||||
"onlyCivitaiUrlsSupported": "Yalnızca Civitai URL'leri destekleniyor",
|
||||
@@ -1261,10 +1260,8 @@
|
||||
}
|
||||
},
|
||||
"manager": {
|
||||
"actions": "Eylemler",
|
||||
"allMissingNodesInstalled": "Tüm eksik düğümler başarıyla yüklendi",
|
||||
"applyChanges": "Değişiklikleri Uygula",
|
||||
"basicInfo": "Temel Bilgiler",
|
||||
"changingVersion": "Sürüm {from} sürümünden {to} sürümüne değiştiriliyor",
|
||||
"clickToFinishSetup": "Kurulumu tamamlamak için tıklayın",
|
||||
"conflicts": {
|
||||
@@ -1327,24 +1324,12 @@
|
||||
"license": "Lisans",
|
||||
"loadingVersions": "Sürümler yükleniyor...",
|
||||
"mixedSelectionMessage": "Karışık seçim üzerinde toplu işlem yapılamaz",
|
||||
"nav": {
|
||||
"allExtensions": "Tüm Eklentiler",
|
||||
"allInWorkflow": "Tümü: {workflowName}",
|
||||
"allInstalled": "Tümü yüklü",
|
||||
"conflicting": "Çakışan",
|
||||
"inWorkflowSection": "İŞ AKIŞINDA",
|
||||
"installedSection": "YÜKLÜ",
|
||||
"missingNodes": "Eksik Düğümler",
|
||||
"notInstalled": "Yüklü Değil",
|
||||
"updatesAvailable": "Güncellemeler Mevcut"
|
||||
},
|
||||
"nightlyVersion": "Gecelik",
|
||||
"noDescription": "Açıklama yok",
|
||||
"noNodesFound": "Düğüm bulunamadı",
|
||||
"noNodesFoundDescription": "Paketin düğümleri ya ayrıştırılamadı ya da paket yalnızca bir ön uç uzantısı ve herhangi bir düğüme sahip değil.",
|
||||
"noResultsFound": "Aramanızla eşleşen sonuç bulunamadı.",
|
||||
"nodePack": "Düğüm Paketi",
|
||||
"nodePackInfo": "Düğüm Paketi Bilgisi",
|
||||
"notAvailable": "Mevcut Değil",
|
||||
"packsSelected": "paket seçildi",
|
||||
"repository": "Depo",
|
||||
@@ -1352,7 +1337,6 @@
|
||||
"restartingBackend": "Değişiklikleri uygulamak için arka uç yeniden başlatılıyor...",
|
||||
"searchPlaceholder": "Ara",
|
||||
"selectVersion": "Sürüm Seç",
|
||||
"selected": "Seçildi",
|
||||
"sort": {
|
||||
"created": "En Yeni",
|
||||
"downloads": "En Popüler",
|
||||
|
||||
@@ -107,7 +107,6 @@
|
||||
"modelUploaded": "模型匯入成功。",
|
||||
"noAssetsFound": "找不到資產",
|
||||
"noModelsInFolder": "此資料夾中沒有可用的 {type}",
|
||||
"noResultsCanImport": "請嘗試調整搜尋或篩選條件。\n你也可以使用上方的「匯入」按鈕新增模型。",
|
||||
"noValidSourceDetected": "未偵測到有效的匯入來源",
|
||||
"notSureLeaveAsIs": "不確定?請保持原樣",
|
||||
"onlyCivitaiUrlsSupported": "僅支援 Civitai 的網址",
|
||||
@@ -1261,10 +1260,8 @@
|
||||
}
|
||||
},
|
||||
"manager": {
|
||||
"actions": "操作",
|
||||
"allMissingNodesInstalled": "所有缺少的節點已成功安裝",
|
||||
"applyChanges": "套用變更",
|
||||
"basicInfo": "基本資訊",
|
||||
"changingVersion": "正在將版本從 {from} 變更為 {to}",
|
||||
"clickToFinishSetup": "點擊",
|
||||
"conflicts": {
|
||||
@@ -1327,24 +1324,12 @@
|
||||
"license": "授權條款",
|
||||
"loadingVersions": "正在載入版本...",
|
||||
"mixedSelectionMessage": "無法對混合選取執行批次操作",
|
||||
"nav": {
|
||||
"allExtensions": "所有擴充功能",
|
||||
"allInWorkflow": "全部於:{workflowName}",
|
||||
"allInstalled": "全部已安裝",
|
||||
"conflicting": "有衝突",
|
||||
"inWorkflowSection": "工作流程中",
|
||||
"installedSection": "已安裝",
|
||||
"missingNodes": "缺少節點",
|
||||
"notInstalled": "未安裝",
|
||||
"updatesAvailable": "有可用更新"
|
||||
},
|
||||
"nightlyVersion": "每夜建置版",
|
||||
"noDescription": "沒有可用的說明",
|
||||
"noNodesFound": "找不到任何節點",
|
||||
"noNodesFoundDescription": "此套件的節點無法解析,或此套件僅為前端擴充功能,沒有任何節點。",
|
||||
"noResultsFound": "找不到符合搜尋條件的結果。",
|
||||
"nodePack": "節點包",
|
||||
"nodePackInfo": "節點包資訊",
|
||||
"notAvailable": "不可用",
|
||||
"packsSelected": "已選擇套件",
|
||||
"repository": "儲存庫",
|
||||
@@ -1352,7 +1337,6 @@
|
||||
"restartingBackend": "正在重新啟動後端以套用變更...",
|
||||
"searchPlaceholder": "搜尋",
|
||||
"selectVersion": "選擇版本",
|
||||
"selected": "已選取",
|
||||
"sort": {
|
||||
"created": "最新上架",
|
||||
"downloads": "最受歡迎",
|
||||
|
||||
@@ -107,7 +107,6 @@
|
||||
"modelUploaded": "模型导入成功!🎉",
|
||||
"noAssetsFound": "未找到资产",
|
||||
"noModelsInFolder": "此文件夹中没有可用的{type}",
|
||||
"noResultsCanImport": "尝试调整您的搜索或筛选条件。\n您也可以使用上方的“导入”按钮添加模型。",
|
||||
"noValidSourceDetected": "检测不到有效的导入源",
|
||||
"notSureLeaveAsIs": "不确定?那就放着不管吧",
|
||||
"onlyCivitaiUrlsSupported": "仅支持 Civitai 链接",
|
||||
@@ -1261,10 +1260,8 @@
|
||||
}
|
||||
},
|
||||
"manager": {
|
||||
"actions": "操作",
|
||||
"allMissingNodesInstalled": "所有缺失节点已成功安装",
|
||||
"applyChanges": "应用更改",
|
||||
"basicInfo": "基本信息",
|
||||
"changingVersion": "将版本从 {from} 更改为 {to}",
|
||||
"clickToFinishSetup": "点击",
|
||||
"conflicts": {
|
||||
@@ -1327,24 +1324,12 @@
|
||||
"license": "许可证",
|
||||
"loadingVersions": "正在加载版本...",
|
||||
"mixedSelectionMessage": "无法对混合选择执行批量操作",
|
||||
"nav": {
|
||||
"allExtensions": "全部扩展",
|
||||
"allInWorkflow": "全部在:{workflowName}",
|
||||
"allInstalled": "全部已安装",
|
||||
"conflicting": "存在冲突",
|
||||
"inWorkflowSection": "工作流中",
|
||||
"installedSection": "已安装",
|
||||
"missingNodes": "缺失节点",
|
||||
"notInstalled": "未安装",
|
||||
"updatesAvailable": "有可用更新"
|
||||
},
|
||||
"nightlyVersion": "每夜",
|
||||
"noDescription": "无可用描述",
|
||||
"noNodesFound": "未找到节点",
|
||||
"noNodesFoundDescription": "无法解析包的节点,或者该包仅为前端扩展,没有任何节点。",
|
||||
"noResultsFound": "未找到符合您搜索的结果。",
|
||||
"nodePack": "节点包",
|
||||
"nodePackInfo": "节点包信息",
|
||||
"notAvailable": "不可用",
|
||||
"packsSelected": "选定的包",
|
||||
"repository": "仓库",
|
||||
@@ -1352,7 +1337,6 @@
|
||||
"restartingBackend": "正在重启后端以应用更改...",
|
||||
"searchPlaceholder": "搜索",
|
||||
"selectVersion": "选择版本",
|
||||
"selected": "已选择",
|
||||
"sort": {
|
||||
"created": "最新",
|
||||
"downloads": "最受欢迎",
|
||||
|
||||
@@ -10,7 +10,7 @@ const createAssetData = (
|
||||
overrides: Partial<AssetDisplayItem> = {}
|
||||
): AssetDisplayItem => ({
|
||||
...baseAsset,
|
||||
secondaryText:
|
||||
description:
|
||||
'High-quality realistic images with perfect detail and natural lighting effects for professional photography',
|
||||
badges: [
|
||||
{ label: 'checkpoints', type: 'type' },
|
||||
@@ -131,21 +131,20 @@ export const EdgeCases: Story = {
|
||||
// Default case for comparison
|
||||
createAssetData({
|
||||
name: 'Complete Data',
|
||||
secondaryText: 'Asset with all data present for comparison'
|
||||
description: 'Asset with all data present for comparison'
|
||||
}),
|
||||
// No badges
|
||||
createAssetData({
|
||||
id: 'no-badges',
|
||||
name: 'No Badges',
|
||||
secondaryText:
|
||||
'Testing graceful handling when badges are not provided',
|
||||
description: 'Testing graceful handling when badges are not provided',
|
||||
badges: []
|
||||
}),
|
||||
// No stars
|
||||
createAssetData({
|
||||
id: 'no-stars',
|
||||
name: 'No Stars',
|
||||
secondaryText: 'Testing missing stars data gracefully',
|
||||
description: 'Testing missing stars data gracefully',
|
||||
stats: {
|
||||
downloadCount: '1.8k',
|
||||
formattedDate: '3/15/25'
|
||||
@@ -155,7 +154,7 @@ export const EdgeCases: Story = {
|
||||
createAssetData({
|
||||
id: 'no-downloads',
|
||||
name: 'No Downloads',
|
||||
secondaryText: 'Testing missing downloads data gracefully',
|
||||
description: 'Testing missing downloads data gracefully',
|
||||
stats: {
|
||||
stars: '4.2k',
|
||||
formattedDate: '3/15/25'
|
||||
@@ -165,7 +164,7 @@ export const EdgeCases: Story = {
|
||||
createAssetData({
|
||||
id: 'no-date',
|
||||
name: 'No Date',
|
||||
secondaryText: 'Testing missing date data gracefully',
|
||||
description: 'Testing missing date data gracefully',
|
||||
stats: {
|
||||
stars: '4.2k',
|
||||
downloadCount: '1.8k'
|
||||
@@ -175,21 +174,21 @@ export const EdgeCases: Story = {
|
||||
createAssetData({
|
||||
id: 'no-stats',
|
||||
name: 'No Stats',
|
||||
secondaryText: 'Testing when all stats are missing',
|
||||
description: 'Testing when all stats are missing',
|
||||
stats: {}
|
||||
}),
|
||||
// Long secondaryText
|
||||
// Long description
|
||||
createAssetData({
|
||||
id: 'long-desc',
|
||||
name: 'Long Description',
|
||||
secondaryText:
|
||||
description:
|
||||
'This is a very long description that should demonstrate how the component handles text overflow and truncation with ellipsis. The description continues with even more content to ensure we test the 2-line clamp behavior properly and see how it renders when there is significantly more text than can fit in the allocated space.'
|
||||
}),
|
||||
// Minimal data
|
||||
createAssetData({
|
||||
id: 'minimal',
|
||||
name: 'Minimal',
|
||||
secondaryText: 'Basic model',
|
||||
description: 'Basic model',
|
||||
tags: ['models'],
|
||||
badges: [],
|
||||
stats: {}
|
||||
|
||||
@@ -82,14 +82,14 @@
|
||||
</h3>
|
||||
<p
|
||||
:id="descId"
|
||||
v-tooltip.top="{ value: asset.secondaryText, showDelay: tooltipDelay }"
|
||||
v-tooltip.top="{ value: asset.description, showDelay: tooltipDelay }"
|
||||
:class="
|
||||
cn(
|
||||
'm-0 text-sm line-clamp-2 [-webkit-box-orient:vertical] [-webkit-line-clamp:2] [display:-webkit-box] text-muted-foreground'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ asset.secondaryText }}
|
||||
{{ asset.description }}
|
||||
</p>
|
||||
<div class="flex items-center justify-between gap-2 mt-auto">
|
||||
<div class="flex gap-3 text-xs text-muted-foreground">
|
||||
|
||||
@@ -34,7 +34,7 @@ describe('ModelInfoPanel', () => {
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
last_access_time: '2024-01-01T00:00:00Z',
|
||||
secondaryText: 'A test model description',
|
||||
description: 'A test model description',
|
||||
badges: [],
|
||||
stats: {},
|
||||
...overrides
|
||||
|
||||
@@ -84,14 +84,14 @@ describe('useAssetBrowser', () => {
|
||||
expect(result.name).toBe(apiAsset.name)
|
||||
|
||||
// Adds display properties
|
||||
expect(result.secondaryText).toBe('test-asset.safetensors')
|
||||
expect(result.description).toBe('Test model')
|
||||
expect(result.badges).toContainEqual({
|
||||
label: 'checkpoints',
|
||||
type: 'type'
|
||||
})
|
||||
})
|
||||
|
||||
it('creates secondaryText from filename when metadata missing', () => {
|
||||
it('creates fallback description from tags when metadata missing', () => {
|
||||
const apiAsset = createApiAsset({
|
||||
tags: ['models', 'loras'],
|
||||
user_metadata: undefined
|
||||
@@ -100,7 +100,7 @@ describe('useAssetBrowser', () => {
|
||||
const { filteredAssets } = useAssetBrowser(ref([apiAsset]))
|
||||
const result = filteredAssets.value[0]
|
||||
|
||||
expect(result.secondaryText).toBe('test-asset.safetensors')
|
||||
expect(result.description).toBe('loras model')
|
||||
})
|
||||
|
||||
it('removes category prefix from badge labels', () => {
|
||||
|
||||
@@ -9,8 +9,8 @@ import type { FilterState } from '@/platform/assets/components/AssetFilterBar.vu
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import {
|
||||
getAssetBaseModels,
|
||||
getAssetDisplayName,
|
||||
getAssetFilename
|
||||
getAssetDescription,
|
||||
getAssetDisplayName
|
||||
} from '@/platform/assets/utils/assetMetadataUtils'
|
||||
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
|
||||
import type { NavGroupData, NavItemData } from '@/types/navTypes'
|
||||
@@ -70,7 +70,7 @@ type AssetBadge = {
|
||||
|
||||
// Display properties for transformed assets
|
||||
export interface AssetDisplayItem extends AssetItem {
|
||||
secondaryText: string
|
||||
description: string
|
||||
badges: AssetBadge[]
|
||||
stats: {
|
||||
formattedDate?: string
|
||||
@@ -116,11 +116,15 @@ export function useAssetBrowser(
|
||||
|
||||
// Transform API asset to display asset
|
||||
function transformAssetForDisplay(asset: AssetItem): AssetDisplayItem {
|
||||
const secondaryText = getAssetFilename(asset)
|
||||
// Extract description from metadata or create from tags
|
||||
const typeTag = asset.tags.find((tag) => tag !== 'models')
|
||||
const description =
|
||||
getAssetDescription(asset) ||
|
||||
`${typeTag || t('assetBrowser.unknown')} model`
|
||||
|
||||
// Create badges from tags and metadata
|
||||
const badges: AssetBadge[] = []
|
||||
|
||||
const typeTag = asset.tags.find((tag) => tag !== 'models')
|
||||
// Type badge from non-root tag
|
||||
if (typeTag) {
|
||||
// Remove category prefix from badge label (e.g. "checkpoint/model" → "model")
|
||||
@@ -148,7 +152,7 @@ export function useAssetBrowser(
|
||||
|
||||
return {
|
||||
...asset,
|
||||
secondaryText,
|
||||
description,
|
||||
badges,
|
||||
stats
|
||||
}
|
||||
|
||||
@@ -4,8 +4,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
import { useMediaAssetActions } from './useMediaAssetActions'
|
||||
|
||||
// Use vi.hoisted to create a mutable reference for isCloud
|
||||
const mockIsCloud = vi.hoisted(() => ({ value: false }))
|
||||
|
||||
@@ -128,6 +126,7 @@ describe('useMediaAssetActions', () => {
|
||||
})
|
||||
|
||||
it('should use asset.name as filename', async () => {
|
||||
const { useMediaAssetActions } = await import('./useMediaAssetActions')
|
||||
const actions = useMediaAssetActions()
|
||||
|
||||
const asset = createMockAsset({
|
||||
@@ -147,6 +146,7 @@ describe('useMediaAssetActions', () => {
|
||||
})
|
||||
|
||||
it('should use asset_hash as filename when available', async () => {
|
||||
const { useMediaAssetActions } = await import('./useMediaAssetActions')
|
||||
const actions = useMediaAssetActions()
|
||||
|
||||
const asset = createMockAsset({
|
||||
@@ -160,6 +160,7 @@ describe('useMediaAssetActions', () => {
|
||||
})
|
||||
|
||||
it('should fall back to asset.name when asset_hash is not available', async () => {
|
||||
const { useMediaAssetActions } = await import('./useMediaAssetActions')
|
||||
const actions = useMediaAssetActions()
|
||||
|
||||
const asset = createMockAsset({
|
||||
@@ -173,6 +174,7 @@ describe('useMediaAssetActions', () => {
|
||||
})
|
||||
|
||||
it('should fall back to asset.name when asset_hash is null', async () => {
|
||||
const { useMediaAssetActions } = await import('./useMediaAssetActions')
|
||||
const actions = useMediaAssetActions()
|
||||
|
||||
const asset = createMockAsset({
|
||||
@@ -194,6 +196,7 @@ describe('useMediaAssetActions', () => {
|
||||
})
|
||||
|
||||
it('should use asset_hash for each asset', async () => {
|
||||
const { useMediaAssetActions } = await import('./useMediaAssetActions')
|
||||
const actions = useMediaAssetActions()
|
||||
|
||||
const assets = [
|
||||
|
||||
@@ -1178,7 +1178,7 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
type: 'boolean',
|
||||
tooltip:
|
||||
'Replaces the floating job queue panel with an equivalent job queue embedded in the Assets side panel. You can disable this to return to the floating panel layout.',
|
||||
defaultValue: true,
|
||||
defaultValue: false,
|
||||
experimental: true
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useFeatureUsageTracker } from './useFeatureUsageTracker'
|
||||
|
||||
const STORAGE_KEY = 'Comfy.FeatureUsage'
|
||||
|
||||
describe('useFeatureUsageTracker', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
vi.clearAllMocks()
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('initializes with zero count for new feature', () => {
|
||||
const { useCount } = useFeatureUsageTracker('test-feature-1')
|
||||
it('initializes with zero count for new feature', async () => {
|
||||
const { useFeatureUsageTracker } = await import('./useFeatureUsageTracker')
|
||||
const { useCount } = useFeatureUsageTracker('test-feature')
|
||||
|
||||
expect(useCount.value).toBe(0)
|
||||
})
|
||||
|
||||
it('increments count on trackUsage', () => {
|
||||
const { useCount, trackUsage } = useFeatureUsageTracker('test-feature-2')
|
||||
it('increments count on trackUsage', async () => {
|
||||
const { useFeatureUsageTracker } = await import('./useFeatureUsageTracker')
|
||||
const { useCount, trackUsage } = useFeatureUsageTracker('test-feature')
|
||||
|
||||
expect(useCount.value).toBe(0)
|
||||
|
||||
@@ -32,12 +32,14 @@ describe('useFeatureUsageTracker', () => {
|
||||
expect(useCount.value).toBe(2)
|
||||
})
|
||||
|
||||
it('sets firstUsed only on first use', () => {
|
||||
it('sets firstUsed only on first use', async () => {
|
||||
vi.useFakeTimers()
|
||||
const firstTs = 1000000
|
||||
vi.setSystemTime(firstTs)
|
||||
try {
|
||||
const { usage, trackUsage } = useFeatureUsageTracker('test-feature-3')
|
||||
const { useFeatureUsageTracker } =
|
||||
await import('./useFeatureUsageTracker')
|
||||
const { usage, trackUsage } = useFeatureUsageTracker('test-feature')
|
||||
|
||||
trackUsage()
|
||||
expect(usage.value?.firstUsed).toBe(firstTs)
|
||||
@@ -50,10 +52,12 @@ describe('useFeatureUsageTracker', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('updates lastUsed on each use', () => {
|
||||
it('updates lastUsed on each use', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { usage, trackUsage } = useFeatureUsageTracker('test-feature-4')
|
||||
const { useFeatureUsageTracker } =
|
||||
await import('./useFeatureUsageTracker')
|
||||
const { usage, trackUsage } = useFeatureUsageTracker('test-feature')
|
||||
|
||||
trackUsage()
|
||||
const firstLastUsed = usage.value?.lastUsed ?? 0
|
||||
@@ -67,9 +71,10 @@ describe('useFeatureUsageTracker', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('reset clears feature data', () => {
|
||||
it('reset clears feature data', async () => {
|
||||
const { useFeatureUsageTracker } = await import('./useFeatureUsageTracker')
|
||||
const { useCount, trackUsage, reset } =
|
||||
useFeatureUsageTracker('test-feature-5')
|
||||
useFeatureUsageTracker('test-feature')
|
||||
|
||||
trackUsage()
|
||||
trackUsage()
|
||||
@@ -79,7 +84,8 @@ describe('useFeatureUsageTracker', () => {
|
||||
expect(useCount.value).toBe(0)
|
||||
})
|
||||
|
||||
it('tracks multiple features independently', () => {
|
||||
it('tracks multiple features independently', async () => {
|
||||
const { useFeatureUsageTracker } = await import('./useFeatureUsageTracker')
|
||||
const featureA = useFeatureUsageTracker('feature-a')
|
||||
const featureB = useFeatureUsageTracker('feature-b')
|
||||
|
||||
@@ -94,6 +100,8 @@ describe('useFeatureUsageTracker', () => {
|
||||
it('persists to localStorage', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { useFeatureUsageTracker } =
|
||||
await import('./useFeatureUsageTracker')
|
||||
const { trackUsage } = useFeatureUsageTracker('persisted-feature')
|
||||
|
||||
trackUsage()
|
||||
@@ -106,7 +114,7 @@ describe('useFeatureUsageTracker', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('loads existing data from localStorage', () => {
|
||||
it('loads existing data from localStorage', async () => {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
@@ -114,6 +122,8 @@ describe('useFeatureUsageTracker', () => {
|
||||
})
|
||||
)
|
||||
|
||||
vi.resetModules()
|
||||
const { useFeatureUsageTracker } = await import('./useFeatureUsageTracker')
|
||||
const { useCount } = useFeatureUsageTracker('existing-feature')
|
||||
|
||||
expect(useCount.value).toBe(5)
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
startTopupTracking,
|
||||
checkForCompletedTopup,
|
||||
clearTopupTracking
|
||||
} from '@/platform/telemetry/topupTracker'
|
||||
import type * as TopupTrackerModule from '@/platform/telemetry/topupTracker'
|
||||
import type { AuditLog } from '@/services/customerEventsService'
|
||||
|
||||
// Mock localStorage
|
||||
@@ -29,15 +25,19 @@ vi.mock('@/platform/telemetry', () => ({
|
||||
}))
|
||||
|
||||
describe('topupTracker', () => {
|
||||
beforeEach(() => {
|
||||
let topupTracker: typeof TopupTrackerModule
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
// Dynamically import to ensure fresh module state
|
||||
topupTracker = await import('@/platform/telemetry/topupTracker')
|
||||
})
|
||||
|
||||
describe('startTopupTracking', () => {
|
||||
it('should save current timestamp to localStorage', () => {
|
||||
const beforeTimestamp = Date.now()
|
||||
|
||||
startTopupTracking()
|
||||
topupTracker.startTopupTracking()
|
||||
|
||||
expect(mockLocalStorage.setItem).toHaveBeenCalledWith(
|
||||
'pending_topup_timestamp',
|
||||
@@ -57,7 +57,7 @@ describe('topupTracker', () => {
|
||||
it('should return false if no pending topup exists', () => {
|
||||
mockLocalStorage.getItem.mockReturnValue(null)
|
||||
|
||||
const result = checkForCompletedTopup([])
|
||||
const result = topupTracker.checkForCompletedTopup([])
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(mockTelemetry.trackApiCreditTopupSucceeded).not.toHaveBeenCalled()
|
||||
@@ -66,7 +66,7 @@ describe('topupTracker', () => {
|
||||
it('should return false if events array is empty', () => {
|
||||
mockLocalStorage.getItem.mockReturnValue(Date.now().toString())
|
||||
|
||||
const result = checkForCompletedTopup([])
|
||||
const result = topupTracker.checkForCompletedTopup([])
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(mockTelemetry.trackApiCreditTopupSucceeded).not.toHaveBeenCalled()
|
||||
@@ -75,7 +75,7 @@ describe('topupTracker', () => {
|
||||
it('should return false if events array is null', () => {
|
||||
mockLocalStorage.getItem.mockReturnValue(Date.now().toString())
|
||||
|
||||
const result = checkForCompletedTopup(null)
|
||||
const result = topupTracker.checkForCompletedTopup(null)
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(mockTelemetry.trackApiCreditTopupSucceeded).not.toHaveBeenCalled()
|
||||
@@ -94,7 +94,7 @@ describe('topupTracker', () => {
|
||||
}
|
||||
]
|
||||
|
||||
const result = checkForCompletedTopup(events)
|
||||
const result = topupTracker.checkForCompletedTopup(events)
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(
|
||||
@@ -122,7 +122,7 @@ describe('topupTracker', () => {
|
||||
}
|
||||
]
|
||||
|
||||
const result = checkForCompletedTopup(events)
|
||||
const result = topupTracker.checkForCompletedTopup(events)
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(mockTelemetry.trackApiCreditTopupSucceeded).toHaveBeenCalledOnce()
|
||||
@@ -144,7 +144,7 @@ describe('topupTracker', () => {
|
||||
}
|
||||
]
|
||||
|
||||
const result = checkForCompletedTopup(events)
|
||||
const result = topupTracker.checkForCompletedTopup(events)
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(mockTelemetry.trackApiCreditTopupSucceeded).not.toHaveBeenCalled()
|
||||
@@ -164,7 +164,7 @@ describe('topupTracker', () => {
|
||||
}
|
||||
]
|
||||
|
||||
const result = checkForCompletedTopup(events)
|
||||
const result = topupTracker.checkForCompletedTopup(events)
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(mockTelemetry.trackApiCreditTopupSucceeded).not.toHaveBeenCalled()
|
||||
@@ -189,7 +189,7 @@ describe('topupTracker', () => {
|
||||
}
|
||||
]
|
||||
|
||||
const result = checkForCompletedTopup(events)
|
||||
const result = topupTracker.checkForCompletedTopup(events)
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(mockTelemetry.trackApiCreditTopupSucceeded).not.toHaveBeenCalled()
|
||||
@@ -198,7 +198,7 @@ describe('topupTracker', () => {
|
||||
|
||||
describe('clearTopupTracking', () => {
|
||||
it('should remove pending topup from localStorage', () => {
|
||||
clearTopupTracking()
|
||||
topupTracker.clearTopupTracking()
|
||||
|
||||
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(
|
||||
'pending_topup_timestamp'
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: false
|
||||
}))
|
||||
@@ -11,14 +9,17 @@ describe('useTelemetry', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should return null when not in cloud distribution', () => {
|
||||
it('should return null when not in cloud distribution', async () => {
|
||||
const { useTelemetry } = await import('@/platform/telemetry')
|
||||
const provider = useTelemetry()
|
||||
|
||||
// Should return null for OSS builds
|
||||
expect(provider).toBeNull()
|
||||
})
|
||||
}, 10000)
|
||||
|
||||
it('should return null consistently for OSS builds', async () => {
|
||||
const { useTelemetry } = await import('@/platform/telemetry')
|
||||
|
||||
it('should return null consistently for OSS builds', () => {
|
||||
const provider1 = useTelemetry()
|
||||
const provider2 = useTelemetry()
|
||||
|
||||
|
||||
@@ -1,96 +1,19 @@
|
||||
import { until } from '@vueuse/core'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { compare } from 'semver'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { compare, valid } from 'semver'
|
||||
import type { Mock } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { ReleaseNote } from '@/platform/updates/common/releaseService'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
|
||||
import { useReleaseService } from '@/platform/updates/common/releaseService'
|
||||
import { useSystemStatsStore } from '@/stores/systemStatsStore'
|
||||
import { isElectron } from '@/utils/envUtil'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import type { SystemStats } from '@/types'
|
||||
|
||||
// Mock the dependencies
|
||||
vi.mock('semver', () => ({
|
||||
compare: vi.fn(),
|
||||
valid: vi.fn(() => '1.0.0')
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/envUtil', () => ({
|
||||
isElectron: vi.fn(() => true)
|
||||
}))
|
||||
|
||||
vi.mock('semver')
|
||||
vi.mock('@/utils/envUtil')
|
||||
vi.mock('@/platform/distribution/types', () => ({ isCloud: false }))
|
||||
|
||||
vi.mock('@/platform/updates/common/releaseService', () => {
|
||||
const getReleases = vi.fn()
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
return {
|
||||
useReleaseService: () => ({
|
||||
getReleases,
|
||||
isLoading,
|
||||
error
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => {
|
||||
const get = vi.fn((key: string) => {
|
||||
if (key === 'Comfy.Notification.ShowVersionUpdates') return true
|
||||
return null
|
||||
})
|
||||
const set = vi.fn()
|
||||
return {
|
||||
useSettingStore: () => ({ get, set })
|
||||
}
|
||||
})
|
||||
|
||||
const mockSystemStatsState = vi.hoisted(() => ({
|
||||
systemStats: {
|
||||
system: {
|
||||
comfyui_version: '1.0.0',
|
||||
argv: []
|
||||
}
|
||||
} satisfies {
|
||||
system: Partial<SystemStats['system']>
|
||||
},
|
||||
isInitialized: true,
|
||||
reset() {
|
||||
this.systemStats = {
|
||||
system: {
|
||||
comfyui_version: '1.0.0',
|
||||
argv: []
|
||||
} satisfies Partial<SystemStats['system']>
|
||||
}
|
||||
this.isInitialized = true
|
||||
}
|
||||
}))
|
||||
vi.mock('@/stores/systemStatsStore', () => {
|
||||
const refetchSystemStats = vi.fn()
|
||||
const getFormFactor = vi.fn(() => 'git-windows')
|
||||
return {
|
||||
useSystemStatsStore: () => ({
|
||||
get systemStats() {
|
||||
return mockSystemStatsState.systemStats
|
||||
},
|
||||
set systemStats(val) {
|
||||
mockSystemStatsState.systemStats = val
|
||||
},
|
||||
get isInitialized() {
|
||||
return mockSystemStatsState.isInitialized
|
||||
},
|
||||
set isInitialized(val) {
|
||||
mockSystemStatsState.isInitialized = val
|
||||
},
|
||||
refetchSystemStats,
|
||||
getFormFactor
|
||||
})
|
||||
}
|
||||
})
|
||||
vi.mock('@/platform/updates/common/releaseService')
|
||||
vi.mock('@/platform/settings/settingStore')
|
||||
vi.mock('@/stores/systemStatsStore')
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
until: vi.fn(() => Promise.resolve()),
|
||||
useStorage: vi.fn(() => ({ value: {} })),
|
||||
@@ -98,6 +21,27 @@ vi.mock('@vueuse/core', () => ({
|
||||
}))
|
||||
|
||||
describe('useReleaseStore', () => {
|
||||
let store: ReturnType<typeof useReleaseStore>
|
||||
let mockReleaseService: {
|
||||
getReleases: Mock
|
||||
isLoading: ReturnType<typeof ref<boolean>>
|
||||
error: ReturnType<typeof ref<string | null>>
|
||||
}
|
||||
let mockSettingStore: { get: Mock; set: Mock }
|
||||
let mockSystemStatsStore: {
|
||||
systemStats: {
|
||||
system: {
|
||||
comfyui_version: string
|
||||
argv?: string[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
devices?: unknown[]
|
||||
} | null
|
||||
isInitialized: boolean
|
||||
refetchSystemStats: Mock
|
||||
getFormFactor: Mock
|
||||
}
|
||||
|
||||
const mockRelease = {
|
||||
id: 1,
|
||||
project: 'comfyui' as const,
|
||||
@@ -107,16 +51,71 @@ describe('useReleaseStore', () => {
|
||||
attention: 'high' as const
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
beforeEach(async () => {
|
||||
setActivePinia(createPinia())
|
||||
|
||||
vi.resetAllMocks()
|
||||
mockSystemStatsState.reset()
|
||||
// Reset all mocks
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Setup mock services with proper refs
|
||||
mockReleaseService = {
|
||||
getReleases: vi.fn(),
|
||||
isLoading: ref(false),
|
||||
error: ref(null)
|
||||
}
|
||||
|
||||
mockSettingStore = {
|
||||
get: vi.fn(),
|
||||
set: vi.fn()
|
||||
}
|
||||
|
||||
mockSystemStatsStore = {
|
||||
systemStats: {
|
||||
system: {
|
||||
comfyui_version: '1.0.0'
|
||||
}
|
||||
},
|
||||
isInitialized: true,
|
||||
refetchSystemStats: vi.fn(),
|
||||
getFormFactor: vi.fn(() => 'git-windows')
|
||||
}
|
||||
|
||||
// Setup mock implementations
|
||||
const { useReleaseService } =
|
||||
await import('@/platform/updates/common/releaseService')
|
||||
const { useSettingStore } = await import('@/platform/settings/settingStore')
|
||||
const { useSystemStatsStore } = await import('@/stores/systemStatsStore')
|
||||
const { isElectron } = await import('@/utils/envUtil')
|
||||
|
||||
vi.mocked(useReleaseService).mockReturnValue(
|
||||
mockReleaseService as Partial<
|
||||
ReturnType<typeof useReleaseService>
|
||||
> as ReturnType<typeof useReleaseService>
|
||||
)
|
||||
vi.mocked(useSettingStore).mockReturnValue(
|
||||
mockSettingStore as Partial<
|
||||
ReturnType<typeof useSettingStore>
|
||||
> as ReturnType<typeof useSettingStore>
|
||||
)
|
||||
vi.mocked(useSystemStatsStore).mockReturnValue(
|
||||
mockSystemStatsStore as Partial<
|
||||
ReturnType<typeof useSystemStatsStore>
|
||||
> as ReturnType<typeof useSystemStatsStore>
|
||||
)
|
||||
vi.mocked(isElectron).mockReturnValue(true)
|
||||
vi.mocked(valid).mockReturnValue('1.0.0')
|
||||
|
||||
// Default showVersionUpdates to true
|
||||
mockSettingStore.get.mockImplementation((key: string) => {
|
||||
if (key === 'Comfy.Notification.ShowVersionUpdates') return true
|
||||
return null
|
||||
})
|
||||
|
||||
store = useReleaseStore()
|
||||
})
|
||||
|
||||
describe('initial state', () => {
|
||||
it('should initialize with default state', () => {
|
||||
const store = useReleaseStore()
|
||||
expect(store.releases).toEqual([])
|
||||
expect(store.isLoading).toBe(false)
|
||||
expect(store.error).toBeNull()
|
||||
@@ -125,7 +124,6 @@ describe('useReleaseStore', () => {
|
||||
|
||||
describe('computed properties', () => {
|
||||
it('should return most recent release', () => {
|
||||
const store = useReleaseStore()
|
||||
const olderRelease = {
|
||||
...mockRelease,
|
||||
id: 2,
|
||||
@@ -138,7 +136,6 @@ describe('useReleaseStore', () => {
|
||||
})
|
||||
|
||||
it('should return 3 most recent releases', () => {
|
||||
const store = useReleaseStore()
|
||||
const releases = [
|
||||
mockRelease,
|
||||
{ ...mockRelease, id: 2, version: '1.1.0' },
|
||||
@@ -151,7 +148,6 @@ describe('useReleaseStore', () => {
|
||||
})
|
||||
|
||||
it('should show update button (shouldShowUpdateButton)', () => {
|
||||
const store = useReleaseStore()
|
||||
vi.mocked(compare).mockReturnValue(1) // newer version available
|
||||
|
||||
store.releases = [mockRelease]
|
||||
@@ -159,7 +155,6 @@ describe('useReleaseStore', () => {
|
||||
})
|
||||
|
||||
it('should not show update button when no new version', () => {
|
||||
const store = useReleaseStore()
|
||||
vi.mocked(compare).mockReturnValue(-1) // current version is newer
|
||||
|
||||
store.releases = [mockRelease]
|
||||
@@ -168,17 +163,19 @@ describe('useReleaseStore', () => {
|
||||
})
|
||||
|
||||
describe('showVersionUpdates setting', () => {
|
||||
beforeEach(async () => {
|
||||
store.releases = [mockRelease]
|
||||
})
|
||||
|
||||
describe('when notifications are enabled', () => {
|
||||
beforeEach(() => {
|
||||
const settingStore = useSettingStore()
|
||||
vi.mocked(settingStore.get).mockImplementation((key: string) => {
|
||||
beforeEach(async () => {
|
||||
mockSettingStore.get.mockImplementation((key: string) => {
|
||||
if (key === 'Comfy.Notification.ShowVersionUpdates') return true
|
||||
return null
|
||||
})
|
||||
})
|
||||
|
||||
it('should show toast for medium/high attention releases', () => {
|
||||
const store = useReleaseStore()
|
||||
vi.mocked(compare).mockReturnValue(1)
|
||||
store.releases = [mockRelease]
|
||||
|
||||
@@ -186,7 +183,6 @@ describe('useReleaseStore', () => {
|
||||
})
|
||||
|
||||
it('should not show toast for low attention releases', () => {
|
||||
const store = useReleaseStore()
|
||||
vi.mocked(compare).mockReturnValue(1)
|
||||
|
||||
const lowAttentionRelease = {
|
||||
@@ -200,18 +196,13 @@ describe('useReleaseStore', () => {
|
||||
})
|
||||
|
||||
it('should show red dot for new versions', () => {
|
||||
const store = useReleaseStore()
|
||||
store.releases = [mockRelease]
|
||||
vi.mocked(compare).mockReturnValue(1)
|
||||
|
||||
expect(store.shouldShowRedDot).toBe(true)
|
||||
})
|
||||
|
||||
it('should show popup for latest version', () => {
|
||||
const store = useReleaseStore()
|
||||
store.releases = [mockRelease]
|
||||
const systemStatsStore = useSystemStatsStore()
|
||||
systemStatsStore.systemStats!.system.comfyui_version = '1.2.0'
|
||||
mockSystemStatsStore.systemStats!.system.comfyui_version = '1.2.0'
|
||||
|
||||
vi.mocked(compare).mockReturnValue(0)
|
||||
|
||||
@@ -219,13 +210,11 @@ describe('useReleaseStore', () => {
|
||||
})
|
||||
|
||||
it('should fetch releases during initialization', async () => {
|
||||
const store = useReleaseStore()
|
||||
const releaseService = useReleaseService()
|
||||
vi.mocked(releaseService.getReleases).mockResolvedValue([mockRelease])
|
||||
mockReleaseService.getReleases.mockResolvedValue([mockRelease])
|
||||
|
||||
await store.initialize()
|
||||
|
||||
expect(releaseService.getReleases).toHaveBeenCalledWith({
|
||||
expect(mockReleaseService.getReleases).toHaveBeenCalledWith({
|
||||
project: 'comfyui',
|
||||
current_version: '1.0.0',
|
||||
form_factor: 'git-windows',
|
||||
@@ -235,35 +224,27 @@ describe('useReleaseStore', () => {
|
||||
})
|
||||
|
||||
describe('when notifications are disabled', () => {
|
||||
beforeEach(() => {
|
||||
const settingStore = useSettingStore()
|
||||
vi.mocked(settingStore.get).mockImplementation((key: string) => {
|
||||
beforeEach(async () => {
|
||||
mockSettingStore.get.mockImplementation((key: string) => {
|
||||
if (key === 'Comfy.Notification.ShowVersionUpdates') return false
|
||||
return null
|
||||
})
|
||||
})
|
||||
|
||||
it('should not show toast even with new version available', () => {
|
||||
const store = useReleaseStore()
|
||||
store.releases = [mockRelease]
|
||||
vi.mocked(compare).mockReturnValue(1)
|
||||
|
||||
expect(store.shouldShowToast).toBe(false)
|
||||
})
|
||||
|
||||
it('should not show red dot even with new version available', () => {
|
||||
const store = useReleaseStore()
|
||||
store.releases = [mockRelease]
|
||||
vi.mocked(compare).mockReturnValue(1)
|
||||
|
||||
expect(store.shouldShowRedDot).toBe(false)
|
||||
})
|
||||
|
||||
it('should not show popup even for latest version', () => {
|
||||
const store = useReleaseStore()
|
||||
store.releases = [mockRelease]
|
||||
const systemStatsStore = useSystemStatsStore()
|
||||
systemStatsStore.systemStats!.system.comfyui_version = '1.2.0'
|
||||
mockSystemStatsStore.systemStats!.system.comfyui_version = '1.2.0'
|
||||
|
||||
vi.mocked(compare).mockReturnValue(0)
|
||||
|
||||
@@ -271,19 +252,15 @@ describe('useReleaseStore', () => {
|
||||
})
|
||||
|
||||
it('should skip fetching releases during initialization', async () => {
|
||||
const store = useReleaseStore()
|
||||
const releaseService = useReleaseService()
|
||||
await store.initialize()
|
||||
|
||||
expect(releaseService.getReleases).not.toHaveBeenCalled()
|
||||
expect(mockReleaseService.getReleases).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not fetch releases when calling fetchReleases directly', async () => {
|
||||
const store = useReleaseStore()
|
||||
const releaseService = useReleaseService()
|
||||
await store.fetchReleases()
|
||||
|
||||
expect(releaseService.getReleases).not.toHaveBeenCalled()
|
||||
expect(mockReleaseService.getReleases).not.toHaveBeenCalled()
|
||||
expect(store.isLoading).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -291,13 +268,11 @@ describe('useReleaseStore', () => {
|
||||
|
||||
describe('release initialization', () => {
|
||||
it('should fetch releases successfully', async () => {
|
||||
const store = useReleaseStore()
|
||||
const releaseService = useReleaseService()
|
||||
vi.mocked(releaseService.getReleases).mockResolvedValue([mockRelease])
|
||||
mockReleaseService.getReleases.mockResolvedValue([mockRelease])
|
||||
|
||||
await store.initialize()
|
||||
|
||||
expect(releaseService.getReleases).toHaveBeenCalledWith({
|
||||
expect(mockReleaseService.getReleases).toHaveBeenCalledWith({
|
||||
project: 'comfyui',
|
||||
current_version: '1.0.0',
|
||||
form_factor: 'git-windows',
|
||||
@@ -307,15 +282,12 @@ describe('useReleaseStore', () => {
|
||||
})
|
||||
|
||||
it('should include form_factor in API call', async () => {
|
||||
const store = useReleaseStore()
|
||||
const releaseService = useReleaseService()
|
||||
const systemStatsStore = useSystemStatsStore()
|
||||
vi.mocked(systemStatsStore.getFormFactor).mockReturnValue('desktop-mac')
|
||||
vi.mocked(releaseService.getReleases).mockResolvedValue([mockRelease])
|
||||
mockSystemStatsStore.getFormFactor.mockReturnValue('desktop-mac')
|
||||
mockReleaseService.getReleases.mockResolvedValue([mockRelease])
|
||||
|
||||
await store.initialize()
|
||||
|
||||
expect(releaseService.getReleases).toHaveBeenCalledWith({
|
||||
expect(mockReleaseService.getReleases).toHaveBeenCalledWith({
|
||||
project: 'comfyui',
|
||||
current_version: '1.0.0',
|
||||
form_factor: 'desktop-mac',
|
||||
@@ -324,22 +296,16 @@ describe('useReleaseStore', () => {
|
||||
})
|
||||
|
||||
it('should skip fetching when --disable-api-nodes is present', async () => {
|
||||
const store = useReleaseStore()
|
||||
const releaseService = useReleaseService()
|
||||
const systemStatsStore = useSystemStatsStore()
|
||||
systemStatsStore.systemStats!.system.argv = ['--disable-api-nodes']
|
||||
mockSystemStatsStore.systemStats!.system.argv = ['--disable-api-nodes']
|
||||
|
||||
await store.initialize()
|
||||
|
||||
expect(releaseService.getReleases).not.toHaveBeenCalled()
|
||||
expect(mockReleaseService.getReleases).not.toHaveBeenCalled()
|
||||
expect(store.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
it('should skip fetching when --disable-api-nodes is one of multiple args', async () => {
|
||||
const store = useReleaseStore()
|
||||
const releaseService = useReleaseService()
|
||||
const systemStatsStore = useSystemStatsStore()
|
||||
systemStatsStore.systemStats!.system.argv = [
|
||||
mockSystemStatsStore.systemStats!.system.argv = [
|
||||
'--port',
|
||||
'8080',
|
||||
'--disable-api-nodes',
|
||||
@@ -348,46 +314,37 @@ describe('useReleaseStore', () => {
|
||||
|
||||
await store.initialize()
|
||||
|
||||
expect(releaseService.getReleases).not.toHaveBeenCalled()
|
||||
expect(mockReleaseService.getReleases).not.toHaveBeenCalled()
|
||||
expect(store.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
it('should fetch normally when --disable-api-nodes is not present', async () => {
|
||||
const store = useReleaseStore()
|
||||
const releaseService = useReleaseService()
|
||||
const systemStatsStore = useSystemStatsStore()
|
||||
systemStatsStore.systemStats!.system.argv = [
|
||||
mockSystemStatsStore.systemStats!.system.argv = [
|
||||
'--port',
|
||||
'8080',
|
||||
'--verbose'
|
||||
]
|
||||
vi.mocked(releaseService.getReleases).mockResolvedValue([mockRelease])
|
||||
mockReleaseService.getReleases.mockResolvedValue([mockRelease])
|
||||
|
||||
await store.initialize()
|
||||
|
||||
expect(releaseService.getReleases).toHaveBeenCalled()
|
||||
expect(mockReleaseService.getReleases).toHaveBeenCalled()
|
||||
expect(store.releases).toEqual([mockRelease])
|
||||
})
|
||||
|
||||
it('should fetch normally when argv is undefined', async () => {
|
||||
const store = useReleaseStore()
|
||||
const releaseService = useReleaseService()
|
||||
const systemStatsStore = useSystemStatsStore()
|
||||
// TODO: Consider deleting this test since the types have to be violated for it to be relevant
|
||||
delete (systemStatsStore.systemStats!.system as { argv?: string[] }).argv
|
||||
vi.mocked(releaseService.getReleases).mockResolvedValue([mockRelease])
|
||||
mockSystemStatsStore.systemStats!.system.argv = undefined
|
||||
mockReleaseService.getReleases.mockResolvedValue([mockRelease])
|
||||
|
||||
await store.initialize()
|
||||
|
||||
expect(releaseService.getReleases).toHaveBeenCalled()
|
||||
expect(mockReleaseService.getReleases).toHaveBeenCalled()
|
||||
expect(store.releases).toEqual([mockRelease])
|
||||
})
|
||||
|
||||
it('should handle API errors gracefully', async () => {
|
||||
const store = useReleaseStore()
|
||||
const releaseService = useReleaseService()
|
||||
vi.mocked(releaseService.getReleases).mockResolvedValue(null)
|
||||
releaseService.error.value = 'API Error'
|
||||
mockReleaseService.getReleases.mockResolvedValue(null)
|
||||
mockReleaseService.error.value = 'API Error'
|
||||
|
||||
await store.initialize()
|
||||
|
||||
@@ -396,9 +353,7 @@ describe('useReleaseStore', () => {
|
||||
})
|
||||
|
||||
it('should handle non-Error objects', async () => {
|
||||
const store = useReleaseStore()
|
||||
const releaseService = useReleaseService()
|
||||
vi.mocked(releaseService.getReleases).mockRejectedValue('String error')
|
||||
mockReleaseService.getReleases.mockRejectedValue('String error')
|
||||
|
||||
await store.initialize()
|
||||
|
||||
@@ -406,14 +361,12 @@ describe('useReleaseStore', () => {
|
||||
})
|
||||
|
||||
it('should set loading state correctly', async () => {
|
||||
const store = useReleaseStore()
|
||||
const releaseService = useReleaseService()
|
||||
let resolvePromise: (value: ReleaseNote[] | null) => void
|
||||
const promise = new Promise<ReleaseNote[] | null>((resolve) => {
|
||||
resolvePromise = resolve
|
||||
})
|
||||
|
||||
vi.mocked(releaseService.getReleases).mockReturnValue(promise)
|
||||
mockReleaseService.getReleases.mockReturnValue(promise)
|
||||
|
||||
const initPromise = store.initialize()
|
||||
expect(store.isLoading).toBe(true)
|
||||
@@ -425,23 +378,19 @@ describe('useReleaseStore', () => {
|
||||
})
|
||||
|
||||
it('should fetch system stats if not available', async () => {
|
||||
const store = useReleaseStore()
|
||||
const releaseService = useReleaseService()
|
||||
const systemStatsStore = useSystemStatsStore()
|
||||
systemStatsStore.systemStats = null
|
||||
systemStatsStore.isInitialized = false
|
||||
vi.mocked(releaseService.getReleases).mockResolvedValue([mockRelease])
|
||||
const { until } = await import('@vueuse/core')
|
||||
mockSystemStatsStore.systemStats = null
|
||||
mockSystemStatsStore.isInitialized = false
|
||||
mockReleaseService.getReleases.mockResolvedValue([mockRelease])
|
||||
|
||||
await store.initialize()
|
||||
|
||||
expect(vi.mocked(until)).toHaveBeenCalled()
|
||||
expect(releaseService.getReleases).toHaveBeenCalled()
|
||||
expect(until).toHaveBeenCalled()
|
||||
expect(mockReleaseService.getReleases).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not set loading state when notifications disabled', async () => {
|
||||
const store = useReleaseStore()
|
||||
const settingStore = useSettingStore()
|
||||
vi.mocked(settingStore.get).mockImplementation((key: string) => {
|
||||
mockSettingStore.get.mockImplementation((key: string) => {
|
||||
if (key === 'Comfy.Notification.ShowVersionUpdates') return false
|
||||
return null
|
||||
})
|
||||
@@ -454,22 +403,16 @@ describe('useReleaseStore', () => {
|
||||
|
||||
describe('--disable-api-nodes argument handling', () => {
|
||||
it('should skip fetchReleases when --disable-api-nodes is present', async () => {
|
||||
const store = useReleaseStore()
|
||||
const releaseService = useReleaseService()
|
||||
const systemStatsStore = useSystemStatsStore()
|
||||
systemStatsStore.systemStats!.system.argv = ['--disable-api-nodes']
|
||||
mockSystemStatsStore.systemStats!.system.argv = ['--disable-api-nodes']
|
||||
|
||||
await store.fetchReleases()
|
||||
|
||||
expect(releaseService.getReleases).not.toHaveBeenCalled()
|
||||
expect(mockReleaseService.getReleases).not.toHaveBeenCalled()
|
||||
expect(store.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
it('should skip fetchReleases when --disable-api-nodes is among other args', async () => {
|
||||
const store = useReleaseStore()
|
||||
const releaseService = useReleaseService()
|
||||
const systemStatsStore = useSystemStatsStore()
|
||||
systemStatsStore.systemStats!.system.argv = [
|
||||
mockSystemStatsStore.systemStats!.system.argv = [
|
||||
'--port',
|
||||
'8080',
|
||||
'--disable-api-nodes',
|
||||
@@ -478,109 +421,96 @@ describe('useReleaseStore', () => {
|
||||
|
||||
await store.fetchReleases()
|
||||
|
||||
expect(releaseService.getReleases).not.toHaveBeenCalled()
|
||||
expect(mockReleaseService.getReleases).not.toHaveBeenCalled()
|
||||
expect(store.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
it('should proceed with fetchReleases when --disable-api-nodes is not present', async () => {
|
||||
const store = useReleaseStore()
|
||||
const releaseService = useReleaseService()
|
||||
const systemStatsStore = useSystemStatsStore()
|
||||
systemStatsStore.systemStats!.system.argv = [
|
||||
mockSystemStatsStore.systemStats!.system.argv = [
|
||||
'--port',
|
||||
'8080',
|
||||
'--verbose'
|
||||
]
|
||||
vi.mocked(releaseService.getReleases).mockResolvedValue([mockRelease])
|
||||
mockReleaseService.getReleases.mockResolvedValue([mockRelease])
|
||||
|
||||
await store.fetchReleases()
|
||||
|
||||
expect(releaseService.getReleases).toHaveBeenCalled()
|
||||
expect(mockReleaseService.getReleases).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should proceed with fetchReleases when argv is undefined', async () => {
|
||||
const store = useReleaseStore()
|
||||
const releaseService = useReleaseService()
|
||||
const systemStatsStore = useSystemStatsStore()
|
||||
delete (systemStatsStore.systemStats!.system as { argv?: string[] }).argv
|
||||
vi.mocked(releaseService.getReleases).mockResolvedValue([mockRelease])
|
||||
mockSystemStatsStore.systemStats!.system.argv = undefined
|
||||
mockReleaseService.getReleases.mockResolvedValue([mockRelease])
|
||||
|
||||
await store.fetchReleases()
|
||||
|
||||
expect(releaseService.getReleases).toHaveBeenCalled()
|
||||
expect(mockReleaseService.getReleases).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should proceed with fetchReleases when system stats are not available', async () => {
|
||||
const store = useReleaseStore()
|
||||
const releaseService = useReleaseService()
|
||||
const systemStatsStore = useSystemStatsStore()
|
||||
systemStatsStore.systemStats = null
|
||||
systemStatsStore.isInitialized = false
|
||||
vi.mocked(releaseService.getReleases).mockResolvedValue([mockRelease])
|
||||
const { until } = await import('@vueuse/core')
|
||||
mockSystemStatsStore.systemStats = null
|
||||
mockSystemStatsStore.isInitialized = false
|
||||
mockReleaseService.getReleases.mockResolvedValue([mockRelease])
|
||||
|
||||
await store.fetchReleases()
|
||||
|
||||
expect(until).toHaveBeenCalled()
|
||||
expect(releaseService.getReleases).toHaveBeenCalled()
|
||||
expect(mockReleaseService.getReleases).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('action handlers', () => {
|
||||
it('should handle skip release', async () => {
|
||||
const store = useReleaseStore()
|
||||
beforeEach(async () => {
|
||||
store.releases = [mockRelease]
|
||||
const settingStore = useSettingStore()
|
||||
})
|
||||
|
||||
it('should handle skip release', async () => {
|
||||
await store.handleSkipRelease('1.2.0')
|
||||
|
||||
expect(settingStore.set).toHaveBeenCalledWith(
|
||||
expect(mockSettingStore.set).toHaveBeenCalledWith(
|
||||
'Comfy.Release.Version',
|
||||
'1.2.0'
|
||||
)
|
||||
expect(settingStore.set).toHaveBeenCalledWith(
|
||||
expect(mockSettingStore.set).toHaveBeenCalledWith(
|
||||
'Comfy.Release.Status',
|
||||
'skipped'
|
||||
)
|
||||
expect(settingStore.set).toHaveBeenCalledWith(
|
||||
expect(mockSettingStore.set).toHaveBeenCalledWith(
|
||||
'Comfy.Release.Timestamp',
|
||||
expect.any(Number)
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle show changelog', async () => {
|
||||
const store = useReleaseStore()
|
||||
store.releases = [mockRelease]
|
||||
const settingStore = useSettingStore()
|
||||
await store.handleShowChangelog('1.2.0')
|
||||
|
||||
expect(settingStore.set).toHaveBeenCalledWith(
|
||||
expect(mockSettingStore.set).toHaveBeenCalledWith(
|
||||
'Comfy.Release.Version',
|
||||
'1.2.0'
|
||||
)
|
||||
expect(settingStore.set).toHaveBeenCalledWith(
|
||||
expect(mockSettingStore.set).toHaveBeenCalledWith(
|
||||
'Comfy.Release.Status',
|
||||
'changelog seen'
|
||||
)
|
||||
expect(settingStore.set).toHaveBeenCalledWith(
|
||||
expect(mockSettingStore.set).toHaveBeenCalledWith(
|
||||
'Comfy.Release.Timestamp',
|
||||
expect.any(Number)
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle whats new seen', async () => {
|
||||
const store = useReleaseStore()
|
||||
store.releases = [mockRelease]
|
||||
const settingStore = useSettingStore()
|
||||
await store.handleWhatsNewSeen('1.2.0')
|
||||
|
||||
expect(settingStore.set).toHaveBeenCalledWith(
|
||||
expect(mockSettingStore.set).toHaveBeenCalledWith(
|
||||
'Comfy.Release.Version',
|
||||
'1.2.0'
|
||||
)
|
||||
expect(settingStore.set).toHaveBeenCalledWith(
|
||||
expect(mockSettingStore.set).toHaveBeenCalledWith(
|
||||
'Comfy.Release.Status',
|
||||
"what's new seen"
|
||||
)
|
||||
expect(settingStore.set).toHaveBeenCalledWith(
|
||||
expect(mockSettingStore.set).toHaveBeenCalledWith(
|
||||
'Comfy.Release.Timestamp',
|
||||
expect.any(Number)
|
||||
)
|
||||
@@ -589,9 +519,7 @@ describe('useReleaseStore', () => {
|
||||
|
||||
describe('popup visibility', () => {
|
||||
it('should show toast for medium/high attention releases', () => {
|
||||
const store = useReleaseStore()
|
||||
const settingStore = useSettingStore()
|
||||
vi.mocked(settingStore.get).mockImplementation((key: string) => {
|
||||
mockSettingStore.get.mockImplementation((key: string) => {
|
||||
if (key === 'Comfy.Release.Version') return null
|
||||
if (key === 'Comfy.Release.Status') return null
|
||||
if (key === 'Comfy.Notification.ShowVersionUpdates') return true
|
||||
@@ -606,10 +534,8 @@ describe('useReleaseStore', () => {
|
||||
})
|
||||
|
||||
it('should show red dot for new versions', () => {
|
||||
const store = useReleaseStore()
|
||||
const settingStore = useSettingStore()
|
||||
vi.mocked(compare).mockReturnValue(1)
|
||||
vi.mocked(settingStore.get).mockImplementation((key: string) => {
|
||||
mockSettingStore.get.mockImplementation((key: string) => {
|
||||
if (key === 'Comfy.Notification.ShowVersionUpdates') return true
|
||||
return null
|
||||
})
|
||||
@@ -620,11 +546,8 @@ describe('useReleaseStore', () => {
|
||||
})
|
||||
|
||||
it('should show popup for latest version', () => {
|
||||
const store = useReleaseStore()
|
||||
const systemStatsStore = useSystemStatsStore()
|
||||
const settingStore = useSettingStore()
|
||||
systemStatsStore.systemStats!.system.comfyui_version = '1.2.0' // Same as release
|
||||
vi.mocked(settingStore.get).mockImplementation((key: string) => {
|
||||
mockSystemStatsStore.systemStats!.system.comfyui_version = '1.2.0' // Same as release
|
||||
mockSettingStore.get.mockImplementation((key: string) => {
|
||||
if (key === 'Comfy.Notification.ShowVersionUpdates') return true
|
||||
return null
|
||||
})
|
||||
@@ -639,11 +562,8 @@ describe('useReleaseStore', () => {
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle missing system stats gracefully', async () => {
|
||||
const store = useReleaseStore()
|
||||
const systemStatsStore = useSystemStatsStore()
|
||||
const settingStore = useSettingStore()
|
||||
systemStatsStore.systemStats = null
|
||||
vi.mocked(settingStore.get).mockImplementation((key: string) => {
|
||||
mockSystemStatsStore.systemStats = null
|
||||
mockSettingStore.get.mockImplementation((key: string) => {
|
||||
if (key === 'Comfy.Notification.ShowVersionUpdates') return false
|
||||
return null
|
||||
})
|
||||
@@ -651,13 +571,11 @@ describe('useReleaseStore', () => {
|
||||
await store.initialize()
|
||||
|
||||
// Should not fetch system stats when notifications disabled
|
||||
expect(systemStatsStore.refetchSystemStats).not.toHaveBeenCalled()
|
||||
expect(mockSystemStatsStore.refetchSystemStats).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle concurrent fetchReleases calls', async () => {
|
||||
const store = useReleaseStore()
|
||||
const releaseService = useReleaseService()
|
||||
vi.mocked(releaseService.getReleases).mockImplementation(
|
||||
mockReleaseService.getReleases.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) =>
|
||||
setTimeout(() => resolve([mockRelease]), 100)
|
||||
@@ -671,37 +589,41 @@ describe('useReleaseStore', () => {
|
||||
await Promise.all([promise1, promise2])
|
||||
|
||||
// Should only call API once due to loading check
|
||||
expect(releaseService.getReleases).toHaveBeenCalledTimes(1)
|
||||
expect(mockReleaseService.getReleases).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isElectron environment checks', () => {
|
||||
beforeEach(async () => {
|
||||
// Set up a new version available
|
||||
store.releases = [mockRelease]
|
||||
mockSettingStore.get.mockImplementation((key: string) => {
|
||||
if (key === 'Comfy.Notification.ShowVersionUpdates') return true
|
||||
return null
|
||||
})
|
||||
})
|
||||
|
||||
describe('when running in Electron (desktop)', () => {
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
const { isElectron } = await import('@/utils/envUtil')
|
||||
vi.mocked(isElectron).mockReturnValue(true)
|
||||
})
|
||||
|
||||
it('should show toast when conditions are met', () => {
|
||||
const store = useReleaseStore()
|
||||
store.releases = [mockRelease]
|
||||
vi.mocked(compare).mockReturnValue(1)
|
||||
store.releases = [mockRelease]
|
||||
|
||||
expect(store.shouldShowToast).toBe(true)
|
||||
})
|
||||
|
||||
it('should show red dot when new version available', () => {
|
||||
const store = useReleaseStore()
|
||||
store.releases = [mockRelease]
|
||||
vi.mocked(compare).mockReturnValue(1)
|
||||
|
||||
expect(store.shouldShowRedDot).toBe(true)
|
||||
})
|
||||
|
||||
it('should show popup for latest version', () => {
|
||||
const store = useReleaseStore()
|
||||
store.releases = [mockRelease]
|
||||
const systemStatsStore = useSystemStatsStore()
|
||||
systemStatsStore.systemStats!.system.comfyui_version = '1.2.0'
|
||||
mockSystemStatsStore.systemStats!.system.comfyui_version = '1.2.0'
|
||||
|
||||
vi.mocked(compare).mockReturnValue(0)
|
||||
|
||||
@@ -710,12 +632,12 @@ describe('useReleaseStore', () => {
|
||||
})
|
||||
|
||||
describe('when NOT running in Electron (web)', () => {
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
const { isElectron } = await import('@/utils/envUtil')
|
||||
vi.mocked(isElectron).mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('should NOT show toast even when all other conditions are met', () => {
|
||||
const store = useReleaseStore()
|
||||
vi.mocked(compare).mockReturnValue(1)
|
||||
|
||||
// Set up all conditions that would normally show toast
|
||||
@@ -725,15 +647,12 @@ describe('useReleaseStore', () => {
|
||||
})
|
||||
|
||||
it('should NOT show red dot even when new version available', () => {
|
||||
const store = useReleaseStore()
|
||||
store.releases = [mockRelease]
|
||||
vi.mocked(compare).mockReturnValue(1)
|
||||
|
||||
expect(store.shouldShowRedDot).toBe(false)
|
||||
})
|
||||
|
||||
it('should NOT show toast regardless of attention level', () => {
|
||||
const store = useReleaseStore()
|
||||
vi.mocked(compare).mockReturnValue(1)
|
||||
|
||||
// Test with high attention releases
|
||||
@@ -753,7 +672,6 @@ describe('useReleaseStore', () => {
|
||||
})
|
||||
|
||||
it('should NOT show red dot even with high attention release', () => {
|
||||
const store = useReleaseStore()
|
||||
vi.mocked(compare).mockReturnValue(1)
|
||||
|
||||
store.releases = [{ ...mockRelease, attention: 'high' as const }]
|
||||
@@ -762,10 +680,7 @@ describe('useReleaseStore', () => {
|
||||
})
|
||||
|
||||
it('should NOT show popup even for latest version', () => {
|
||||
const store = useReleaseStore()
|
||||
store.releases = [mockRelease]
|
||||
const systemStatsStore = useSystemStatsStore()
|
||||
systemStatsStore.systemStats!.system.comfyui_version = '1.2.0'
|
||||
mockSystemStatsStore.systemStats!.system.comfyui_version = '1.2.0'
|
||||
|
||||
vi.mocked(compare).mockReturnValue(0)
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { until } from '@vueuse/core'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
@@ -358,15 +357,17 @@ describe('useVersionCompatibilityStore', () => {
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should fetch system stats if not available', async () => {
|
||||
const { until } = await import('@vueuse/core')
|
||||
mockSystemStatsStore.systemStats = null
|
||||
mockSystemStatsStore.isInitialized = false
|
||||
|
||||
await store.initialize()
|
||||
|
||||
expect(vi.mocked(until)).toHaveBeenCalled()
|
||||
expect(until).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not fetch system stats if already available', async () => {
|
||||
const { until } = await import('@vueuse/core')
|
||||
mockSystemStatsStore.systemStats = {
|
||||
system: {
|
||||
comfyui_version: '1.24.0',
|
||||
@@ -377,7 +378,7 @@ describe('useVersionCompatibilityStore', () => {
|
||||
|
||||
await store.initialize()
|
||||
|
||||
expect(vi.mocked(until)).not.toHaveBeenCalled()
|
||||
expect(until).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -215,6 +215,8 @@ export const useWorkflowService = () => {
|
||||
}
|
||||
}
|
||||
|
||||
workflowDraftStore.removeDraft(workflow.path)
|
||||
|
||||
// If this is the last workflow, create a new default temporary workflow
|
||||
if (workflowStore.openWorkflows.length === 1) {
|
||||
await loadDefaultWorkflow()
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
useWorkflowBookmarkStore,
|
||||
useWorkflowStore
|
||||
} from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowDraftStore } from '@/platform/workflow/persistence/stores/workflowDraftStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
import { defaultGraph, defaultGraphJSON } from '@/scripts/defaultGraph'
|
||||
@@ -910,4 +911,41 @@ describe('useWorkflowStore', () => {
|
||||
expect(mostRecent).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('closeWorkflow draft cleanup', () => {
|
||||
it('should remove draft for persisted workflows on close', async () => {
|
||||
const draftStore = useWorkflowDraftStore()
|
||||
await syncRemoteWorkflows(['a.json'])
|
||||
const workflow = store.getWorkflowByPath('workflows/a.json')!
|
||||
|
||||
draftStore.saveDraft('workflows/a.json', {
|
||||
data: '{"dirty":true}',
|
||||
updatedAt: Date.now(),
|
||||
name: 'a.json',
|
||||
isTemporary: false
|
||||
})
|
||||
expect(draftStore.getDraft('workflows/a.json')).toBeDefined()
|
||||
|
||||
await store.closeWorkflow(workflow)
|
||||
|
||||
expect(draftStore.getDraft('workflows/a.json')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should remove draft for temporary workflows on close', async () => {
|
||||
const draftStore = useWorkflowDraftStore()
|
||||
const workflow = store.createTemporary('temp.json')
|
||||
|
||||
draftStore.saveDraft(workflow.path, {
|
||||
data: '{"dirty":true}',
|
||||
updatedAt: Date.now(),
|
||||
name: 'temp.json',
|
||||
isTemporary: true
|
||||
})
|
||||
expect(draftStore.getDraft(workflow.path)).toBeDefined()
|
||||
|
||||
await store.closeWorkflow(workflow)
|
||||
|
||||
expect(draftStore.getDraft(workflow.path)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -463,11 +463,9 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
||||
openWorkflowPaths.value = openWorkflowPaths.value.filter(
|
||||
(path) => path !== workflow.path
|
||||
)
|
||||
useWorkflowDraftStore().removeDraft(workflow.path)
|
||||
if (workflow.isTemporary) {
|
||||
// Clear thumbnail when temporary workflow is closed
|
||||
clearThumbnail(workflow.key)
|
||||
// Clear draft when unsaved workflow tab is closed
|
||||
useWorkflowDraftStore().removeDraft(workflow.path)
|
||||
delete workflowLookup.value[workflow.path]
|
||||
} else {
|
||||
workflow.unload()
|
||||
|
||||
@@ -394,6 +394,7 @@ interface SubgraphDefinitionBase<
|
||||
id: string
|
||||
revision: number
|
||||
name: string
|
||||
category?: string
|
||||
|
||||
inputNode: T extends ComfyWorkflow1BaseInput
|
||||
? z.input<typeof zExportedSubgraphIONode>
|
||||
@@ -425,6 +426,7 @@ const zSubgraphDefinition = zComfyWorkflow1
|
||||
id: z.string().uuid(),
|
||||
revision: z.number(),
|
||||
name: z.string(),
|
||||
category: z.string().optional(),
|
||||
inputNode: zExportedSubgraphIONode,
|
||||
outputNode: zExportedSubgraphIONode,
|
||||
|
||||
|
||||
@@ -3,9 +3,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { createGraphThumbnail } from '@/renderer/core/thumbnail/graphThumbnailRenderer'
|
||||
import { useWorkflowThumbnail } from '@/renderer/core/thumbnail/useWorkflowThumbnail'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
vi.mock('@/renderer/core/thumbnail/graphThumbnailRenderer', () => ({
|
||||
createGraphThumbnail: vi.fn()
|
||||
@@ -22,6 +19,12 @@ vi.mock('@/scripts/api', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
const { useWorkflowThumbnail } =
|
||||
await import('@/renderer/core/thumbnail/useWorkflowThumbnail')
|
||||
const { createGraphThumbnail } =
|
||||
await import('@/renderer/core/thumbnail/graphThumbnailRenderer')
|
||||
const { api } = await import('@/scripts/api')
|
||||
|
||||
describe('useWorkflowThumbnail', () => {
|
||||
let workflowStore: ReturnType<typeof useWorkflowStore>
|
||||
|
||||
|
||||
@@ -205,7 +205,7 @@ defineExpose({ runButtonClick })
|
||||
<NodeWidgets
|
||||
:node-data
|
||||
:style="{ background: applyLightThemeColor(nodeData.bgcolor) }"
|
||||
class="py-3 gap-y-3 **:[.col-span-2]:grid-cols-1 *:has-[textarea]:h-50 rounded-lg max-w-100"
|
||||
class="py-3 gap-y-3 **:[.col-span-2]:grid-cols-1 not-has-[textarea]:flex-0 rounded-lg max-w-100"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
@@ -237,7 +237,7 @@ defineExpose({ runButtonClick })
|
||||
:node-data
|
||||
:class="
|
||||
cn(
|
||||
'py-3 gap-y-3 **:[.col-span-2]:grid-cols-1 *:has-[textarea]:h-50 rounded-lg',
|
||||
'py-3 gap-y-3 **:[.col-span-2]:grid-cols-1 not-has-[textarea]:flex-0 rounded-lg',
|
||||
nodeData.hasErrors &&
|
||||
'ring-2 ring-inset ring-node-stroke-error'
|
||||
)
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CollapsibleRoot,
|
||||
CollapsibleTrigger,
|
||||
CollapsibleContent
|
||||
} from 'reka-ui'
|
||||
|
||||
import WorkflowsSidebarTab from '@/components/sidebar/tabs/WorkflowsSidebarTab.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
|
||||
import { t } from '@/i18n'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
</script>
|
||||
<template>
|
||||
<CollapsibleRoot class="flex flex-col">
|
||||
<CollapsibleTrigger as-child>
|
||||
<Button variant="secondary" class="size-10 self-end m-4 mb-2">
|
||||
<i class="icon-[lucide--menu] size-8" />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent class="flex gap-2 flex-col">
|
||||
<div class="w-full border-b-2 border-border-subtle" />
|
||||
<Popover>
|
||||
<template #button>
|
||||
<Button variant="secondary" size="lg" class="w-full">
|
||||
<i class="icon-[comfy--workflow]" />
|
||||
{{ t('Workflows') }}
|
||||
</Button>
|
||||
</template>
|
||||
<WorkflowsSidebarTab class="h-300 w-[80vw]" />
|
||||
</Popover>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
class="w-full"
|
||||
@click="useWorkflowTemplateSelectorDialog().show('menu')"
|
||||
>
|
||||
<i class="icon-[comfy--template]" />
|
||||
{{ t('sideToolbar.templates') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
class="w-full"
|
||||
@click="
|
||||
useCommandStore().execute('Comfy.ToggleLinear', {
|
||||
metadata: { source: 'button' }
|
||||
})
|
||||
"
|
||||
>
|
||||
<i class="icon-[lucide--log-out]" />
|
||||
{{ t('linearMode.graphMode') }}
|
||||
</Button>
|
||||
<div class="w-full border-b-2 border-border-subtle" />
|
||||
</CollapsibleContent>
|
||||
</CollapsibleRoot>
|
||||
</template>
|
||||
@@ -156,11 +156,7 @@ watch([selectedIndex, selectedOutput], doEmit)
|
||||
watch(
|
||||
() => outputs.media.value,
|
||||
(newAssets, oldAssets) => {
|
||||
if (
|
||||
newAssets.length === oldAssets.length ||
|
||||
(oldAssets.length === 0 && newAssets.length !== 1)
|
||||
)
|
||||
return
|
||||
if (newAssets.length === oldAssets.length || oldAssets.length === 0) return
|
||||
if (selectedIndex.value[0] <= 0) {
|
||||
selectedIndex.value = [0, 0]
|
||||
return
|
||||
|
||||
@@ -200,8 +200,9 @@ vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
import { useMinimap } from '@/renderer/extensions/minimap/composables/useMinimap'
|
||||
import { api } from '@/scripts/api'
|
||||
const { useMinimap } =
|
||||
await import('@/renderer/extensions/minimap/composables/useMinimap')
|
||||
const { api } = await import('@/scripts/api')
|
||||
|
||||
describe('useMinimap', () => {
|
||||
let moduleMockCanvasElement: HTMLCanvasElement
|
||||
|
||||
@@ -103,7 +103,9 @@ describe('useMinimapRenderer', () => {
|
||||
expect(vi.mocked(renderMinimapToCanvas)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should only render when redraw is needed', () => {
|
||||
it('should only render when redraw is needed', async () => {
|
||||
const { renderMinimapToCanvas } =
|
||||
await import('@/renderer/extensions/minimap/minimapCanvasRenderer')
|
||||
const canvasRef = ref(mockCanvas)
|
||||
const graphRef = ref(mockGraph) as Ref<LGraph | null>
|
||||
const boundsRef = ref({ minX: 0, minY: 0, width: 100, height: 100 })
|
||||
|
||||
@@ -4,11 +4,6 @@ import { ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
calculateMinimapScale,
|
||||
calculateNodeBounds,
|
||||
enforceMinimumBounds
|
||||
} from '@/renderer/core/spatial/boundsCalculator'
|
||||
import { useMinimapViewport } from '@/renderer/extensions/minimap/composables/useMinimapViewport'
|
||||
import type { MinimapCanvas } from '@/renderer/extensions/minimap/types'
|
||||
|
||||
@@ -71,7 +66,10 @@ describe('useMinimapViewport', () => {
|
||||
expect(viewport.scale.value).toBe(1)
|
||||
})
|
||||
|
||||
it('should calculate graph bounds from nodes', () => {
|
||||
it('should calculate graph bounds from nodes', async () => {
|
||||
const { calculateNodeBounds, enforceMinimumBounds } =
|
||||
await import('@/renderer/core/spatial/boundsCalculator')
|
||||
|
||||
vi.mocked(calculateNodeBounds).mockReturnValue({
|
||||
minX: 100,
|
||||
minY: 100,
|
||||
@@ -94,7 +92,10 @@ describe('useMinimapViewport', () => {
|
||||
expect(enforceMinimumBounds).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle empty graph', () => {
|
||||
it('should handle empty graph', async () => {
|
||||
const { calculateNodeBounds } =
|
||||
await import('@/renderer/core/spatial/boundsCalculator')
|
||||
|
||||
vi.mocked(calculateNodeBounds).mockReturnValue(null)
|
||||
|
||||
const canvasRef = ref(mockCanvas) as Ref<MinimapCanvas | null>
|
||||
@@ -130,7 +131,11 @@ describe('useMinimapViewport', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should calculate viewport transform', () => {
|
||||
it('should calculate viewport transform', async () => {
|
||||
const { calculateNodeBounds, enforceMinimumBounds, calculateMinimapScale } =
|
||||
await import('@/renderer/core/spatial/boundsCalculator')
|
||||
|
||||
// Mock the bounds calculation
|
||||
vi.mocked(calculateNodeBounds).mockReturnValue({
|
||||
minX: 0,
|
||||
minY: 0,
|
||||
@@ -231,7 +236,10 @@ describe('useMinimapViewport', () => {
|
||||
expect(() => viewport.centerViewOn(100, 100)).not.toThrow()
|
||||
})
|
||||
|
||||
it('should calculate scale correctly', () => {
|
||||
it('should calculate scale correctly', async () => {
|
||||
const { calculateMinimapScale, calculateNodeBounds, enforceMinimumBounds } =
|
||||
await import('@/renderer/core/spatial/boundsCalculator')
|
||||
|
||||
const testBounds = {
|
||||
minX: 0,
|
||||
minY: 0,
|
||||
|
||||
@@ -158,7 +158,15 @@ const hasMultipleVideos = computed(() => props.imageUrls.length > 1)
|
||||
// Watch for URL changes and reset state
|
||||
watch(
|
||||
() => props.imageUrls,
|
||||
(newUrls) => {
|
||||
(newUrls, oldUrls) => {
|
||||
// Only reset state if URLs actually changed (not just array reference)
|
||||
const urlsChanged =
|
||||
!oldUrls ||
|
||||
newUrls.length !== oldUrls.length ||
|
||||
newUrls.some((url, i) => url !== oldUrls[i])
|
||||
|
||||
if (!urlsChanged) return
|
||||
|
||||
// Reset current index if it's out of bounds
|
||||
if (currentIndex.value >= newUrls.length) {
|
||||
currentIndex.value = 0
|
||||
@@ -169,7 +177,7 @@ watch(
|
||||
videoError.value = false
|
||||
showLoader.value = newUrls.length > 0
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Event handlers
|
||||
|
||||
@@ -308,4 +308,80 @@ describe('ImagePreview', () => {
|
||||
expect(imgElement.exists()).toBe(true)
|
||||
expect(imgElement.attributes('alt')).toBe('Node output 2')
|
||||
})
|
||||
|
||||
describe('URL change detection', () => {
|
||||
it('should NOT reset loading state when imageUrls prop is reassigned with identical URLs', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const urls = ['/api/view?filename=test.png&type=output']
|
||||
const wrapper = mountImagePreview({ imageUrls: urls })
|
||||
|
||||
// Simulate image load completing
|
||||
const img = wrapper.find('img')
|
||||
await img.trigger('load')
|
||||
await nextTick()
|
||||
|
||||
// Verify loader is hidden after load
|
||||
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false)
|
||||
|
||||
// Reassign with new array reference but same content
|
||||
await wrapper.setProps({ imageUrls: [...urls] })
|
||||
await nextTick()
|
||||
|
||||
// Advance past the 250ms delayed loader timeout
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
await nextTick()
|
||||
|
||||
// Loading state should NOT have been reset - aria-busy should still be false
|
||||
// because the URLs are identical (just a new array reference)
|
||||
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('should reset loading state when imageUrls prop changes to different URLs', async () => {
|
||||
const urls = ['/api/view?filename=test.png&type=output']
|
||||
const wrapper = mountImagePreview({ imageUrls: urls })
|
||||
|
||||
// Simulate image load completing
|
||||
const img = wrapper.find('img')
|
||||
await img.trigger('load')
|
||||
await nextTick()
|
||||
|
||||
// Verify loader is hidden
|
||||
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false)
|
||||
|
||||
// Change to different URL
|
||||
await wrapper.setProps({
|
||||
imageUrls: ['/api/view?filename=different.png&type=output']
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
// After 250ms timeout, loading state should be reset (aria-busy="true")
|
||||
// We can check the internal state via the Skeleton appearing
|
||||
// or wait for the timeout
|
||||
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle empty to non-empty URL transitions correctly', async () => {
|
||||
const wrapper = mountImagePreview({ imageUrls: [] })
|
||||
|
||||
// No preview initially
|
||||
expect(wrapper.find('.image-preview').exists()).toBe(false)
|
||||
|
||||
// Add URLs
|
||||
await wrapper.setProps({
|
||||
imageUrls: ['/api/view?filename=test.png&type=output']
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
// Preview should appear
|
||||
expect(wrapper.find('.image-preview').exists()).toBe(true)
|
||||
expect(wrapper.find('img').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -176,7 +176,15 @@ const imageAltText = computed(() => `Node output ${currentIndex.value + 1}`)
|
||||
// Watch for URL changes and reset state
|
||||
watch(
|
||||
() => props.imageUrls,
|
||||
(newUrls) => {
|
||||
(newUrls, oldUrls) => {
|
||||
// Only reset state if URLs actually changed (not just array reference)
|
||||
const urlsChanged =
|
||||
!oldUrls ||
|
||||
newUrls.length !== oldUrls.length ||
|
||||
newUrls.some((url, i) => url !== oldUrls[i])
|
||||
|
||||
if (!urlsChanged) return
|
||||
|
||||
// Reset current index if it's out of bounds
|
||||
if (currentIndex.value >= newUrls.length) {
|
||||
currentIndex.value = 0
|
||||
@@ -188,7 +196,7 @@ watch(
|
||||
imageError.value = false
|
||||
if (newUrls.length > 0) startDelayedLoader()
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Event handlers
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
cn(
|
||||
'lg-slot lg-slot--input flex items-center group rounded-r-lg m-0',
|
||||
'cursor-crosshair',
|
||||
props.dotOnly ? 'lg-slot--dot-only' : 'pr-6',
|
||||
dotOnly ? 'lg-slot--dot-only' : 'pr-6',
|
||||
{
|
||||
'lg-slot--connected': props.connected,
|
||||
'lg-slot--compatible': props.compatible,
|
||||
@@ -36,7 +36,7 @@
|
||||
<!-- Slot Name -->
|
||||
<div class="h-full flex items-center min-w-0">
|
||||
<span
|
||||
v-if="!dotOnly"
|
||||
v-if="!props.dotOnly && !hasNoLabel"
|
||||
:class="
|
||||
cn(
|
||||
'truncate text-node-component-slot-text',
|
||||
@@ -47,8 +47,7 @@
|
||||
{{
|
||||
slotData.label ||
|
||||
slotData.localized_name ||
|
||||
slotData.name ||
|
||||
`Input ${index}`
|
||||
(slotData.name ?? `Input ${index}`)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
@@ -84,6 +83,14 @@ interface InputSlotProps {
|
||||
|
||||
const props = defineProps<InputSlotProps>()
|
||||
|
||||
const hasNoLabel = computed(
|
||||
() =>
|
||||
!props.slotData.label &&
|
||||
!props.slotData.localized_name &&
|
||||
props.slotData.name === ''
|
||||
)
|
||||
const dotOnly = computed(() => props.dotOnly || hasNoLabel.value)
|
||||
|
||||
const executionStore = useExecutionStore()
|
||||
|
||||
const hasSlotError = computed(() => {
|
||||
|
||||
@@ -150,7 +150,9 @@
|
||||
v-if="!isCollapsed && nodeData.resizable !== false"
|
||||
role="button"
|
||||
:aria-label="t('g.resizeFromBottomRight')"
|
||||
:class="cn(baseResizeHandleClasses, 'right-0 bottom-0 cursor-se-resize')"
|
||||
:class="
|
||||
cn(baseResizeHandleClasses, '-right-1 -bottom-1 cursor-se-resize')
|
||||
"
|
||||
@pointerdown.stop="handleResizePointerDown"
|
||||
/>
|
||||
</div>
|
||||
@@ -344,7 +346,7 @@ function initSizeStyles() {
|
||||
}
|
||||
|
||||
const baseResizeHandleClasses =
|
||||
'absolute h-3 w-3 opacity-0 pointer-events-auto focus-visible:outline focus-visible:outline-2 focus-visible:outline-white/40'
|
||||
'absolute h-5 w-5 opacity-0 pointer-events-auto focus-visible:outline focus-visible:outline-2 focus-visible:outline-white/40'
|
||||
|
||||
const MIN_NODE_WIDTH = 225
|
||||
|
||||
@@ -549,6 +551,12 @@ const showAdvancedState = customRef((track, trigger) => {
|
||||
}
|
||||
})
|
||||
|
||||
const hasVideoInput = computed(() => {
|
||||
return (
|
||||
lgraphNode.value?.inputs?.some((input) => input.type === 'VIDEO') ?? false
|
||||
)
|
||||
})
|
||||
|
||||
const nodeMedia = computed(() => {
|
||||
const newOutputs = nodeOutputs.nodeOutputs[nodeOutputLocatorId.value]
|
||||
const node = lgraphNode.value
|
||||
@@ -558,13 +566,9 @@ const nodeMedia = computed(() => {
|
||||
const urls = nodeOutputs.getNodeImageUrls(node)
|
||||
if (!urls?.length) return undefined
|
||||
|
||||
// Determine media type from previewMediaType or fallback to input slot types
|
||||
// Note: Despite the field name "images", videos are also included in outputs
|
||||
// TODO: fix the backend to return videos using the videos key instead of the images key
|
||||
const hasVideoInput = node.inputs?.some((input) => input.type === 'VIDEO')
|
||||
const type =
|
||||
node.previewMediaType === 'video' ||
|
||||
(!node.previewMediaType && hasVideoInput)
|
||||
(!node.previewMediaType && hasVideoInput.value)
|
||||
? 'video'
|
||||
: 'image'
|
||||
|
||||
|
||||
@@ -108,11 +108,11 @@ import NodeBadge from '@/renderer/extensions/vueNodes/components/NodeBadge.vue'
|
||||
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
|
||||
import { applyLightThemeColor } from '@/renderer/extensions/vueNodes/utils/nodeStyleUtils'
|
||||
import { app } from '@/scripts/app'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
import {
|
||||
getLocatorIdFromNodeData,
|
||||
getNodeByLocatorId
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import type { NodeBadgeProps } from './NodeBadge.vue'
|
||||
@@ -160,12 +160,12 @@ const enterSubgraphTooltipConfig = computed(() => {
|
||||
})
|
||||
|
||||
const resolveTitle = (info: VueNodeData | undefined) => {
|
||||
const untitledLabel = st('g.untitled', 'Untitled')
|
||||
return resolveNodeDisplayName(info ?? null, {
|
||||
emptyLabel: untitledLabel,
|
||||
untitledLabel,
|
||||
st
|
||||
})
|
||||
const title = (info?.title ?? '').trim()
|
||||
if (title.length > 0) return title
|
||||
|
||||
const nodeType = (info?.type ?? '').trim() || 'Untitled'
|
||||
const key = `nodeDefs.${normalizeI18nKey(nodeType)}.display_name`
|
||||
return st(key, nodeType)
|
||||
}
|
||||
|
||||
// Local state for title to provide immediate feedback
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
<div v-else v-tooltip.right="tooltipConfig" :class="slotWrapperClass">
|
||||
<div class="relative h-full flex items-center min-w-0">
|
||||
<!-- Slot Name -->
|
||||
<span v-if="!dotOnly" class="truncate text-node-component-slot-text">
|
||||
{{ slotData.localized_name || slotData.name || `Output ${index}` }}
|
||||
<span
|
||||
v-if="!props.dotOnly && !hasNoLabel"
|
||||
class="truncate text-node-component-slot-text"
|
||||
>
|
||||
{{ slotData.localized_name || (slotData.name ?? `Output ${index}`) }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Connection Dot -->
|
||||
@@ -44,6 +47,11 @@ interface OutputSlotProps {
|
||||
|
||||
const props = defineProps<OutputSlotProps>()
|
||||
|
||||
const hasNoLabel = computed(
|
||||
() => !props.slotData.localized_name && props.slotData.name === ''
|
||||
)
|
||||
const dotOnly = computed(() => props.dotOnly || hasNoLabel.value)
|
||||
|
||||
// Error boundary implementation
|
||||
const renderError = ref<string | null>(null)
|
||||
|
||||
@@ -79,7 +87,7 @@ const slotWrapperClass = computed(() =>
|
||||
cn(
|
||||
'lg-slot lg-slot--output flex items-center justify-end group rounded-l-lg h-6',
|
||||
'cursor-crosshair',
|
||||
props.dotOnly ? 'lg-slot--dot-only justify-center' : 'pl-6',
|
||||
dotOnly.value ? 'lg-slot--dot-only justify-center' : 'pl-6',
|
||||
{
|
||||
'lg-slot--connected': props.connected,
|
||||
'lg-slot--compatible': props.compatible,
|
||||
|
||||
@@ -206,6 +206,60 @@ describe('WidgetMarkdown Dual Mode Display', () => {
|
||||
expect(clickSpy).not.toHaveBeenCalled()
|
||||
expect(keydownSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
describe('Pointer Event Propagation', () => {
|
||||
it('stops pointerdown propagation to prevent node drag during text selection', async () => {
|
||||
const widget = createMockWidget('# Test')
|
||||
const wrapper = mountComponent(widget, '# Test')
|
||||
|
||||
await clickToEdit(wrapper)
|
||||
|
||||
const textarea = wrapper.find('textarea')
|
||||
expect(textarea.exists()).toBe(true)
|
||||
|
||||
const parentPointerdownHandler = vi.fn()
|
||||
const wrapperEl = wrapper.element as HTMLElement
|
||||
wrapperEl.addEventListener('pointerdown', parentPointerdownHandler)
|
||||
|
||||
await textarea.trigger('pointerdown')
|
||||
|
||||
expect(parentPointerdownHandler).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stops pointermove propagation during text selection', async () => {
|
||||
const widget = createMockWidget('# Test')
|
||||
const wrapper = mountComponent(widget, '# Test')
|
||||
|
||||
await clickToEdit(wrapper)
|
||||
|
||||
const textarea = wrapper.find('textarea')
|
||||
|
||||
const parentPointermoveHandler = vi.fn()
|
||||
const wrapperEl = wrapper.element as HTMLElement
|
||||
wrapperEl.addEventListener('pointermove', parentPointermoveHandler)
|
||||
|
||||
await textarea.trigger('pointermove')
|
||||
|
||||
expect(parentPointermoveHandler).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stops pointerup propagation after text selection', async () => {
|
||||
const widget = createMockWidget('# Test')
|
||||
const wrapper = mountComponent(widget, '# Test')
|
||||
|
||||
await clickToEdit(wrapper)
|
||||
|
||||
const textarea = wrapper.find('textarea')
|
||||
|
||||
const parentPointerupHandler = vi.fn()
|
||||
const wrapperEl = wrapper.element as HTMLElement
|
||||
wrapperEl.addEventListener('pointerup', parentPointerupHandler)
|
||||
|
||||
await textarea.trigger('pointerup')
|
||||
|
||||
expect(parentPointerupHandler).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Value Updates', () => {
|
||||
|
||||
@@ -21,6 +21,9 @@
|
||||
}
|
||||
}"
|
||||
data-capture-wheel="true"
|
||||
@pointerdown.capture.stop
|
||||
@pointermove.capture.stop
|
||||
@pointerup.capture.stop
|
||||
@click.stop
|
||||
@keydown.stop
|
||||
/>
|
||||
|
||||
@@ -152,6 +152,50 @@ describe('WidgetSelectDropdown custom label mapping', () => {
|
||||
expect(consoleErrorSpy).toHaveBeenCalled()
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('falls back to original value when label mapping returns empty string', () => {
|
||||
const getOptionLabel = vi.fn((value: string | null) => {
|
||||
if (value === 'photo_abc.jpg') {
|
||||
return ''
|
||||
}
|
||||
return `Labeled: ${value}`
|
||||
})
|
||||
|
||||
const widget = createMockWidget('img_001.png', {
|
||||
getOptionLabel
|
||||
})
|
||||
const wrapper = mountComponent(widget, 'img_001.png')
|
||||
|
||||
const inputItems = wrapper.vm.inputItems
|
||||
expect(inputItems[0].name).toBe('img_001.png')
|
||||
expect(inputItems[0].label).toBe('Labeled: img_001.png')
|
||||
expect(inputItems[1].name).toBe('photo_abc.jpg')
|
||||
expect(inputItems[1].label).toBe('photo_abc.jpg')
|
||||
expect(inputItems[2].name).toBe('hash789.png')
|
||||
expect(inputItems[2].label).toBe('Labeled: hash789.png')
|
||||
})
|
||||
|
||||
it('falls back to original value when label mapping returns undefined', () => {
|
||||
const getOptionLabel = vi.fn((value: string | null) => {
|
||||
if (value === 'hash789.png') {
|
||||
return undefined as unknown as string
|
||||
}
|
||||
return `Labeled: ${value}`
|
||||
})
|
||||
|
||||
const widget = createMockWidget('img_001.png', {
|
||||
getOptionLabel
|
||||
})
|
||||
const wrapper = mountComponent(widget, 'img_001.png')
|
||||
|
||||
const inputItems = wrapper.vm.inputItems
|
||||
expect(inputItems[0].name).toBe('img_001.png')
|
||||
expect(inputItems[0].label).toBe('Labeled: img_001.png')
|
||||
expect(inputItems[1].name).toBe('photo_abc.jpg')
|
||||
expect(inputItems[1].label).toBe('Labeled: photo_abc.jpg')
|
||||
expect(inputItems[2].name).toBe('hash789.png')
|
||||
expect(inputItems[2].label).toBe('hash789.png')
|
||||
})
|
||||
})
|
||||
|
||||
describe('output items with custom label mapping', () => {
|
||||
@@ -171,4 +215,102 @@ describe('WidgetSelectDropdown custom label mapping', () => {
|
||||
expect(Array.isArray(outputItems)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('missing value handling for template-loaded nodes', () => {
|
||||
it('creates a fallback item in "all" filter when modelValue is not in available items', () => {
|
||||
const widget = createMockWidget('template_image.png', {
|
||||
values: ['img_001.png', 'photo_abc.jpg']
|
||||
})
|
||||
const wrapper = mountComponent(widget, 'template_image.png')
|
||||
|
||||
const inputItems = wrapper.vm.inputItems
|
||||
expect(inputItems).toHaveLength(2)
|
||||
expect(
|
||||
inputItems.some((item) => item.name === 'template_image.png')
|
||||
).toBe(false)
|
||||
|
||||
// The missing value should be accessible via dropdownItems when filter is 'all' (default)
|
||||
const dropdownItems = (
|
||||
wrapper.vm as unknown as { dropdownItems: DropdownItem[] }
|
||||
).dropdownItems
|
||||
expect(
|
||||
dropdownItems.some((item) => item.name === 'template_image.png')
|
||||
).toBe(true)
|
||||
expect(dropdownItems[0].name).toBe('template_image.png')
|
||||
expect(dropdownItems[0].id).toBe('missing-template_image.png')
|
||||
})
|
||||
|
||||
it('does not include fallback item when filter is "inputs"', async () => {
|
||||
const widget = createMockWidget('template_image.png', {
|
||||
values: ['img_001.png', 'photo_abc.jpg']
|
||||
})
|
||||
const wrapper = mountComponent(widget, 'template_image.png')
|
||||
|
||||
const vmWithFilter = wrapper.vm as unknown as {
|
||||
filterSelected: string
|
||||
dropdownItems: DropdownItem[]
|
||||
}
|
||||
|
||||
vmWithFilter.filterSelected = 'inputs'
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const dropdownItems = vmWithFilter.dropdownItems
|
||||
expect(dropdownItems).toHaveLength(2)
|
||||
expect(
|
||||
dropdownItems.every((item) => !String(item.id).startsWith('missing-'))
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('does not include fallback item when filter is "outputs"', async () => {
|
||||
const widget = createMockWidget('template_image.png', {
|
||||
values: ['img_001.png', 'photo_abc.jpg']
|
||||
})
|
||||
const wrapper = mountComponent(widget, 'template_image.png')
|
||||
|
||||
const vmWithFilter = wrapper.vm as unknown as {
|
||||
filterSelected: string
|
||||
dropdownItems: DropdownItem[]
|
||||
outputItems: DropdownItem[]
|
||||
}
|
||||
|
||||
vmWithFilter.filterSelected = 'outputs'
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const dropdownItems = vmWithFilter.dropdownItems
|
||||
expect(dropdownItems).toHaveLength(wrapper.vm.outputItems.length)
|
||||
expect(
|
||||
dropdownItems.every((item) => !String(item.id).startsWith('missing-'))
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('does not create a fallback item when modelValue exists in available items', () => {
|
||||
const widget = createMockWidget('img_001.png', {
|
||||
values: ['img_001.png', 'photo_abc.jpg']
|
||||
})
|
||||
const wrapper = mountComponent(widget, 'img_001.png')
|
||||
|
||||
const dropdownItems = (
|
||||
wrapper.vm as unknown as { dropdownItems: DropdownItem[] }
|
||||
).dropdownItems
|
||||
expect(dropdownItems).toHaveLength(2)
|
||||
expect(
|
||||
dropdownItems.every((item) => !String(item.id).startsWith('missing-'))
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('does not create a fallback item when modelValue is undefined', () => {
|
||||
const widget = createMockWidget(undefined as unknown as string, {
|
||||
values: ['img_001.png', 'photo_abc.jpg']
|
||||
})
|
||||
const wrapper = mountComponent(widget, undefined)
|
||||
|
||||
const dropdownItems = (
|
||||
wrapper.vm as unknown as { dropdownItems: DropdownItem[] }
|
||||
).dropdownItems
|
||||
expect(dropdownItems).toHaveLength(2)
|
||||
expect(
|
||||
dropdownItems.every((item) => !String(item.id).startsWith('missing-'))
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||