Compare commits
2 Commits
fix/load-a
...
curve-conn
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f12a225b2 | ||
|
|
6816fae86c |
13
.github/workflows/cloud-dispatch-build.yaml
vendored
@@ -14,7 +14,7 @@ on:
|
||||
- 'cloud/*'
|
||||
- 'main'
|
||||
pull_request:
|
||||
types: [labeled, synchronize]
|
||||
types: [labeled]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions: {}
|
||||
@@ -26,18 +26,11 @@ concurrency:
|
||||
jobs:
|
||||
dispatch:
|
||||
# Fork guard: prevent forks from dispatching to the cloud repo.
|
||||
# For pull_request events, only dispatch for preview labels.
|
||||
# - labeled: fires when a label is added; check the added label name.
|
||||
# - synchronize: fires on push; check existing labels on the PR.
|
||||
# For pull_request events, only dispatch when the 'preview' label is added.
|
||||
if: >
|
||||
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
|
||||
(github.event_name != 'pull_request' ||
|
||||
(github.event.action == 'labeled' &&
|
||||
contains(fromJSON('["preview","preview-cpu","preview-gpu"]'), github.event.label.name)) ||
|
||||
(github.event.action == 'synchronize' &&
|
||||
(contains(github.event.pull_request.labels.*.name, 'preview') ||
|
||||
contains(github.event.pull_request.labels.*.name, 'preview-cpu') ||
|
||||
contains(github.event.pull_request.labels.*.name, 'preview-gpu'))))
|
||||
github.event.label.name == 'preview')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Build client payload
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<template v-if="filter.tasks.length === 0">
|
||||
<!-- Empty filter -->
|
||||
<Divider />
|
||||
<p class="w-full text-center text-neutral-400">
|
||||
<p class="text-neutral-400 w-full text-center">
|
||||
{{ $t('maintenance.allOk') }}
|
||||
</p>
|
||||
</template>
|
||||
@@ -25,7 +25,7 @@
|
||||
|
||||
<!-- Display: Cards -->
|
||||
<template v-else>
|
||||
<div class="pad-y my-4 flex flex-wrap justify-evenly gap-8">
|
||||
<div class="flex flex-wrap justify-evenly gap-8 pad-y my-4">
|
||||
<TaskCard
|
||||
v-for="task in filter.tasks"
|
||||
:key="task.id"
|
||||
@@ -45,8 +45,7 @@ import { useConfirm, useToast } from 'primevue'
|
||||
import ConfirmPopup from 'primevue/confirmpopup'
|
||||
import Divider from 'primevue/divider'
|
||||
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
import { useMaintenanceTaskStore } from '@/stores/maintenanceTaskStore'
|
||||
import type {
|
||||
MaintenanceFilter,
|
||||
@@ -56,7 +55,6 @@ import type {
|
||||
import TaskCard from './TaskCard.vue'
|
||||
import TaskListItem from './TaskListItem.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const confirm = useConfirm()
|
||||
const taskStore = useMaintenanceTaskStore()
|
||||
@@ -82,7 +80,8 @@ const executeTask = async (task: MaintenanceTask) => {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('maintenance.error.toastTitle'),
|
||||
detail: message ?? t('maintenance.error.defaultDescription')
|
||||
detail: message ?? t('maintenance.error.defaultDescription'),
|
||||
life: 10_000
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -189,7 +189,8 @@ const completeValidation = async () => {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('maintenance.error.cannotContinue')
|
||||
detail: t('maintenance.error.cannotContinue'),
|
||||
life: 5_000
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<BaseViewTemplate dark hide-language-selector>
|
||||
<div class="flex h-full flex-col items-center justify-center p-8 2xl:p-16">
|
||||
<div class="h-full p-8 2xl:p-16 flex flex-col items-center justify-center">
|
||||
<div
|
||||
class="flex w-full max-w-[600px] flex-col gap-6 rounded-lg bg-neutral-800 p-6 shadow-lg"
|
||||
class="bg-neutral-800 rounded-lg shadow-lg p-6 w-full max-w-[600px] flex flex-col gap-6"
|
||||
>
|
||||
<h2 class="text-3xl font-semibold text-neutral-100">
|
||||
{{ $t('install.helpImprove') }}
|
||||
@@ -15,7 +15,7 @@
|
||||
<a
|
||||
href="https://comfy.org/privacy"
|
||||
target="_blank"
|
||||
class="text-blue-400 underline hover:text-blue-300"
|
||||
class="text-blue-400 hover:text-blue-300 underline"
|
||||
>
|
||||
{{ $t('install.privacyPolicy') }} </a
|
||||
>.
|
||||
@@ -33,7 +33,7 @@
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-end pt-6">
|
||||
<div class="flex pt-6 justify-end">
|
||||
<Button
|
||||
:label="$t('g.ok')"
|
||||
icon="pi pi-check"
|
||||
@@ -72,7 +72,8 @@ const updateConsent = async () => {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('install.settings.errorUpdatingConsent'),
|
||||
detail: t('install.settings.errorUpdatingConsentDetail')
|
||||
detail: t('install.settings.errorUpdatingConsentDetail'),
|
||||
life: 3000
|
||||
})
|
||||
} finally {
|
||||
isUpdating.value = false
|
||||
|
||||
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 100 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: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 107 KiB |
@@ -1,62 +0,0 @@
|
||||
# Release Process
|
||||
|
||||
## Bump Types
|
||||
|
||||
All releases use `release-version-bump.yaml`. Effects differ by bump type:
|
||||
|
||||
| Bump | Target | Creates branches? | GitHub release |
|
||||
| ---------- | ---------- | ------------------------------------- | ---------------------------- |
|
||||
| Minor | `main` | `core/` + `cloud/` for previous minor | Published, "latest" |
|
||||
| Patch | `main` | No | Published, "latest" |
|
||||
| Patch | `core/X.Y` | No | **Draft** (uncheck "latest") |
|
||||
| Prerelease | any | No | Draft + prerelease |
|
||||
|
||||
**Minor bump** (e.g. 1.41→1.42): freezes the previous minor into `core/1.41`
|
||||
and `cloud/1.41`, branched from the commit _before_ the bump. Nightly patch
|
||||
bumps on `main` are convenience snapshots — no branches created.
|
||||
|
||||
**Patch on `core/X.Y`**: publishes a hotfix draft release. Must not be marked
|
||||
"latest" so `main` stays current.
|
||||
|
||||
### Dual-homed commits
|
||||
|
||||
When a minor bump happens, unreleased commits appear in both places:
|
||||
|
||||
```
|
||||
v1.40.1 ── A ── B ── C ── [bump to 1.41.0]
|
||||
│
|
||||
└── core/1.40
|
||||
```
|
||||
|
||||
A, B, C become v1.41.0 on `main` AND sit on `core/1.40` (where they could
|
||||
later ship as v1.40.2). Same commits, no divergence — the branch just prevents
|
||||
1.41+ features from mixing in so ComfyUI can stay on 1.40.x.
|
||||
|
||||
## Backporting
|
||||
|
||||
1. Add `needs-backport` + version label to the merged PR
|
||||
2. `pr-backport.yaml` cherry-picks and creates a backport PR
|
||||
3. Conflicts produce a comment with details and an agent prompt
|
||||
|
||||
## Publishing
|
||||
|
||||
Merged PRs with the `Release` label trigger `release-draft-create.yaml`,
|
||||
publishing to GitHub Releases (`dist.zip`), PyPI (`comfyui-frontend-package`),
|
||||
and npm (`@comfyorg/comfyui-frontend-types`).
|
||||
|
||||
## Bi-weekly ComfyUI Integration
|
||||
|
||||
`release-biweekly-comfyui.yaml` runs every other Monday — if the next `core/`
|
||||
branch has unreleased commits, it triggers a patch bump and drafts a PR to
|
||||
`Comfy-Org/ComfyUI` updating `requirements.txt`.
|
||||
|
||||
## Workflows
|
||||
|
||||
| Workflow | Purpose |
|
||||
| ------------------------------- | ------------------------------------------------ |
|
||||
| `release-version-bump.yaml` | Bump version, create Release PR |
|
||||
| `release-draft-create.yaml` | Build + publish to GitHub/PyPI/npm |
|
||||
| `release-branch-create.yaml` | Create `core/` + `cloud/` branches (minor/major) |
|
||||
| `release-biweekly-comfyui.yaml` | Auto-patch + ComfyUI requirements PR |
|
||||
| `pr-backport.yaml` | Cherry-pick fixes to stable branches |
|
||||
| `cloud-backport-tag.yaml` | Tag cloud branch merges |
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.42.2",
|
||||
"version": "1.42.0",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -166,22 +166,13 @@ describe('TopMenuSection', () => {
|
||||
})
|
||||
|
||||
describe('authentication state', () => {
|
||||
function createLegacyTabBarWrapper() {
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn })
|
||||
const settingStore = useSettingStore(pinia)
|
||||
vi.mocked(settingStore.get).mockImplementation((key) =>
|
||||
key === 'Comfy.UI.TabBarLayout' ? 'Legacy' : undefined
|
||||
)
|
||||
return createWrapper({ pinia })
|
||||
}
|
||||
|
||||
describe('when user is logged in', () => {
|
||||
beforeEach(() => {
|
||||
mockData.isLoggedIn = true
|
||||
})
|
||||
|
||||
it('should display CurrentUserButton and not display LoginButton', () => {
|
||||
const wrapper = createLegacyTabBarWrapper()
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.findComponent(CurrentUserButton).exists()).toBe(true)
|
||||
expect(wrapper.findComponent(LoginButton).exists()).toBe(false)
|
||||
})
|
||||
@@ -195,7 +186,7 @@ describe('TopMenuSection', () => {
|
||||
describe('on desktop platform', () => {
|
||||
it('should display LoginButton and not display CurrentUserButton', () => {
|
||||
mockData.isDesktop = true
|
||||
const wrapper = createLegacyTabBarWrapper()
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.findComponent(LoginButton).exists()).toBe(true)
|
||||
expect(wrapper.findComponent(CurrentUserButton).exists()).toBe(false)
|
||||
})
|
||||
@@ -203,7 +194,7 @@ describe('TopMenuSection', () => {
|
||||
|
||||
describe('on web platform', () => {
|
||||
it('should not display CurrentUserButton and not display LoginButton', () => {
|
||||
const wrapper = createLegacyTabBarWrapper()
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.findComponent(CurrentUserButton).exists()).toBe(false)
|
||||
expect(wrapper.findComponent(LoginButton).exists()).toBe(false)
|
||||
})
|
||||
|
||||
@@ -34,7 +34,17 @@
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div ref="actionbarContainerRef" :class="actionbarContainerClass">
|
||||
<div
|
||||
ref="actionbarContainerRef"
|
||||
:class="
|
||||
cn(
|
||||
'actionbar-container pointer-events-auto relative flex h-12 items-center gap-2 rounded-lg border bg-comfy-menu-bg px-2 shadow-interface',
|
||||
hasAnyError
|
||||
? 'border-destructive-background-hover'
|
||||
: 'border-interface-stroke'
|
||||
)
|
||||
"
|
||||
>
|
||||
<ActionBarButtons />
|
||||
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
|
||||
<div
|
||||
@@ -45,7 +55,6 @@
|
||||
<ComfyActionbar
|
||||
:top-menu-container="actionbarContainerRef"
|
||||
:queue-overlay-expanded="isQueueOverlayExpanded"
|
||||
:has-any-error="hasAnyError"
|
||||
@update:progress-target="updateProgressTarget"
|
||||
/>
|
||||
<CurrentUserButton
|
||||
@@ -61,7 +70,7 @@
|
||||
@click="() => openShareDialog().catch(toastErrorHandler)"
|
||||
@pointerenter="prefetchShareDialog"
|
||||
>
|
||||
<i class="icon-[comfy--send] size-4" />
|
||||
<i class="icon-[lucide--share-2] size-4" />
|
||||
<span class="not-md:hidden">
|
||||
{{ t('actionbar.share') }}
|
||||
</span>
|
||||
@@ -114,7 +123,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useLocalStorage, useMutationObserver } from '@vueuse/core'
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -136,7 +145,6 @@ import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useActionBarButtonStore } from '@/stores/actionBarButtonStore'
|
||||
import { useQueueUIStore } from '@/stores/queueStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
@@ -160,7 +168,6 @@ const { isLoggedIn } = useCurrentUser()
|
||||
const { t } = useI18n()
|
||||
const { toastErrorHandler } = useErrorHandling()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const actionBarButtonStore = useActionBarButtonStore()
|
||||
const queueUIStore = useQueueUIStore()
|
||||
const { isOverlayExpanded: isQueueOverlayExpanded } = storeToRefs(queueUIStore)
|
||||
const { shouldShowRedDot: shouldShowConflictRedDot } =
|
||||
@@ -175,45 +182,8 @@ const isActionbarEnabled = computed(
|
||||
const isActionbarFloating = computed(
|
||||
() => isActionbarEnabled.value && !isActionbarDocked.value
|
||||
)
|
||||
/**
|
||||
* Whether the actionbar container has any visible docked buttons
|
||||
* (excluding ComfyActionbar, which uses position:fixed when floating
|
||||
* and does not contribute to the container's visual layout).
|
||||
*/
|
||||
const hasDockedButtons = computed(() => {
|
||||
if (actionBarButtonStore.buttons.length > 0) return true
|
||||
if (hasLegacyContent.value) return true
|
||||
if (isLoggedIn.value && !isIntegratedTabBar.value) return true
|
||||
if (isDesktop && !isIntegratedTabBar.value) return true
|
||||
if (isCloud && flags.workflowSharingEnabled) return true
|
||||
if (!isRightSidePanelOpen.value) return true
|
||||
return false
|
||||
})
|
||||
const isActionbarContainerEmpty = computed(
|
||||
() => isActionbarFloating.value && !hasDockedButtons.value
|
||||
)
|
||||
const actionbarContainerClass = computed(() => {
|
||||
const base =
|
||||
'actionbar-container pointer-events-auto relative flex h-12 items-center gap-2 rounded-lg border bg-comfy-menu-bg shadow-interface'
|
||||
|
||||
if (isActionbarContainerEmpty.value) {
|
||||
return cn(
|
||||
base,
|
||||
'-ml-2 w-0 min-w-0 border-transparent shadow-none',
|
||||
'has-[.border-dashed]:ml-0 has-[.border-dashed]:w-auto has-[.border-dashed]:min-w-auto',
|
||||
'has-[.border-dashed]:border-interface-stroke has-[.border-dashed]:pl-2 has-[.border-dashed]:shadow-interface'
|
||||
)
|
||||
}
|
||||
|
||||
const borderClass =
|
||||
!isActionbarFloating.value && hasAnyError.value
|
||||
? 'border-destructive-background-hover'
|
||||
: 'border-interface-stroke'
|
||||
|
||||
return cn(base, 'px-2', borderClass)
|
||||
})
|
||||
const isIntegratedTabBar = computed(
|
||||
() => settingStore.get('Comfy.UI.TabBarLayout') !== 'Legacy'
|
||||
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
|
||||
)
|
||||
const { isQueuePanelV2Enabled, isRunProgressBarEnabled } =
|
||||
useQueueFeatureFlags()
|
||||
@@ -263,25 +233,6 @@ const rightSidePanelTooltipConfig = computed(() =>
|
||||
|
||||
// Maintain support for legacy topbar elements attached by custom scripts
|
||||
const legacyCommandsContainerRef = ref<HTMLElement>()
|
||||
const hasLegacyContent = ref(false)
|
||||
|
||||
function checkLegacyContent() {
|
||||
const el = legacyCommandsContainerRef.value
|
||||
if (!el) {
|
||||
hasLegacyContent.value = false
|
||||
return
|
||||
}
|
||||
// Mirror the CSS: [&:not(:has(*>*:not(:empty)))]:hidden
|
||||
hasLegacyContent.value =
|
||||
el.querySelector(':scope > * > *:not(:empty)') !== null
|
||||
}
|
||||
|
||||
useMutationObserver(legacyCommandsContainerRef, checkLegacyContent, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (legacyCommandsContainerRef.value) {
|
||||
app.menu.element.style.width = 'fit-content'
|
||||
|
||||
@@ -119,14 +119,9 @@ import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import ComfyRunButton from './ComfyRunButton'
|
||||
|
||||
const {
|
||||
topMenuContainer,
|
||||
queueOverlayExpanded = false,
|
||||
hasAnyError = false
|
||||
} = defineProps<{
|
||||
const { topMenuContainer, queueOverlayExpanded = false } = defineProps<{
|
||||
topMenuContainer?: HTMLElement | null
|
||||
queueOverlayExpanded?: boolean
|
||||
hasAnyError?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -440,12 +435,7 @@ const panelClass = computed(() =>
|
||||
isDragging.value && 'pointer-events-none select-none',
|
||||
isDocked.value
|
||||
? 'static border-none bg-transparent p-0'
|
||||
: [
|
||||
'fixed shadow-interface',
|
||||
hasAnyError
|
||||
? 'border-destructive-background-hover'
|
||||
: 'border-interface-stroke'
|
||||
]
|
||||
: 'fixed shadow-interface'
|
||||
)
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -46,74 +46,71 @@ function showApps() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pointer-events-auto flex flex-row items-start gap-2">
|
||||
<div class="pointer-events-auto flex flex-col gap-2">
|
||||
<Button
|
||||
v-if="enableAppBuilder"
|
||||
v-tooltip.right="{
|
||||
value: t('linearMode.appModeToolbar.appBuilder'),
|
||||
...tooltipOptions
|
||||
}"
|
||||
variant="secondary"
|
||||
size="unset"
|
||||
:disabled="!hasNodes"
|
||||
:aria-label="t('linearMode.appModeToolbar.appBuilder')"
|
||||
class="size-10 rounded-lg"
|
||||
@click="enterBuilder"
|
||||
>
|
||||
<i class="icon-[lucide--hammer] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="isCloud && flags.workflowSharingEnabled"
|
||||
v-tooltip.right="{
|
||||
value: t('actionbar.shareTooltip'),
|
||||
...tooltipOptions
|
||||
}"
|
||||
variant="secondary"
|
||||
size="unset"
|
||||
:aria-label="t('actionbar.shareTooltip')"
|
||||
class="size-10 rounded-lg"
|
||||
@click="() => openShareDialog().catch(toastErrorHandler)"
|
||||
@pointerenter="prefetchShareDialog"
|
||||
>
|
||||
<i class="icon-[lucide--send] size-4" />
|
||||
</Button>
|
||||
|
||||
<div
|
||||
class="flex w-10 flex-col overflow-hidden rounded-lg bg-secondary-background"
|
||||
>
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: t('sideToolbar.mediaAssets.title'),
|
||||
...tooltipOptions
|
||||
}"
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:aria-label="t('sideToolbar.mediaAssets.title')"
|
||||
:class="
|
||||
cn('size-10', isAssetsActive && 'bg-secondary-background-hover')
|
||||
"
|
||||
@click="openAssets"
|
||||
>
|
||||
<i class="icon-[comfy--image-ai-edit] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: t('linearMode.appModeToolbar.apps'),
|
||||
...tooltipOptions
|
||||
}"
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:aria-label="t('linearMode.appModeToolbar.apps')"
|
||||
:class="
|
||||
cn('size-10', isAppsActive && 'bg-secondary-background-hover')
|
||||
"
|
||||
@click="showApps"
|
||||
>
|
||||
<i class="icon-[lucide--panels-top-left] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pointer-events-auto flex flex-col gap-2">
|
||||
<WorkflowActionsDropdown source="app_mode_toolbar" />
|
||||
|
||||
<Button
|
||||
v-if="enableAppBuilder"
|
||||
v-tooltip.right="{
|
||||
value: t('linearMode.appModeToolbar.appBuilder'),
|
||||
...tooltipOptions
|
||||
}"
|
||||
variant="secondary"
|
||||
size="unset"
|
||||
:disabled="!hasNodes"
|
||||
:aria-label="t('linearMode.appModeToolbar.appBuilder')"
|
||||
class="size-10 rounded-lg"
|
||||
@click="enterBuilder"
|
||||
>
|
||||
<i class="icon-[lucide--hammer] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="isCloud && flags.workflowSharingEnabled"
|
||||
v-tooltip.right="{
|
||||
value: t('actionbar.shareTooltip'),
|
||||
...tooltipOptions
|
||||
}"
|
||||
variant="secondary"
|
||||
size="unset"
|
||||
:aria-label="t('actionbar.shareTooltip')"
|
||||
class="size-10 rounded-lg"
|
||||
@click="() => openShareDialog().catch(toastErrorHandler)"
|
||||
@pointerenter="prefetchShareDialog"
|
||||
>
|
||||
<i class="icon-[lucide--send] size-4" />
|
||||
</Button>
|
||||
|
||||
<div
|
||||
class="flex w-10 flex-col overflow-hidden rounded-lg bg-secondary-background"
|
||||
>
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: t('sideToolbar.mediaAssets.title'),
|
||||
...tooltipOptions
|
||||
}"
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:aria-label="t('sideToolbar.mediaAssets.title')"
|
||||
:class="
|
||||
cn('size-10', isAssetsActive && 'bg-secondary-background-hover')
|
||||
"
|
||||
@click="openAssets"
|
||||
>
|
||||
<i class="icon-[comfy--image-ai-edit] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: t('linearMode.appModeToolbar.apps'),
|
||||
...tooltipOptions
|
||||
}"
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:aria-label="t('linearMode.appModeToolbar.apps')"
|
||||
:class="cn('size-10', isAppsActive && 'bg-secondary-background-hover')"
|
||||
@click="showApps"
|
||||
>
|
||||
<i class="icon-[lucide--panels-top-left] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -8,9 +8,9 @@ import DraggableList from '@/components/common/DraggableList.vue'
|
||||
import IoItem from '@/components/builder/IoItem.vue'
|
||||
import PropertiesAccordionItem from '@/components/rightSidePanel/layout/PropertiesAccordionItem.vue'
|
||||
import WidgetItem from '@/components/rightSidePanel/parameters/WidgetItem.vue'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
import {
|
||||
LGraphEventMode,
|
||||
@@ -25,10 +25,10 @@ import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteracti
|
||||
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
|
||||
import { app } from '@/scripts/app'
|
||||
import { DOMWidgetImpl } from '@/scripts/domWidget'
|
||||
import { promptRenameWidget } from '@/utils/widgetUtil'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { nodeTypeValidForApp, useAppModeStore } from '@/stores/appModeStore'
|
||||
import { resolveNodeWidget } from '@/utils/litegraphUtil'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { resolveNode } from '@/utils/litegraphUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { HideLayoutFieldKey } from '@/types/widgetTypes'
|
||||
|
||||
@@ -53,15 +53,18 @@ workflowStore.activeWorkflow?.changeTracker?.reset()
|
||||
const arrangeInputs = computed(() =>
|
||||
appModeStore.selectedInputs
|
||||
.map(([nodeId, widgetName]) => {
|
||||
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
|
||||
return node ? { nodeId, widgetName, node, widget } : null
|
||||
const node = resolveNode(nodeId)
|
||||
if (!node) return null
|
||||
const widget = node.widgets?.find((w) => w.name === widgetName)
|
||||
return { nodeId, widgetName, node, widget }
|
||||
})
|
||||
.filter((item): item is NonNullable<typeof item> => item !== null)
|
||||
)
|
||||
|
||||
const inputsWithState = computed(() =>
|
||||
appModeStore.selectedInputs.map(([nodeId, widgetName]) => {
|
||||
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
|
||||
const node = resolveNode(nodeId)
|
||||
const widget = node?.widgets?.find((w) => w.name === widgetName)
|
||||
if (!node || !widget) {
|
||||
return {
|
||||
nodeId,
|
||||
@@ -70,12 +73,15 @@ const inputsWithState = computed(() =>
|
||||
}
|
||||
}
|
||||
|
||||
const input = node.inputs.find((i) => i.widget?.name === widget.name)
|
||||
const rename = input && (() => renameWidget(widget, input))
|
||||
|
||||
return {
|
||||
nodeId,
|
||||
widgetName,
|
||||
label: widget.label,
|
||||
subLabel: node.title,
|
||||
rename: () => promptRenameWidget(widget, node, t)
|
||||
rename
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -86,6 +92,20 @@ const outputsWithState = computed<[NodeId, string][]>(() =>
|
||||
])
|
||||
)
|
||||
|
||||
async function renameWidget(widget: IBaseWidget, input: INodeInputSlot) {
|
||||
const newLabel = await useDialogService().prompt({
|
||||
title: t('g.rename'),
|
||||
message: t('g.enterNewNamePrompt'),
|
||||
defaultValue: widget.label,
|
||||
placeholder: widget.name
|
||||
})
|
||||
if (newLabel === null) return
|
||||
widget.label = newLabel || undefined
|
||||
input.label = newLabel || undefined
|
||||
widget.callback?.(widget.value)
|
||||
useCanvasStore().canvas?.setDirty(true)
|
||||
}
|
||||
|
||||
function getHovered(
|
||||
e: MouseEvent
|
||||
): undefined | [LGraphNode, undefined] | [LGraphNode, IBaseWidget] {
|
||||
@@ -106,7 +126,7 @@ function getHovered(
|
||||
|
||||
function getBounding(nodeId: NodeId, widgetName?: string) {
|
||||
if (settingStore.get('Comfy.VueNodes.Enabled')) return undefined
|
||||
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
|
||||
const node = app.rootGraph.getNodeById(nodeId)
|
||||
if (!node) return
|
||||
|
||||
const titleOffset =
|
||||
@@ -119,6 +139,7 @@ function getBounding(nodeId: NodeId, widgetName?: string) {
|
||||
left: `${node.pos[0]}px`,
|
||||
top: `${node.pos[1] - titleOffset}px`
|
||||
}
|
||||
const widget = node.widgets?.find((w) => w.name === widgetName)
|
||||
if (!widget) return
|
||||
|
||||
const margin = widget instanceof DOMWidgetImpl ? widget.margin : undefined
|
||||
@@ -141,11 +162,7 @@ function handleDown(e: MouseEvent) {
|
||||
}
|
||||
function handleClick(e: MouseEvent) {
|
||||
const [node, widget] = getHovered(e) ?? []
|
||||
if (
|
||||
node?.mode !== LGraphEventMode.ALWAYS ||
|
||||
!nodeTypeValidForApp(node.type) ||
|
||||
node.has_errors
|
||||
)
|
||||
if (node?.mode !== LGraphEventMode.ALWAYS)
|
||||
return canvasInteractions.forwardEventToCanvas(e)
|
||||
|
||||
if (!widget) {
|
||||
@@ -157,16 +174,12 @@ function handleClick(e: MouseEvent) {
|
||||
else appModeStore.selectedOutputs.splice(index, 1)
|
||||
return
|
||||
}
|
||||
if (!isSelectInputsMode.value || widget.options.canvasOnly) return
|
||||
if (!isSelectInputsMode.value) return
|
||||
|
||||
const storeId = isPromotedWidgetView(widget) ? widget.sourceNodeId : node.id
|
||||
const storeName = isPromotedWidgetView(widget)
|
||||
? widget.sourceWidgetName
|
||||
: widget.name
|
||||
const index = appModeStore.selectedInputs.findIndex(
|
||||
([nodeId, widgetName]) => storeId == nodeId && storeName === widgetName
|
||||
([nodeId, widgetName]) => node.id == nodeId && widget.name === widgetName
|
||||
)
|
||||
if (index === -1) appModeStore.selectedInputs.push([storeId, storeName])
|
||||
if (index === -1) appModeStore.selectedInputs.push([node.id, widget.name])
|
||||
else appModeStore.selectedInputs.splice(index, 1)
|
||||
}
|
||||
|
||||
@@ -185,9 +198,7 @@ const renderedOutputs = computed(() => {
|
||||
return canvas
|
||||
.graph!.nodes.filter(
|
||||
(n) =>
|
||||
n.constructor.nodeData?.output_node &&
|
||||
n.mode === LGraphEventMode.ALWAYS &&
|
||||
!n.has_errors
|
||||
n.constructor.nodeData?.output_node && n.mode === LGraphEventMode.ALWAYS
|
||||
)
|
||||
.map(nodeToDisplayTuple)
|
||||
})
|
||||
@@ -249,7 +260,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
<template #label>
|
||||
<div class="flex gap-3">
|
||||
{{ t('nodeHelpPage.inputs') }}
|
||||
<i class="icon-[lucide--info] bg-muted-foreground" />
|
||||
<i class="icon-[lucide--circle-alert] bg-muted-foreground" />
|
||||
</div>
|
||||
</template>
|
||||
<template #empty>
|
||||
@@ -304,7 +315,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
<template #label>
|
||||
<div class="flex gap-3">
|
||||
{{ t('nodeHelpPage.outputs') }}
|
||||
<i class="icon-[lucide--info] bg-muted-foreground" />
|
||||
<i class="icon-[lucide--circle-alert] bg-muted-foreground" />
|
||||
</div>
|
||||
</template>
|
||||
<template #empty>
|
||||
|
||||
@@ -72,7 +72,7 @@ const menuItems = computed(() => [
|
||||
},
|
||||
{
|
||||
label: t('builderMenu.exitAppBuilder'),
|
||||
icon: 'icon-[lucide--x]',
|
||||
icon: 'icon-[lucide--square-pen]',
|
||||
action: onExitBuilder
|
||||
}
|
||||
])
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
@@ -7,13 +7,6 @@ import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const titleTooltip = ref<string | null>(null)
|
||||
const subTitleTooltip = ref<string | null>(null)
|
||||
|
||||
function isTruncated(e: MouseEvent): boolean {
|
||||
const el = e.currentTarget as HTMLElement
|
||||
return el.scrollWidth > el.clientWidth
|
||||
}
|
||||
const { rename, remove } = defineProps<{
|
||||
title: string
|
||||
subTitle?: string
|
||||
@@ -39,28 +32,15 @@ const entries = computed(() => {
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
class="my-2 flex items-center-safe gap-2 rounded-lg p-2"
|
||||
data-testid="builder-io-item"
|
||||
>
|
||||
<div class="drag-handle mr-auto flex min-w-0 flex-col gap-1">
|
||||
<div
|
||||
v-tooltip.left="{ value: titleTooltip, showDelay: 300 }"
|
||||
class="drag-handle truncate text-sm"
|
||||
data-testid="builder-io-item-title"
|
||||
@mouseenter="titleTooltip = isTruncated($event) ? title : null"
|
||||
v-text="title"
|
||||
/>
|
||||
<div
|
||||
v-tooltip.left="{ value: subTitleTooltip, showDelay: 300 }"
|
||||
class="drag-handle truncate text-xs text-muted-foreground"
|
||||
data-testid="builder-io-item-subtitle"
|
||||
@mouseenter="
|
||||
subTitleTooltip = isTruncated($event) ? (subTitle ?? null) : null
|
||||
"
|
||||
v-text="subTitle"
|
||||
/>
|
||||
</div>
|
||||
<div class="my-2 flex items-center-safe gap-2 rounded-lg p-2">
|
||||
<div
|
||||
class="drag-handle mr-auto inline max-w-max min-w-0 flex-[4_1_0%] truncate"
|
||||
v-text="title"
|
||||
/>
|
||||
<div
|
||||
class="drag-handle inline max-w-max min-w-0 flex-[2_1_0%] truncate text-end text-muted-foreground"
|
||||
v-text="subTitle"
|
||||
/>
|
||||
<Popover :entries>
|
||||
<template #button>
|
||||
<Button variant="muted-textonly">
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogRoot,
|
||||
DialogTitle,
|
||||
DialogTrigger
|
||||
} from 'reka-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
defineProps<{ title?: string; to?: string | HTMLElement }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
<template>
|
||||
<DialogRoot v-slot="{ close }">
|
||||
<DialogTrigger as-child>
|
||||
<slot name="button" />
|
||||
</DialogTrigger>
|
||||
<DialogPortal :to>
|
||||
<DialogOverlay
|
||||
class="data-[state=open]:animate-overlayShow fixed inset-0 z-30 bg-black/70"
|
||||
/>
|
||||
<DialogContent
|
||||
v-bind="$attrs"
|
||||
class="data-[state=open]:animate-contentShow fixed top-[50%] left-[50%] z-1700 max-h-[85vh] w-[90vw] max-w-[450px] translate-x-[-50%] translate-y-[-50%] rounded-2xl border border-border-subtle bg-base-background p-2 shadow-sm"
|
||||
>
|
||||
<div
|
||||
v-if="title"
|
||||
class="flex w-full items-center justify-between border-b border-border-subtle px-4"
|
||||
>
|
||||
<DialogTitle class="text-sm">{{ title }}</DialogTitle>
|
||||
<DialogClose as-child>
|
||||
<Button
|
||||
:aria-label="t('g.close')"
|
||||
size="icon"
|
||||
variant="muted-textonly"
|
||||
>
|
||||
<i class="icon-[lucide--x]" />
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
<slot :close />
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</DialogRoot>
|
||||
</template>
|
||||
95
src/components/common/SearchBox.stories.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import type { ComponentExposed } from 'vue-component-type-helpers'
|
||||
interface GenericMeta<C> extends Omit<Meta<C>, 'component'> {
|
||||
component: Omit<ComponentExposed<C>, 'focus'>
|
||||
}
|
||||
|
||||
const meta: GenericMeta<typeof SearchBox> = {
|
||||
title: 'Components/Input/SearchBox',
|
||||
component: SearchBox,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
modelValue: {
|
||||
control: 'text'
|
||||
},
|
||||
placeholder: {
|
||||
control: 'text'
|
||||
},
|
||||
showBorder: {
|
||||
control: 'boolean',
|
||||
description: 'Toggle border prop'
|
||||
},
|
||||
size: {
|
||||
control: 'select',
|
||||
options: ['md', 'lg'],
|
||||
description: 'Size variant of the search box'
|
||||
},
|
||||
'onUpdate:modelValue': { action: 'update:modelValue' },
|
||||
onSearch: { action: 'search' }
|
||||
},
|
||||
args: {
|
||||
modelValue: '',
|
||||
placeholder: 'Search...',
|
||||
showBorder: false,
|
||||
size: 'md'
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: (args) => ({
|
||||
components: { SearchBox },
|
||||
setup() {
|
||||
const searchText = ref('')
|
||||
return { searchText, args }
|
||||
},
|
||||
template: `
|
||||
<div style="max-width: 320px;">
|
||||
<SearchBox v-bind="args" v-model="searchText" />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const WithBorder: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
showBorder: true
|
||||
}
|
||||
}
|
||||
|
||||
export const NoBorder: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
showBorder: false
|
||||
}
|
||||
}
|
||||
|
||||
export const MediumSize: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
size: 'md',
|
||||
showBorder: false
|
||||
}
|
||||
}
|
||||
|
||||
export const LargeSize: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
size: 'lg',
|
||||
showBorder: false
|
||||
}
|
||||
}
|
||||
|
||||
export const LargeSizeWithBorder: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
size: 'lg',
|
||||
showBorder: true
|
||||
}
|
||||
}
|
||||
193
src/components/common/SearchBox.test.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
templateWidgets: {
|
||||
sort: {
|
||||
searchPlaceholder: 'Search...'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('SearchBox', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
const createWrapper = (props = {}) => {
|
||||
return mount(SearchBox, {
|
||||
props: {
|
||||
modelValue: '',
|
||||
...props
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('debounced search functionality', () => {
|
||||
it('should debounce search input by 300ms', async () => {
|
||||
const wrapper = createWrapper()
|
||||
const input = wrapper.find('input')
|
||||
|
||||
// Type search query
|
||||
await input.setValue('test')
|
||||
|
||||
// Model should not update immediately
|
||||
expect(wrapper.emitted('search')).toBeFalsy()
|
||||
|
||||
// Advance timers by 299ms (just before debounce delay)
|
||||
await vi.advanceTimersByTimeAsync(299)
|
||||
await nextTick()
|
||||
expect(wrapper.emitted('search')).toBeFalsy()
|
||||
|
||||
// Advance timers by 1ms more (reaching 300ms)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await nextTick()
|
||||
|
||||
// Model should now be updated
|
||||
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
|
||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['test'])
|
||||
})
|
||||
|
||||
it('should reset debounce timer on each keystroke', async () => {
|
||||
const wrapper = createWrapper()
|
||||
const input = wrapper.find('input')
|
||||
|
||||
// Type first character
|
||||
await input.setValue('t')
|
||||
vi.advanceTimersByTime(200)
|
||||
await nextTick()
|
||||
|
||||
// Type second character (should reset timer)
|
||||
await input.setValue('te')
|
||||
vi.advanceTimersByTime(200)
|
||||
await nextTick()
|
||||
|
||||
// Type third character (should reset timer again)
|
||||
await input.setValue('tes')
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
await nextTick()
|
||||
|
||||
// Should not have emitted yet (only 200ms passed since last keystroke)
|
||||
expect(wrapper.emitted('search')).toBeFalsy()
|
||||
|
||||
// Advance final 100ms to reach 300ms
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await nextTick()
|
||||
|
||||
// Should now emit with final value
|
||||
expect(wrapper.emitted('search')).toBeTruthy()
|
||||
expect(wrapper.emitted('search')?.[0]).toEqual(['tes', []])
|
||||
})
|
||||
|
||||
it('should only emit final value after rapid typing', async () => {
|
||||
const wrapper = createWrapper()
|
||||
const input = wrapper.find('input')
|
||||
|
||||
// Simulate rapid typing
|
||||
const searchTerms = ['s', 'se', 'sea', 'sear', 'searc', 'search']
|
||||
for (const term of searchTerms) {
|
||||
await input.setValue(term)
|
||||
await vi.advanceTimersByTimeAsync(50) // Less than debounce delay
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
// Should not have emitted yet
|
||||
expect(wrapper.emitted('search')).toBeFalsy()
|
||||
|
||||
// Complete the debounce delay
|
||||
await vi.advanceTimersByTimeAsync(350)
|
||||
await nextTick()
|
||||
|
||||
// Should emit only once with final value
|
||||
expect(wrapper.emitted('search')).toHaveLength(1)
|
||||
expect(wrapper.emitted('search')?.[0]).toEqual(['search', []])
|
||||
})
|
||||
|
||||
describe('bidirectional model sync', () => {
|
||||
it('should sync external model changes to internal state', async () => {
|
||||
const wrapper = createWrapper({ modelValue: 'initial' })
|
||||
const input = wrapper.find('input')
|
||||
|
||||
expect(input.element.value).toBe('initial')
|
||||
|
||||
// Update model externally
|
||||
await wrapper.setProps({ modelValue: 'external update' })
|
||||
await nextTick()
|
||||
|
||||
// Internal state should sync
|
||||
expect(input.element.value).toBe('external update')
|
||||
})
|
||||
})
|
||||
|
||||
describe('placeholder', () => {
|
||||
it('should use custom placeholder when provided', () => {
|
||||
const wrapper = createWrapper({ placeholder: 'Custom search...' })
|
||||
const input = wrapper.find('input')
|
||||
|
||||
expect(input.attributes('placeholder')).toBe('Custom search...')
|
||||
expect(input.attributes('aria-label')).toBe('Custom search...')
|
||||
})
|
||||
|
||||
it('should use default placeholder when not provided', () => {
|
||||
const wrapper = createWrapper()
|
||||
const input = wrapper.find('input')
|
||||
|
||||
expect(input.attributes('placeholder')).toBe('Search...')
|
||||
expect(input.attributes('aria-label')).toBe('Search...')
|
||||
})
|
||||
})
|
||||
|
||||
describe('autofocus', () => {
|
||||
it('should focus input when autofocus is true', async () => {
|
||||
const wrapper = createWrapper({ autofocus: true })
|
||||
await nextTick()
|
||||
|
||||
const input = wrapper.find('input')
|
||||
const inputElement = input.element as HTMLInputElement
|
||||
|
||||
// Note: In JSDOM, focus() doesn't actually set document.activeElement
|
||||
// We can only verify that the focus method exists and doesn't throw
|
||||
expect(inputElement.focus).toBeDefined()
|
||||
})
|
||||
|
||||
it('should not autofocus when autofocus is false', () => {
|
||||
const wrapper = createWrapper({ autofocus: false })
|
||||
const input = wrapper.find('input')
|
||||
|
||||
expect(document.activeElement).not.toBe(input.element)
|
||||
})
|
||||
})
|
||||
|
||||
describe('click to focus', () => {
|
||||
it('should focus input when wrapper is clicked', async () => {
|
||||
const wrapper = createWrapper()
|
||||
const wrapperDiv = wrapper.find('[class*="flex"]')
|
||||
|
||||
await wrapperDiv.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
// Input should receive focus
|
||||
const input = wrapper.find('input').element as HTMLInputElement
|
||||
expect(input.focus).toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
139
src/components/common/SearchBox.vue
Normal file
@@ -0,0 +1,139 @@
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'relative flex w-full cursor-text items-center gap-2 bg-comfy-input text-comfy-input-foreground',
|
||||
customClass,
|
||||
wrapperStyle
|
||||
)
|
||||
"
|
||||
>
|
||||
<InputText
|
||||
ref="inputRef"
|
||||
v-model="modelValue"
|
||||
:placeholder
|
||||
:autofocus
|
||||
unstyled
|
||||
:class="
|
||||
cn(
|
||||
'absolute inset-0 size-full border-none bg-transparent text-sm outline-none',
|
||||
isLarge ? 'pl-11' : 'pl-8'
|
||||
)
|
||||
"
|
||||
:aria-label="placeholder"
|
||||
/>
|
||||
<Button
|
||||
v-if="filterIcon"
|
||||
size="icon"
|
||||
variant="textonly"
|
||||
class="filter-button absolute inset-y-0 right-0 m-0 p-0"
|
||||
@click="$emit('showFilter', $event)"
|
||||
>
|
||||
<i :class="filterIcon" />
|
||||
</Button>
|
||||
<InputIcon v-if="!modelValue" :class="icon" />
|
||||
<Button
|
||||
v-if="modelValue"
|
||||
:class="cn('clear-button absolute', isLarge ? 'left-2' : 'left-0')"
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
@click="modelValue = ''"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="filters?.length" class="search-filters flex flex-wrap gap-2 pt-2">
|
||||
<SearchFilterChip
|
||||
v-for="filter in filters"
|
||||
:key="filter.id"
|
||||
:text="filter.text"
|
||||
:badge="filter.badge"
|
||||
:badge-class="filter.badgeClass"
|
||||
@remove="$emit('removeFilter', filter)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" generic="TFilter extends SearchFilter">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import InputIcon from 'primevue/inputicon'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
import type { SearchFilter } from './SearchFilterChip.vue'
|
||||
import SearchFilterChip from './SearchFilterChip.vue'
|
||||
|
||||
const {
|
||||
placeholder = 'Search...',
|
||||
icon = 'pi pi-search',
|
||||
debounceTime = 300,
|
||||
filterIcon,
|
||||
filters = [],
|
||||
autofocus = false,
|
||||
showBorder = false,
|
||||
size = 'md',
|
||||
class: customClass
|
||||
} = defineProps<{
|
||||
placeholder?: string
|
||||
icon?: string
|
||||
debounceTime?: number
|
||||
filterIcon?: string
|
||||
filters?: TFilter[]
|
||||
autofocus?: boolean
|
||||
showBorder?: boolean
|
||||
size?: 'md' | 'lg'
|
||||
class?: string
|
||||
}>()
|
||||
|
||||
const isLarge = computed(() => size === 'lg')
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'search', value: string, filters: TFilter[]): void
|
||||
(e: 'showFilter', event: Event): void
|
||||
(e: 'removeFilter', filter: TFilter): void
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<string>({ required: true })
|
||||
|
||||
const inputRef = ref()
|
||||
|
||||
defineExpose({
|
||||
focus: () => {
|
||||
inputRef.value?.$el?.focus()
|
||||
}
|
||||
})
|
||||
|
||||
watchDebounced(
|
||||
modelValue,
|
||||
(value: string) => {
|
||||
emit('search', value, filters)
|
||||
},
|
||||
{ debounce: debounceTime }
|
||||
)
|
||||
|
||||
const wrapperStyle = computed(() => {
|
||||
if (showBorder) {
|
||||
return cn(
|
||||
'box-border rounded-sm border border-solid border-border-default p-2',
|
||||
isLarge.value ? 'h-10' : 'h-8'
|
||||
)
|
||||
}
|
||||
|
||||
// Size-specific classes matching button sizes for consistency
|
||||
const sizeClasses = {
|
||||
md: 'h-8 px-2 py-1.5', // Matches button sm size
|
||||
lg: 'h-10 px-4 py-2' // Matches button md size
|
||||
}[size]
|
||||
|
||||
return cn('rounded-lg', sizeClasses)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.p-inputtext) {
|
||||
--p-form-field-padding-x: 0.625rem;
|
||||
}
|
||||
</style>
|
||||
90
src/components/common/SearchBoxV2.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import SearchBoxV2 from './SearchBoxV2.vue'
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
watchDebounced: vi.fn(() => vi.fn())
|
||||
}))
|
||||
|
||||
describe('SearchBoxV2', () => {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: {
|
||||
clear: 'Clear',
|
||||
searchPlaceholder: 'Search...'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function mountComponent(props = {}) {
|
||||
return mount(SearchBoxV2, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
ComboboxRoot: {
|
||||
template: '<div><slot /></div>'
|
||||
},
|
||||
ComboboxAnchor: {
|
||||
template: '<div><slot /></div>'
|
||||
},
|
||||
ComboboxInput: {
|
||||
template:
|
||||
'<input :placeholder="placeholder" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
|
||||
props: ['placeholder', 'modelValue', 'autoFocus']
|
||||
}
|
||||
}
|
||||
},
|
||||
props: {
|
||||
modelValue: '',
|
||||
...props
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('uses i18n placeholder when no placeholder prop provided', () => {
|
||||
const wrapper = mountComponent()
|
||||
const input = wrapper.find('input')
|
||||
expect(input.attributes('placeholder')).toBe('Search...')
|
||||
})
|
||||
|
||||
it('uses custom placeholder when provided', () => {
|
||||
const wrapper = mountComponent({
|
||||
placeholder: 'Custom placeholder'
|
||||
})
|
||||
const input = wrapper.find('input')
|
||||
expect(input.attributes('placeholder')).toBe('Custom placeholder')
|
||||
})
|
||||
|
||||
it('shows search icon when search term is empty', () => {
|
||||
const wrapper = mountComponent({ modelValue: '' })
|
||||
expect(wrapper.find('i.icon-\\[lucide--search\\]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('shows clear button when search term is not empty', () => {
|
||||
const wrapper = mountComponent({ modelValue: 'test' })
|
||||
expect(wrapper.find('button').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('clears search term when clear button is clicked', async () => {
|
||||
const wrapper = mountComponent({ modelValue: 'test' })
|
||||
const clearButton = wrapper.find('button')
|
||||
await clearButton.trigger('click')
|
||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([''])
|
||||
})
|
||||
|
||||
it('applies large size classes when size is lg', () => {
|
||||
const wrapper = mountComponent({ size: 'lg' })
|
||||
expect(wrapper.html()).toContain('size-5')
|
||||
})
|
||||
|
||||
it('applies medium size classes when size is md', () => {
|
||||
const wrapper = mountComponent({ size: 'md' })
|
||||
expect(wrapper.html()).toContain('size-4')
|
||||
})
|
||||
})
|
||||
117
src/components/common/SearchBoxV2.vue
Normal file
@@ -0,0 +1,117 @@
|
||||
<template>
|
||||
<div class="flex flex-auto flex-col gap-2">
|
||||
<ComboboxRoot :ignore-filter="true" :open="false">
|
||||
<ComboboxAnchor
|
||||
:class="
|
||||
cn(
|
||||
'relative flex w-full cursor-text items-center',
|
||||
'rounded-lg bg-comfy-input text-comfy-input-foreground',
|
||||
showBorder &&
|
||||
'box-border border border-solid border-border-default',
|
||||
sizeClasses,
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<i
|
||||
v-if="!searchTerm"
|
||||
:class="cn('pointer-events-none absolute left-4', icon, iconClass)"
|
||||
/>
|
||||
<Button
|
||||
v-else
|
||||
class="absolute left-2"
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
:aria-label="$t('g.clear')"
|
||||
@click="clearSearch"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</Button>
|
||||
|
||||
<ComboboxInput
|
||||
ref="inputRef"
|
||||
v-model="searchTerm"
|
||||
:class="
|
||||
cn(
|
||||
'size-full border-none bg-transparent text-sm outline-none',
|
||||
inputPadding
|
||||
)
|
||||
"
|
||||
:placeholder="placeholderText"
|
||||
:auto-focus="autofocus"
|
||||
/>
|
||||
</ComboboxAnchor>
|
||||
</ComboboxRoot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import { ComboboxAnchor, ComboboxInput, ComboboxRoot } from 'reka-ui'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const {
|
||||
placeholder,
|
||||
icon = 'icon-[lucide--search]',
|
||||
debounceTime = 300,
|
||||
autofocus = false,
|
||||
showBorder = false,
|
||||
size = 'md',
|
||||
class: className
|
||||
} = defineProps<{
|
||||
placeholder?: string
|
||||
icon?: string
|
||||
debounceTime?: number
|
||||
autofocus?: boolean
|
||||
showBorder?: boolean
|
||||
size?: 'md' | 'lg'
|
||||
class?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
search: [value: string]
|
||||
}>()
|
||||
|
||||
const searchTerm = defineModel<string>({ required: true })
|
||||
|
||||
const inputRef = ref<InstanceType<typeof ComboboxInput> | null>(null)
|
||||
|
||||
defineExpose({
|
||||
focus: () => {
|
||||
inputRef.value?.$el?.focus()
|
||||
}
|
||||
})
|
||||
|
||||
const isLarge = computed(() => size === 'lg')
|
||||
const placeholderText = computed(
|
||||
() => placeholder ?? t('g.searchPlaceholder', { subject: '' })
|
||||
)
|
||||
|
||||
const sizeClasses = computed(() => {
|
||||
if (showBorder) {
|
||||
return isLarge.value ? 'h-10 p-2' : 'h-8 p-2'
|
||||
}
|
||||
return isLarge.value ? 'h-12 px-4 py-2' : 'h-10 px-4 py-2'
|
||||
})
|
||||
|
||||
const iconClass = computed(() => (isLarge.value ? 'size-5' : 'size-4'))
|
||||
const inputPadding = computed(() => (isLarge.value ? 'pl-8' : 'pl-6'))
|
||||
|
||||
function clearSearch() {
|
||||
searchTerm.value = ''
|
||||
}
|
||||
|
||||
watchDebounced(
|
||||
searchTerm,
|
||||
(value: string) => {
|
||||
emit('search', value)
|
||||
},
|
||||
{ debounce: debounceTime }
|
||||
)
|
||||
</script>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<Avatar
|
||||
class="aspect-square bg-interface-panel-selected-surface"
|
||||
class="bg-interface-panel-selected-surface"
|
||||
:image="photoUrl ?? undefined"
|
||||
:icon="hasAvatar ? undefined : 'icon-[lucide--user]'"
|
||||
:pt:icon:class="{ 'size-4': !hasAvatar }"
|
||||
|
||||
@@ -155,93 +155,6 @@ describe('VirtualGrid', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('emits approach-end for single-column list when scrolled near bottom', async () => {
|
||||
const items = createItems(50)
|
||||
mockedWidth.value = 400
|
||||
mockedHeight.value = 600
|
||||
mockedScrollY.value = 0
|
||||
|
||||
const wrapper = mount(VirtualGrid<TestItem>, {
|
||||
props: {
|
||||
items,
|
||||
gridStyle: {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'minmax(0, 1fr)'
|
||||
},
|
||||
defaultItemHeight: 48,
|
||||
defaultItemWidth: 200,
|
||||
maxColumns: 1,
|
||||
bufferRows: 1
|
||||
},
|
||||
slots: {
|
||||
item: `<template #item="{ item }">
|
||||
<div class="test-item">{{ item.name }}</div>
|
||||
</template>`
|
||||
},
|
||||
attachTo: document.body
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.emitted('approach-end')).toBeUndefined()
|
||||
|
||||
// Scroll near the end: 50 items * 48px = 2400px total
|
||||
// viewRows = ceil(600/48) = 13, buffer = 1
|
||||
// Need toCol >= items.length - cols*bufferRows = 50 - 1 = 49
|
||||
// toCol = (offsetRows + bufferRows + viewRows) * cols
|
||||
// offsetRows = floor(scrollY / 48)
|
||||
// Need (offsetRows + 1 + 13) * 1 >= 49 → offsetRows >= 35
|
||||
// scrollY = 35 * 48 = 1680
|
||||
mockedScrollY.value = 1680
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.emitted('approach-end')).toBeDefined()
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('does not emit approach-end without maxColumns in single-column layout', async () => {
|
||||
// Demonstrates the bug: without maxColumns=1, cols is calculated
|
||||
// from width/itemWidth (400/200 = 2), causing incorrect row math
|
||||
const items = createItems(50)
|
||||
mockedWidth.value = 400
|
||||
mockedHeight.value = 600
|
||||
mockedScrollY.value = 0
|
||||
|
||||
const wrapper = mount(VirtualGrid<TestItem>, {
|
||||
props: {
|
||||
items,
|
||||
gridStyle: {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'minmax(0, 1fr)'
|
||||
},
|
||||
defaultItemHeight: 48,
|
||||
defaultItemWidth: 200,
|
||||
// No maxColumns — cols will be floor(400/200) = 2
|
||||
bufferRows: 1
|
||||
},
|
||||
slots: {
|
||||
item: `<template #item="{ item }">
|
||||
<div class="test-item">{{ item.name }}</div>
|
||||
</template>`
|
||||
},
|
||||
attachTo: document.body
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
// Same scroll position as the passing test
|
||||
mockedScrollY.value = 1680
|
||||
await nextTick()
|
||||
|
||||
// With cols=2, toCol = (35+1+13)*2 = 98, which exceeds items.length (50)
|
||||
// remainingCol = 50-98 = -48, hasMoreToRender = false → isNearEnd = false
|
||||
// The approach-end never fires at the correct scroll position
|
||||
expect(wrapper.emitted('approach-end')).toBeUndefined()
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('forces cols to maxColumns when maxColumns is finite', async () => {
|
||||
mockedWidth.value = 100
|
||||
mockedHeight.value = 200
|
||||
|
||||
@@ -3,8 +3,13 @@
|
||||
ref="svgRef"
|
||||
viewBox="-0.04 -0.04 1.08 1.08"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
class="aspect-square w-full cursor-crosshair rounded-[5px] bg-node-component-surface"
|
||||
@pointerdown.stop="handleSvgPointerDown"
|
||||
:class="
|
||||
cn(
|
||||
'aspect-square w-full rounded-[5px] bg-node-component-surface',
|
||||
disabled ? 'cursor-default' : 'cursor-crosshair'
|
||||
)
|
||||
"
|
||||
@pointerdown.stop="onSvgPointerDown"
|
||||
@contextmenu.prevent.stop
|
||||
>
|
||||
<line
|
||||
@@ -56,20 +61,23 @@
|
||||
:stroke="curveColor"
|
||||
stroke-width="0.008"
|
||||
stroke-linecap="round"
|
||||
:opacity="disabled ? 0.5 : 1"
|
||||
/>
|
||||
|
||||
<circle
|
||||
v-for="(point, i) in modelValue"
|
||||
:key="i"
|
||||
:cx="point[0]"
|
||||
:cy="1 - point[1]"
|
||||
r="0.02"
|
||||
:fill="curveColor"
|
||||
stroke="white"
|
||||
stroke-width="0.004"
|
||||
class="cursor-grab"
|
||||
@pointerdown.stop="startDrag(i, $event)"
|
||||
/>
|
||||
<template v-if="!disabled">
|
||||
<circle
|
||||
v-for="(point, i) in modelValue"
|
||||
:key="i"
|
||||
:cx="point[0]"
|
||||
:cy="1 - point[1]"
|
||||
r="0.02"
|
||||
:fill="curveColor"
|
||||
stroke="white"
|
||||
stroke-width="0.004"
|
||||
class="cursor-grab"
|
||||
@pointerdown.stop="startDrag(i, $event)"
|
||||
/>
|
||||
</template>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
@@ -77,14 +85,20 @@
|
||||
import { computed, useTemplateRef } from 'vue'
|
||||
|
||||
import { useCurveEditor } from '@/composables/useCurveEditor'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import type { CurvePoint } from './types'
|
||||
|
||||
import { histogramToPath } from './curveUtils'
|
||||
|
||||
const { curveColor = 'white', histogram } = defineProps<{
|
||||
const {
|
||||
curveColor = 'white',
|
||||
histogram,
|
||||
disabled = false
|
||||
} = defineProps<{
|
||||
curveColor?: string
|
||||
histogram?: Uint32Array | null
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<CurvePoint[]>({
|
||||
@@ -98,6 +112,10 @@ const { curvePath, handleSvgPointerDown, startDrag } = useCurveEditor({
|
||||
modelValue
|
||||
})
|
||||
|
||||
function onSvgPointerDown(e: PointerEvent) {
|
||||
if (!disabled) handleSvgPointerDown(e)
|
||||
}
|
||||
|
||||
const histogramPath = computed(() =>
|
||||
histogram ? histogramToPath(histogram) : ''
|
||||
)
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
<template>
|
||||
<CurveEditor v-model="modelValue" />
|
||||
<CurveEditor
|
||||
:model-value="effectivePoints"
|
||||
:disabled="isDisabled"
|
||||
@update:model-value="modelValue = $event"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { CurvePoint } from './types'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useLinkedWidgetValue } from '@/composables/useLinkedWidgetValue'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
import CurveEditor from './CurveEditor.vue'
|
||||
import type { CurvePoint } from './types'
|
||||
|
||||
const { widget, nodeId } = defineProps<{
|
||||
widget: SimplifiedWidget
|
||||
nodeId: string
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<CurvePoint[]>({
|
||||
default: () => [
|
||||
@@ -13,4 +26,14 @@ const modelValue = defineModel<CurvePoint[]>({
|
||||
[1, 1]
|
||||
]
|
||||
})
|
||||
|
||||
const isDisabled = computed(() => !!widget.options?.disabled)
|
||||
|
||||
const upstreamValue = useLinkedWidgetValue(nodeId, widget.name, 'curve')
|
||||
|
||||
const effectivePoints = computed(() =>
|
||||
isDisabled.value && upstreamValue.value
|
||||
? (upstreamValue.value as CurvePoint[])
|
||||
: modelValue.value
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -14,10 +14,10 @@
|
||||
</template>
|
||||
|
||||
<template #header>
|
||||
<SearchInput
|
||||
<SearchBox
|
||||
v-model="searchQuery"
|
||||
size="lg"
|
||||
class="max-w-96 flex-1"
|
||||
class="max-w-[384px]"
|
||||
autofocus
|
||||
/>
|
||||
</template>
|
||||
@@ -389,7 +389,7 @@ import CardBottom from '@/components/card/CardBottom.vue'
|
||||
import CardContainer from '@/components/card/CardContainer.vue'
|
||||
import CardTop from '@/components/card/CardTop.vue'
|
||||
import SquareChip from '@/components/chip/SquareChip.vue'
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import MultiSelect from '@/components/input/MultiSelect.vue'
|
||||
import SingleSelect from '@/components/input/SingleSelect.vue'
|
||||
import AudioThumbnail from '@/components/templates/thumbnails/AudioThumbnail.vue'
|
||||
|
||||
@@ -138,7 +138,8 @@ onMounted(async () => {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('toastMessages.failedToFetchLogs')
|
||||
detail: t('toastMessages.failedToFetchLogs'),
|
||||
life: 5000
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -275,7 +275,8 @@ async function handleBuy() {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('credits.topUp.purchaseError'),
|
||||
detail: t('credits.topUp.purchaseErrorDetail', { error: errorMessage })
|
||||
detail: t('credits.topUp.purchaseErrorDetail', { error: errorMessage }),
|
||||
life: 5000
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="keybinding-panel flex flex-col gap-2">
|
||||
<SearchInput
|
||||
<SearchBox
|
||||
v-model="filters['global'].value"
|
||||
:placeholder="$t('g.searchPlaceholder', { subject: $t('g.keybindings') })"
|
||||
/>
|
||||
@@ -155,7 +155,7 @@ import { useToast } from 'primevue/usetoast'
|
||||
import { computed, ref, watchEffect } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { KeyComboImpl } from '@/platform/keybindings/keyCombo'
|
||||
import { KeybindingImpl } from '@/platform/keybindings/keybinding'
|
||||
|
||||
@@ -98,7 +98,8 @@ async function onConfirmCancel() {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('subscription.cancelDialog.failed'),
|
||||
detail: error instanceof Error ? error.message : t('g.unknownError')
|
||||
detail: error instanceof Error ? error.message : t('g.unknownError'),
|
||||
life: 5000
|
||||
})
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
|
||||
@@ -50,9 +50,7 @@
|
||||
{{ t('g.dismiss') }}
|
||||
</Button>
|
||||
<Button variant="secondary" size="lg" @click="seeErrors">
|
||||
{{
|
||||
appMode ? t('linearMode.error.goto') : t('errorOverlay.seeErrors')
|
||||
}}
|
||||
{{ t('errorOverlay.seeErrors') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -71,8 +69,6 @@ import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useErrorGroups } from '@/components/rightSidePanel/errors/useErrorGroups'
|
||||
|
||||
defineProps<{ appMode?: boolean }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
@@ -98,7 +94,6 @@ function dismiss() {
|
||||
}
|
||||
|
||||
function seeErrors() {
|
||||
canvasStore.linearMode = false
|
||||
if (canvasStore.canvas) {
|
||||
canvasStore.canvas.deselectAll()
|
||||
canvasStore.updateSelectedItems()
|
||||
|
||||
@@ -579,7 +579,8 @@ const onUpdateComfyUI = async (): Promise<void> => {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: error.value || t('helpCenter.updateComfyUIFailed')
|
||||
detail: error.value || t('helpCenter.updateComfyUIFailed'),
|
||||
life: 5000
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -596,7 +597,8 @@ const onUpdateComfyUI = async (): Promise<void> => {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: err instanceof Error ? err.message : t('g.unknownError')
|
||||
detail: err instanceof Error ? err.message : t('g.unknownError'),
|
||||
life: 5000
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,11 @@
|
||||
v-if="isHelpCenterVisible"
|
||||
class="help-center-popup"
|
||||
:class="{
|
||||
'sidebar-left': sidebarLocation === 'left',
|
||||
'sidebar-right': sidebarLocation === 'right',
|
||||
'sidebar-left':
|
||||
triggerLocation === 'sidebar' && sidebarLocation === 'left',
|
||||
'sidebar-right':
|
||||
triggerLocation === 'sidebar' && sidebarLocation === 'right',
|
||||
'topbar-right': triggerLocation === 'topbar',
|
||||
'small-sidebar': isSmall
|
||||
}"
|
||||
>
|
||||
@@ -60,6 +63,7 @@ const { isSmall = false } = defineProps<{
|
||||
|
||||
const {
|
||||
isHelpCenterVisible,
|
||||
triggerLocation,
|
||||
sidebarLocation,
|
||||
closeHelpCenter,
|
||||
handleWhatsNewDismissed
|
||||
@@ -97,6 +101,25 @@ const {
|
||||
right: 1rem;
|
||||
}
|
||||
|
||||
.help-center-popup.topbar-right {
|
||||
top: 2rem;
|
||||
right: 1rem;
|
||||
bottom: auto;
|
||||
animation: slideInDown 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideInDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
|
||||
@@ -93,12 +93,13 @@
|
||||
#header
|
||||
>
|
||||
<div class="flex flex-col px-2 pt-2 pb-0">
|
||||
<SearchInput
|
||||
<SearchBox
|
||||
v-if="showSearchBox"
|
||||
v-model="searchQuery"
|
||||
:class="showSelectedCount || showClearButton ? 'mb-2' : ''"
|
||||
:placeholder="searchPlaceholder"
|
||||
size="sm"
|
||||
:show-order="true"
|
||||
:show-border="true"
|
||||
:place-holder="searchPlaceholder"
|
||||
/>
|
||||
<div
|
||||
v-if="showSelectedCount || showClearButton"
|
||||
@@ -181,7 +182,7 @@ import MultiSelect from 'primevue/multiselect'
|
||||
import { computed, useAttrs } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { usePopoverSizing } from '@/composables/usePopoverSizing'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
@@ -72,12 +72,12 @@
|
||||
/>
|
||||
</div>
|
||||
<SliderControl
|
||||
v-model="brushSizeSliderValue"
|
||||
v-model="brushSize"
|
||||
class="flex-1"
|
||||
label=""
|
||||
:min="0"
|
||||
:max="1"
|
||||
:step="0.001"
|
||||
:min="1"
|
||||
:max="250"
|
||||
:step="1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -182,26 +182,6 @@ const brushSize = computed({
|
||||
set: (value: number) => store.setBrushSize(value)
|
||||
})
|
||||
|
||||
const rawSliderValue = ref<number | null>(null)
|
||||
|
||||
const brushSizeSliderValue = computed({
|
||||
get: () => {
|
||||
if (rawSliderValue.value !== null) {
|
||||
const cachedSize = Math.round(Math.pow(250, rawSliderValue.value))
|
||||
if (cachedSize === brushSize.value) {
|
||||
return rawSliderValue.value
|
||||
}
|
||||
}
|
||||
|
||||
return Math.log(brushSize.value) / Math.log(250)
|
||||
},
|
||||
set: (value: number) => {
|
||||
rawSliderValue.value = value
|
||||
const size = Math.round(Math.pow(250, value))
|
||||
store.setBrushSize(size)
|
||||
}
|
||||
})
|
||||
|
||||
const brushOpacity = computed({
|
||||
get: () => store.brushSettings.opacity,
|
||||
set: (value: number) => store.setBrushOpacity(value)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<SearchInput
|
||||
<SearchBox
|
||||
v-if="showSearch"
|
||||
:model-value="searchQuery"
|
||||
class="min-w-0 flex-1"
|
||||
@@ -116,7 +116,7 @@
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { jobSortModes } from '@/composables/queue/useJobList'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { computed, reactive, ref, toValue, watch } from 'vue'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import Fuse from 'fuse.js'
|
||||
import type { IFuseOptions } from 'fuse.js'
|
||||
|
||||
@@ -227,7 +227,7 @@ function searchErrorGroups(groups: ErrorGroup[], query: string) {
|
||||
}
|
||||
|
||||
export function useErrorGroups(
|
||||
searchQuery: MaybeRefOrGetter<string>,
|
||||
searchQuery: Ref<string>,
|
||||
t: (key: string) => string
|
||||
) {
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
@@ -584,7 +584,7 @@ export function useErrorGroups(
|
||||
})
|
||||
|
||||
const filteredGroups = computed<ErrorGroup[]>(() => {
|
||||
const query = toValue(searchQuery).trim()
|
||||
const query = searchQuery.value.trim()
|
||||
return searchErrorGroups(tabErrorGroups.value, query)
|
||||
})
|
||||
|
||||
|
||||
@@ -15,9 +15,10 @@ 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'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
|
||||
import { getWidgetDefaultValue, promptWidgetLabel } from '@/utils/widgetUtil'
|
||||
import { getWidgetDefaultValue } from '@/utils/widgetUtil'
|
||||
import type { WidgetValue } from '@/utils/widgetUtil'
|
||||
|
||||
const {
|
||||
@@ -41,6 +42,7 @@ const label = defineModel<string>('label', { required: true })
|
||||
const canvasStore = useCanvasStore()
|
||||
const favoritedWidgetsStore = useFavoritedWidgetsStore()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const dialogService = useDialogService()
|
||||
const { t } = useI18n()
|
||||
|
||||
const hasParents = computed(() => parents?.length > 0)
|
||||
@@ -65,8 +67,15 @@ const isCurrentValueDefault = computed(() => {
|
||||
})
|
||||
|
||||
async function handleRename() {
|
||||
const newLabel = await promptWidgetLabel(widget, t)
|
||||
if (newLabel !== null) label.value = newLabel
|
||||
const newLabel = await dialogService.prompt({
|
||||
title: t('g.rename'),
|
||||
message: t('g.enterNewNamePrompt'),
|
||||
defaultValue: widget.label,
|
||||
placeholder: widget.name
|
||||
})
|
||||
|
||||
if (newLabel === null) return
|
||||
label.value = newLabel
|
||||
}
|
||||
|
||||
function handleHideInput() {
|
||||
|
||||
@@ -11,13 +11,12 @@
|
||||
}"
|
||||
@click="onLogoMenuClick($event)"
|
||||
>
|
||||
<div class="flex items-center gap-0.5">
|
||||
<div class="flex size-8 items-center justify-center rounded-lg bg-black">
|
||||
<ComfyLogo
|
||||
alt="ComfyUI Logo"
|
||||
class="comfyui-logo h-[18px] w-[18px]"
|
||||
class="comfyui-logo h-[18px] w-[18px] text-white"
|
||||
mode="fill"
|
||||
/>
|
||||
<i class="icon-[lucide--chevron-down] size-3 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
v-if="userStore.isMultiUserServer"
|
||||
:is-small="isSmall"
|
||||
/>
|
||||
<SidebarHelpCenterIcon :is-small="isSmall" />
|
||||
<SidebarHelpCenterIcon v-if="!isIntegratedTabBar" :is-small="isSmall" />
|
||||
<SidebarBottomPanelToggleButton v-if="!isCloud" :is-small="isSmall" />
|
||||
<SidebarShortcutsToggleButton :is-small="isSmall" />
|
||||
<SidebarSettingsButton :is-small="isSmall" />
|
||||
@@ -95,6 +95,9 @@ const sidebarLocation = computed<'left' | 'right'>(() =>
|
||||
settingStore.get('Comfy.Sidebar.Location')
|
||||
)
|
||||
const sidebarStyle = computed(() => settingStore.get('Comfy.Sidebar.Style'))
|
||||
const isIntegratedTabBar = computed(
|
||||
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
|
||||
)
|
||||
const isConnected = computed(
|
||||
() =>
|
||||
selectedTab.value ||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
:icon-badge="shouldShowRedDot ? '•' : ''"
|
||||
badge-class="-top-1 -right-1 min-w-2 w-2 h-2 p-0 rounded-full text-[0px] bg-[#ff3b30]"
|
||||
:is-small="isSmall"
|
||||
@click="toggleHelpCenter()"
|
||||
@click="toggleHelpCenter"
|
||||
/>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -18,8 +18,6 @@
|
||||
class="flex-1"
|
||||
:items="assetItems"
|
||||
:grid-style="listGridStyle"
|
||||
:max-columns="1"
|
||||
:default-item-height="48"
|
||||
@approach-end="emit('approach-end')"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
@@ -35,7 +33,7 @@
|
||||
tabindex="0"
|
||||
:aria-label="
|
||||
t('assetBrowser.ariaLabel.assetCard', {
|
||||
name: getAssetDisplayName(item.asset),
|
||||
name: item.asset.name,
|
||||
type: getAssetMediaType(item.asset)
|
||||
})
|
||||
"
|
||||
@@ -46,7 +44,7 @@
|
||||
)
|
||||
"
|
||||
:preview-url="getAssetPreviewUrl(item.asset)"
|
||||
:preview-alt="getAssetDisplayName(item.asset)"
|
||||
:preview-alt="item.asset.name"
|
||||
:icon-name="iconForMediaType(getAssetMediaType(item.asset))"
|
||||
:is-video-preview="isVideoAsset(item.asset)"
|
||||
:primary-text="getAssetPrimaryText(item.asset)"
|
||||
@@ -135,12 +133,8 @@ const listGridStyle = {
|
||||
gap: '0.5rem'
|
||||
}
|
||||
|
||||
function getAssetDisplayName(asset: AssetItem): string {
|
||||
return asset.display_name || asset.name
|
||||
}
|
||||
|
||||
function getAssetPrimaryText(asset: AssetItem): string {
|
||||
return truncateFilename(getAssetDisplayName(asset))
|
||||
return truncateFilename(asset.name)
|
||||
}
|
||||
|
||||
function getAssetMediaType(asset: AssetItem) {
|
||||
|
||||
@@ -569,7 +569,7 @@ const handleZoomClick = (asset: AssetItem) => {
|
||||
const dialogStore = useDialogStore()
|
||||
dialogStore.showDialog({
|
||||
key: 'asset-3d-viewer',
|
||||
title: asset.display_name || asset.name,
|
||||
title: asset.name,
|
||||
component: Load3dViewerContent,
|
||||
props: {
|
||||
modelUrl: asset.preview_url || ''
|
||||
@@ -615,7 +615,8 @@ const enterFolderView = async (asset: AssetItem) => {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('sideToolbar.folderView.errorSummary'),
|
||||
detail: t('sideToolbar.folderView.errorDetail')
|
||||
detail: t('sideToolbar.folderView.errorDetail'),
|
||||
life: 5000
|
||||
})
|
||||
exitFolderView()
|
||||
}
|
||||
@@ -661,7 +662,8 @@ const copyJobId = async () => {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('mediaAsset.jobIdToast.error'),
|
||||
detail: t('mediaAsset.jobIdToast.jobIdCopyFailed')
|
||||
detail: t('mediaAsset.jobIdToast.jobIdCopyFailed'),
|
||||
life: 3000
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
</template>
|
||||
<template #header>
|
||||
<div class="px-2 2xl:px-4">
|
||||
<SearchInput
|
||||
<SearchBox
|
||||
ref="searchBoxRef"
|
||||
v-model:model-value="searchQuery"
|
||||
class="workflows-search-box"
|
||||
@@ -146,7 +146,7 @@ import { computed, nextTick, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import TextDivider from '@/components/common/TextDivider.vue'
|
||||
import TreeExplorer from '@/components/common/TreeExplorer.vue'
|
||||
import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue'
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
</template>
|
||||
<template #header>
|
||||
<div class="px-2 2xl:px-4">
|
||||
<SearchInput
|
||||
<SearchBox
|
||||
ref="searchBoxRef"
|
||||
v-model:model-value="searchQuery"
|
||||
:placeholder="
|
||||
@@ -56,7 +56,7 @@
|
||||
import { Divider } from 'primevue'
|
||||
import { computed, nextTick, onMounted, ref, toRef, watch } from 'vue'
|
||||
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import TreeExplorer from '@/components/common/TreeExplorer.vue'
|
||||
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
|
||||
import ElectronDownloadItems from '@/components/sidebar/tabs/modelLibrary/ElectronDownloadItems.vue'
|
||||
|
||||
@@ -86,40 +86,18 @@
|
||||
</template>
|
||||
<template #header>
|
||||
<div class="px-2 2xl:px-4">
|
||||
<div class="flex items-center gap-1">
|
||||
<SearchInput
|
||||
ref="searchBoxRef"
|
||||
v-model="searchQuery"
|
||||
data-testid="node-library-search"
|
||||
class="node-lib-search-box"
|
||||
:placeholder="
|
||||
$t('g.searchPlaceholder', { subject: $t('g.nodes') })
|
||||
"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
class="filter-button shrink-0"
|
||||
:aria-label="$t('g.filter')"
|
||||
@click="(e: Event) => searchFilter?.toggle(e)"
|
||||
>
|
||||
<i class="pi pi-filter" />
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
v-if="filters?.length"
|
||||
class="search-filters flex flex-wrap gap-2 pt-2"
|
||||
>
|
||||
<SearchFilterChip
|
||||
v-for="filter in filters"
|
||||
:key="filter.id"
|
||||
:text="filter.text"
|
||||
:badge="filter.badge"
|
||||
:badge-class="filter.badgeClass"
|
||||
@remove="onRemoveFilter(filter)"
|
||||
/>
|
||||
</div>
|
||||
<SearchBox
|
||||
ref="searchBoxRef"
|
||||
v-model:model-value="searchQuery"
|
||||
data-testid="node-library-search"
|
||||
class="node-lib-search-box"
|
||||
:placeholder="$t('g.searchPlaceholder', { subject: $t('g.nodes') })"
|
||||
filter-icon="pi pi-filter"
|
||||
:filters
|
||||
@search="handleSearch"
|
||||
@show-filter="($event) => searchFilter?.toggle($event)"
|
||||
@remove-filter="onRemoveFilter"
|
||||
/>
|
||||
|
||||
<Popover ref="searchFilter" class="ml-[-13px]">
|
||||
<NodeSearchFilter @add-filter="onAddFilter" />
|
||||
@@ -177,9 +155,8 @@ import {
|
||||
} from 'vue'
|
||||
|
||||
import { resolveEssentialsDisplayName } from '@/constants/essentialsDisplayNames'
|
||||
import SearchFilterChip from '@/components/common/SearchFilterChip.vue'
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import type { SearchFilter } from '@/components/common/SearchFilterChip.vue'
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import TreeExplorer from '@/components/common/TreeExplorer.vue'
|
||||
import NodePreview from '@/components/node/NodePreview.vue'
|
||||
import NodeSearchFilter from '@/components/searchbox/NodeSearchFilter.vue'
|
||||
|
||||
@@ -69,7 +69,7 @@ vi.mock('./nodeLibrary/NodeDragPreview.vue', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/search-input/SearchInput.vue', () => ({
|
||||
vi.mock('@/components/common/SearchBoxV2.vue', () => ({
|
||||
default: {
|
||||
name: 'SearchBox',
|
||||
template: '<input data-testid="search-box" />',
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<template #header>
|
||||
<TabsRoot v-model="selectedTab" class="flex flex-col">
|
||||
<div class="flex items-center justify-between gap-2 px-2 pb-2 2xl:px-4">
|
||||
<SearchInput
|
||||
<SearchBox
|
||||
ref="searchBoxRef"
|
||||
v-model="searchQuery"
|
||||
:placeholder="$t('g.search') + '...'"
|
||||
@@ -180,7 +180,7 @@ import { computed, nextTick, onMounted, ref, watchEffect } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { resolveEssentialsDisplayName } from '@/constants/essentialsDisplayNames'
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import SearchBox from '@/components/common/SearchBoxV2.vue'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
|
||||
import { usePerTabState } from '@/composables/usePerTabState'
|
||||
@@ -253,7 +253,7 @@ const filterOptions = ref<Record<NodeCategoryId, boolean>>({
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const searchBoxRef = ref<InstanceType<typeof SearchInput> | null>(null)
|
||||
const searchBoxRef = ref<InstanceType<typeof SearchBox> | null>(null)
|
||||
const searchQuery = ref('')
|
||||
const expandedKeysByTab = ref<Record<TabId, string[]>>({
|
||||
essentials: [],
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<UserAvatar
|
||||
v-else
|
||||
:photo-url="photoURL"
|
||||
:class="compact && 'h-full w-auto'"
|
||||
:class="compact && 'size-full'"
|
||||
/>
|
||||
|
||||
<i v-if="showArrow" class="icon-[lucide--chevron-down] size-4 px-1" />
|
||||
|
||||
21
src/components/topbar/TopMenuHelpButton.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<Button
|
||||
class="comfy-help-center-btn relative text-base-foreground"
|
||||
variant="textonly"
|
||||
@click="toggleHelpCenter"
|
||||
>
|
||||
<div class="not-md:hidden">{{ $t('menu.helpAndFeedback') }}</div>
|
||||
<i class="ml-0.5 icon-[lucide--circle-help]" />
|
||||
<span
|
||||
v-if="shouldShowRedDot"
|
||||
class="absolute top-[7px] right-[7px] size-1.5 rounded-full bg-[#ff3b30]"
|
||||
/>
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useHelpCenter } from '@/composables/useHelpCenter'
|
||||
|
||||
const { shouldShowRedDot, toggleHelpCenter } = useHelpCenter('topbar')
|
||||
</script>
|
||||
@@ -10,9 +10,8 @@
|
||||
@mouseup="handleMouseUp"
|
||||
@click="handleClick"
|
||||
>
|
||||
<i v-if="isBuilderState" class="bg-text-subtle icon-[lucide--hammer]" />
|
||||
<i
|
||||
v-else-if="workflowOption.workflow.initialMode === 'app'"
|
||||
v-if="workflowOption.workflow.initialMode === 'app'"
|
||||
class="icon-[lucide--panels-top-left] bg-primary-background"
|
||||
/>
|
||||
<span
|
||||
@@ -150,11 +149,6 @@ const shouldShowStatusIndicator = computed(() => {
|
||||
return false
|
||||
})
|
||||
|
||||
const isBuilderState = computed(() => {
|
||||
const currentMode = props.workflowOption.workflow.activeMode
|
||||
return typeof currentMode === 'string' && currentMode.startsWith('builder:')
|
||||
})
|
||||
|
||||
const isActiveTab = computed(() => {
|
||||
return workflowStore.activeWorkflow?.key === props.workflowOption.workflow.key
|
||||
})
|
||||
|
||||
@@ -83,18 +83,13 @@
|
||||
v-if="isIntegratedTabBar"
|
||||
class="ml-auto flex shrink-0 items-center gap-2 px-2"
|
||||
>
|
||||
<Button
|
||||
v-if="isCloud || isNightly"
|
||||
v-tooltip="{ value: $t('actionbar.feedbackTooltip'), showDelay: 300 }"
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
class="shrink-0 text-base-foreground"
|
||||
:aria-label="$t('actionbar.feedback')"
|
||||
@click="openFeedback"
|
||||
>
|
||||
<i class="icon-[lucide--message-square-text]" />
|
||||
</Button>
|
||||
<CurrentUserButton v-if="showCurrentUser" compact class="shrink-0 p-1" />
|
||||
<TopMenuHelpButton />
|
||||
<CurrentUserButton
|
||||
v-if="isLoggedIn"
|
||||
:show-arrow="false"
|
||||
compact
|
||||
class="grid w-10 shrink-0 p-1"
|
||||
/>
|
||||
<LoginButton v-else-if="isDesktop" class="p-1" />
|
||||
</div>
|
||||
<div v-if="isDesktop" class="window-actions-spacer app-drag shrink-0" />
|
||||
@@ -107,20 +102,21 @@ import ScrollPanel from 'primevue/scrollpanel'
|
||||
import SelectButton from 'primevue/selectbutton'
|
||||
import { computed, nextTick, onUpdated, ref, watch } from 'vue'
|
||||
import type { WatchStopHandle } from 'vue'
|
||||
|
||||
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
|
||||
import LoginButton from '@/components/topbar/LoginButton.vue'
|
||||
import TopMenuHelpButton from '@/components/topbar/TopMenuHelpButton.vue'
|
||||
import WorkflowTab from '@/components/topbar/WorkflowTab.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { buildFeedbackUrl } from '@/platform/support/config'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
|
||||
import { isDesktop } from '@/platform/distribution/types'
|
||||
import { whileMouseDown } from '@/utils/mouseDownUtil'
|
||||
|
||||
import WorkflowOverflowMenu from './WorkflowOverflowMenu.vue'
|
||||
@@ -142,14 +138,8 @@ const commandStore = useCommandStore()
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
|
||||
const isIntegratedTabBar = computed(
|
||||
() => settingStore.get('Comfy.UI.TabBarLayout') !== 'Legacy'
|
||||
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
|
||||
)
|
||||
const showCurrentUser = computed(() => isCloud || isLoggedIn.value)
|
||||
|
||||
const feedbackUrl = buildFeedbackUrl()
|
||||
function openFeedback() {
|
||||
window.open(feedbackUrl, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const showOverflowArrows = ref(false)
|
||||
|
||||
@@ -24,7 +24,7 @@ function handleWheel(e: WheelEvent) {
|
||||
|
||||
let dragging = false
|
||||
function handleDown(e: PointerEvent) {
|
||||
if (e.button !== 0 && e.button !== 1) return
|
||||
if (e.button !== 0) return
|
||||
|
||||
const zoomPaneEl = zoomPane.value
|
||||
if (!zoomPaneEl) return
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, watch } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import SearchInput from './SearchInput.vue'
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
watchDebounced: vi.fn((source, cb, opts) => {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
return watch(source, (val: string) => {
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = setTimeout(() => cb(val), opts?.debounce ?? 300)
|
||||
})
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: {
|
||||
clear: 'Clear',
|
||||
searchPlaceholder: 'Search...'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('SearchInput', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
function mountComponent(props = {}) {
|
||||
return mount(SearchInput, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
ComboboxRoot: {
|
||||
template: '<div><slot /></div>'
|
||||
},
|
||||
ComboboxAnchor: {
|
||||
template: '<div @click="$emit(\'click\')"><slot /></div>',
|
||||
emits: ['click']
|
||||
},
|
||||
ComboboxInput: {
|
||||
template:
|
||||
'<input :placeholder="placeholder" :value="modelValue" :autofocus="autoFocus || undefined" @input="$emit(\'update:modelValue\', $event.target.value)" />',
|
||||
props: ['placeholder', 'modelValue', 'autoFocus']
|
||||
}
|
||||
}
|
||||
},
|
||||
props: {
|
||||
modelValue: '',
|
||||
...props
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('debounced search', () => {
|
||||
it('should debounce search input by 300ms', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const input = wrapper.find('input')
|
||||
|
||||
await input.setValue('test')
|
||||
|
||||
expect(wrapper.emitted('search')).toBeFalsy()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(299)
|
||||
await nextTick()
|
||||
expect(wrapper.emitted('search')).toBeFalsy()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.emitted('search')).toEqual([['test']])
|
||||
})
|
||||
|
||||
it('should reset debounce timer on each keystroke', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const input = wrapper.find('input')
|
||||
|
||||
await input.setValue('t')
|
||||
vi.advanceTimersByTime(200)
|
||||
await nextTick()
|
||||
|
||||
await input.setValue('te')
|
||||
vi.advanceTimersByTime(200)
|
||||
await nextTick()
|
||||
|
||||
await input.setValue('tes')
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.emitted('search')).toBeFalsy()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.emitted('search')).toBeTruthy()
|
||||
expect(wrapper.emitted('search')?.[0]).toEqual(['tes'])
|
||||
})
|
||||
|
||||
it('should only emit final value after rapid typing', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const input = wrapper.find('input')
|
||||
|
||||
const searchTerms = ['s', 'se', 'sea', 'sear', 'searc', 'search']
|
||||
for (const term of searchTerms) {
|
||||
await input.setValue(term)
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.emitted('search')).toBeFalsy()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(350)
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.emitted('search')).toHaveLength(1)
|
||||
expect(wrapper.emitted('search')?.[0]).toEqual(['search'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('model sync', () => {
|
||||
it('should sync external model changes to internal state', async () => {
|
||||
const wrapper = mountComponent({ modelValue: 'initial' })
|
||||
const input = wrapper.find('input')
|
||||
|
||||
expect(input.element.value).toBe('initial')
|
||||
|
||||
await wrapper.setProps({ modelValue: 'external update' })
|
||||
await nextTick()
|
||||
|
||||
expect(input.element.value).toBe('external update')
|
||||
})
|
||||
})
|
||||
|
||||
describe('placeholder', () => {
|
||||
it('should use custom placeholder when provided', () => {
|
||||
const wrapper = mountComponent({ placeholder: 'Custom search...' })
|
||||
const input = wrapper.find('input')
|
||||
|
||||
expect(input.attributes('placeholder')).toBe('Custom search...')
|
||||
})
|
||||
|
||||
it('should use i18n placeholder when not provided', () => {
|
||||
const wrapper = mountComponent()
|
||||
const input = wrapper.find('input')
|
||||
|
||||
expect(input.attributes('placeholder')).toBe('Search...')
|
||||
})
|
||||
})
|
||||
|
||||
describe('autofocus', () => {
|
||||
it('should pass autofocus prop to ComboboxInput', () => {
|
||||
const wrapper = mountComponent({ autofocus: true })
|
||||
const input = wrapper.find('input')
|
||||
expect(input.attributes('autofocus')).toBeDefined()
|
||||
})
|
||||
|
||||
it('should not autofocus by default', () => {
|
||||
const wrapper = mountComponent()
|
||||
const input = wrapper.find('input')
|
||||
expect(input.attributes('autofocus')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('focus method', () => {
|
||||
it('should expose focus method via ref', () => {
|
||||
const wrapper = mountComponent()
|
||||
expect(wrapper.vm.focus).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('clear button', () => {
|
||||
it('shows search icon when value is empty', () => {
|
||||
const wrapper = mountComponent({ modelValue: '' })
|
||||
expect(wrapper.find('button[aria-label="Clear"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows clear button when value is not empty', () => {
|
||||
const wrapper = mountComponent({ modelValue: 'test' })
|
||||
expect(wrapper.find('button[aria-label="Clear"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('clears value when clear button is clicked', async () => {
|
||||
const wrapper = mountComponent({ modelValue: 'test' })
|
||||
const clearButton = wrapper.find('button')
|
||||
await clearButton.trigger('click')
|
||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([''])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,15 +1,11 @@
|
||||
<template>
|
||||
<ComboboxRoot
|
||||
:ignore-filter="true"
|
||||
:open="false"
|
||||
:disabled="disabled"
|
||||
:class="className"
|
||||
>
|
||||
<ComboboxRoot :ignore-filter="true" :open="false" :disabled="disabled">
|
||||
<ComboboxAnchor
|
||||
:class="
|
||||
cn(
|
||||
searchInputVariants({ size }),
|
||||
disabled && 'pointer-events-none opacity-50'
|
||||
disabled && 'pointer-events-none opacity-50',
|
||||
className
|
||||
)
|
||||
"
|
||||
@click="focus"
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import type {
|
||||
ComponentPropsAndSlots,
|
||||
Meta,
|
||||
StoryObj
|
||||
} from '@storybook/vue3-vite'
|
||||
import { computed, ref, toRefs } from 'vue'
|
||||
|
||||
import Slider from './Slider.vue'
|
||||
|
||||
interface StoryArgs extends ComponentPropsAndSlots<typeof Slider> {
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
const meta: Meta<StoryArgs> = {
|
||||
title: 'Components/Slider',
|
||||
component: Slider,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'centered' },
|
||||
argTypes: {
|
||||
min: { control: 'number' },
|
||||
max: { control: 'number' },
|
||||
step: { control: 'number' },
|
||||
disabled: { control: 'boolean' }
|
||||
},
|
||||
args: {
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
disabled: false
|
||||
},
|
||||
decorators: [
|
||||
(story) => ({
|
||||
components: { story },
|
||||
template: '<div class="w-72"><story /></div>'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: (args) => ({
|
||||
components: { Slider },
|
||||
setup() {
|
||||
const { min, max, step, disabled } = toRefs(args)
|
||||
const value = ref([36])
|
||||
const display = computed(() => value.value[0])
|
||||
return { value, display, min, max, step, disabled }
|
||||
},
|
||||
template: `
|
||||
<div class="flex items-center gap-4 rounded-lg bg-component-node-widget-background px-3 py-2">
|
||||
<Slider v-model="value" :min :max :step :disabled class="flex-1" />
|
||||
<span class="w-14 shrink-0 text-right text-xs text-component-node-foreground">{{ display }}</span>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: { disabled: true },
|
||||
render: (args) => ({
|
||||
components: { Slider },
|
||||
setup() {
|
||||
const { min, max, step, disabled } = toRefs(args)
|
||||
const value = ref([36])
|
||||
const display = computed(() => value.value[0])
|
||||
return { value, display, min, max, step, disabled }
|
||||
},
|
||||
template: `
|
||||
<div class="flex items-center gap-4 rounded-lg bg-component-node-widget-background px-3 py-2">
|
||||
<Slider v-model="value" :min :max :step :disabled class="flex-1" />
|
||||
<span class="w-14 shrink-0 text-right text-xs text-component-node-foreground">{{ display }}</span>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
</template>
|
||||
|
||||
<template #header>
|
||||
<SearchInput v-model="searchQuery" size="lg" class="max-w-96 flex-1" />
|
||||
<SearchBox v-model="searchQuery" size="lg" class="max-w-[384px]" />
|
||||
</template>
|
||||
|
||||
<template #header-right-area>
|
||||
@@ -130,7 +130,7 @@ import CardBottom from '@/components/card/CardBottom.vue'
|
||||
import CardContainer from '@/components/card/CardContainer.vue'
|
||||
import CardTop from '@/components/card/CardTop.vue'
|
||||
import SquareChip from '@/components/chip/SquareChip.vue'
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import MultiSelect from '@/components/input/MultiSelect.vue'
|
||||
import SingleSelect from '@/components/input/SingleSelect.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
@@ -8,7 +8,7 @@ import CardContainer from '@/components/card/CardContainer.vue'
|
||||
import CardTop from '@/components/card/CardTop.vue'
|
||||
import SquareChip from '@/components/chip/SquareChip.vue'
|
||||
import MultiSelect from '@/components/input/MultiSelect.vue'
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import SingleSelect from '@/components/input/SingleSelect.vue'
|
||||
import type { NavGroupData, NavItemData } from '@/types/navTypes'
|
||||
import { OnCloseKey } from '@/types/widgetTypes'
|
||||
@@ -68,7 +68,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
|
||||
components: {
|
||||
BaseModalLayout,
|
||||
LeftSidePanel,
|
||||
SearchInput,
|
||||
SearchBox,
|
||||
MultiSelect,
|
||||
SingleSelect,
|
||||
Button,
|
||||
@@ -186,7 +186,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
|
||||
|
||||
<!-- Header -->
|
||||
<template v-if="args.hasHeader" #header>
|
||||
<SearchInput
|
||||
<SearchBox
|
||||
class="max-w-[384px]"
|
||||
size="lg"
|
||||
:modelValue="searchQuery"
|
||||
@@ -309,7 +309,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
|
||||
|
||||
<!-- Header -->
|
||||
<template v-if="args.hasHeader" #header>
|
||||
<SearchInput
|
||||
<SearchBox
|
||||
class="max-w-[384px]"
|
||||
size="lg"
|
||||
:modelValue="searchQuery"
|
||||
|
||||
@@ -397,7 +397,8 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
if (app.canvas.empty) {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('toastMessages.emptyCanvas')
|
||||
summary: t('toastMessages.emptyCanvas'),
|
||||
life: 3000
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -556,7 +557,8 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('toastMessages.nothingToQueue'),
|
||||
detail: t('toastMessages.pleaseSelectOutputNodes')
|
||||
detail: t('toastMessages.pleaseSelectOutputNodes'),
|
||||
life: 3000
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -569,7 +571,8 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('toastMessages.failedToQueue'),
|
||||
detail: t('toastMessages.failedExecutionPathResolution')
|
||||
detail: t('toastMessages.failedExecutionPathResolution'),
|
||||
life: 3000
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -599,7 +602,8 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('toastMessages.nothingToGroup'),
|
||||
detail: t('toastMessages.pleaseSelectNodesToGroup')
|
||||
detail: t('toastMessages.pleaseSelectNodesToGroup'),
|
||||
life: 3000
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -958,7 +962,8 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('manager.notAvailable')
|
||||
detail: t('manager.notAvailable'),
|
||||
life: 3000
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -1043,7 +1048,8 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('toastMessages.cannotCreateSubgraph'),
|
||||
detail: t('toastMessages.failedToConvertToSubgraph')
|
||||
detail: t('toastMessages.failedToConvertToSubgraph'),
|
||||
life: 3000
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -1252,7 +1258,8 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
summary: t('g.error'),
|
||||
detail: t('g.commandProhibited', {
|
||||
command: 'Comfy.Memory.UnloadModels'
|
||||
})
|
||||
}),
|
||||
life: 3000
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -1271,7 +1278,8 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
summary: t('g.error'),
|
||||
detail: t('g.commandProhibited', {
|
||||
command: 'Comfy.Memory.UnloadModelsAndExecutionCache'
|
||||
})
|
||||
}),
|
||||
life: 3000
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -5,15 +5,19 @@ import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
|
||||
import { useHelpCenterStore } from '@/stores/helpCenterStore'
|
||||
import type { HelpCenterTriggerLocation } from '@/stores/helpCenterStore'
|
||||
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
|
||||
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
|
||||
import { useNodeConflictDialog } from '@/workbench/extensions/manager/composables/useNodeConflictDialog'
|
||||
|
||||
export function useHelpCenter() {
|
||||
export function useHelpCenter(
|
||||
triggerFrom: HelpCenterTriggerLocation = 'sidebar'
|
||||
) {
|
||||
const settingStore = useSettingStore()
|
||||
const releaseStore = useReleaseStore()
|
||||
const helpCenterStore = useHelpCenterStore()
|
||||
const { isVisible: isHelpCenterVisible } = storeToRefs(helpCenterStore)
|
||||
const { isVisible: isHelpCenterVisible, triggerLocation } =
|
||||
storeToRefs(helpCenterStore)
|
||||
const { shouldShowRedDot: showReleaseRedDot } = storeToRefs(releaseStore)
|
||||
|
||||
const conflictDetection = useConflictDetection()
|
||||
@@ -38,9 +42,9 @@ export function useHelpCenter() {
|
||||
*/
|
||||
const toggleHelpCenter = () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'sidebar_help_center_toggled'
|
||||
button_id: `${triggerFrom}_help_center_toggled`
|
||||
})
|
||||
helpCenterStore.toggle()
|
||||
helpCenterStore.toggle(triggerFrom)
|
||||
}
|
||||
|
||||
const closeHelpCenter = () => {
|
||||
@@ -86,6 +90,7 @@ export function useHelpCenter() {
|
||||
|
||||
return {
|
||||
isHelpCenterVisible,
|
||||
triggerLocation,
|
||||
shouldShowRedDot,
|
||||
sidebarLocation,
|
||||
toggleHelpCenter,
|
||||
|
||||
50
src/composables/useLinkedWidgetValue.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { ComputedRef } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
/**
|
||||
* Returns the upstream widget value for a connected input slot.
|
||||
* Matches by output slot name first, then falls back to widget type.
|
||||
*/
|
||||
export function useLinkedWidgetValue(
|
||||
nodeId: string,
|
||||
widgetName: string,
|
||||
widgetType: string
|
||||
): ComputedRef<unknown | undefined> {
|
||||
const canvasStore = useCanvasStore()
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
|
||||
return computed(() => {
|
||||
const graph = canvasStore.canvas?.graph
|
||||
if (!graph) return undefined
|
||||
|
||||
const node = graph.getNodeById(nodeId)
|
||||
if (!node?.inputs) return undefined
|
||||
|
||||
const slot = node.inputs.find((s) => s.name === widgetName)
|
||||
if (!slot?.link) return undefined
|
||||
|
||||
const link = graph.getLink(slot.link)
|
||||
if (!link) return undefined
|
||||
|
||||
const graphId = graph.rootGraph.id
|
||||
const originNode = graph.getNodeById(link.origin_id)
|
||||
const outputName = originNode?.outputs?.[link.origin_slot]?.name
|
||||
|
||||
const upstreamWidgets = widgetValueStore.getNodeWidgets(
|
||||
graphId,
|
||||
link.origin_id
|
||||
)
|
||||
|
||||
const matched = outputName
|
||||
? upstreamWidgets.find((w) => w.name === outputName)
|
||||
: undefined
|
||||
|
||||
if (matched) return matched.value
|
||||
|
||||
const typeMatches = upstreamWidgets.filter((w) => w.type === widgetType)
|
||||
return typeMatches.length === 1 ? typeMatches[0].value : undefined
|
||||
})
|
||||
}
|
||||
@@ -81,7 +81,8 @@ function getParentNodes(): SubgraphNode[] {
|
||||
useToastStore().add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('subgraphStore.promoteOutsideSubgraph')
|
||||
detail: t('subgraphStore.promoteOutsideSubgraph'),
|
||||
life: 2000
|
||||
})
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({})
|
||||
}))
|
||||
vi.mock('@/stores/domWidgetStore', () => ({
|
||||
useDomWidgetStore: () => ({ widgetStates: new Map() })
|
||||
}))
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ updatePreviews: () => ({}) })
|
||||
}))
|
||||
|
||||
function createOuterSubgraphSetup(inputNames: string[]): {
|
||||
outerSubgraph: Subgraph
|
||||
outerSubgraphNode: SubgraphNode
|
||||
} {
|
||||
const outerSubgraph = createTestSubgraph({
|
||||
inputs: inputNames.map((name) => ({ name, type: '*' }))
|
||||
})
|
||||
const outerSubgraphNode = createTestSubgraphNode(outerSubgraph, { id: 1 })
|
||||
return { outerSubgraph, outerSubgraphNode }
|
||||
}
|
||||
|
||||
function addLinkedNestedSubgraphNode(
|
||||
outerSubgraph: Subgraph,
|
||||
inputName: string,
|
||||
linkedInputName: string,
|
||||
options: { widget?: string } = {}
|
||||
): { innerSubgraphNode: SubgraphNode } {
|
||||
const innerSubgraph = createTestSubgraph({
|
||||
inputs: [{ name: linkedInputName, type: '*' }]
|
||||
})
|
||||
const innerSubgraphNode = createTestSubgraphNode(innerSubgraph, { id: 819 })
|
||||
outerSubgraph.add(innerSubgraphNode)
|
||||
|
||||
const inputSlot = outerSubgraph.inputNode.slots.find(
|
||||
(slot) => slot.name === inputName
|
||||
)
|
||||
if (!inputSlot) throw new Error(`Missing subgraph input slot: ${inputName}`)
|
||||
|
||||
const input = innerSubgraphNode.addInput(linkedInputName, '*')
|
||||
if (options.widget) {
|
||||
innerSubgraphNode.addWidget('number', options.widget, 0, () => undefined)
|
||||
input.widget = { name: options.widget }
|
||||
}
|
||||
inputSlot.connect(input, innerSubgraphNode)
|
||||
|
||||
if (input.link == null) {
|
||||
throw new Error(`Expected link to be created for input ${linkedInputName}`)
|
||||
}
|
||||
|
||||
return { innerSubgraphNode }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('resolveSubgraphInputTarget', () => {
|
||||
test('returns target for widget-backed input on nested SubgraphNode', () => {
|
||||
const { outerSubgraph, outerSubgraphNode } = createOuterSubgraphSetup([
|
||||
'width'
|
||||
])
|
||||
addLinkedNestedSubgraphNode(outerSubgraph, 'width', 'width', {
|
||||
widget: 'width'
|
||||
})
|
||||
|
||||
const result = resolveSubgraphInputTarget(outerSubgraphNode, 'width')
|
||||
|
||||
expect(result).toMatchObject({
|
||||
nodeId: '819',
|
||||
widgetName: 'width'
|
||||
})
|
||||
})
|
||||
|
||||
test('returns undefined for non-widget input on nested SubgraphNode', () => {
|
||||
const { outerSubgraph, outerSubgraphNode } = createOuterSubgraphSetup([
|
||||
'audio'
|
||||
])
|
||||
addLinkedNestedSubgraphNode(outerSubgraph, 'audio', 'audio')
|
||||
|
||||
const result = resolveSubgraphInputTarget(outerSubgraphNode, 'audio')
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
test('resolves widget inputs but not non-widget inputs on the same nested SubgraphNode', () => {
|
||||
const { outerSubgraph, outerSubgraphNode } = createOuterSubgraphSetup([
|
||||
'width',
|
||||
'audio'
|
||||
])
|
||||
addLinkedNestedSubgraphNode(outerSubgraph, 'width', 'width', {
|
||||
widget: 'width'
|
||||
})
|
||||
addLinkedNestedSubgraphNode(outerSubgraph, 'audio', 'audio')
|
||||
|
||||
expect(
|
||||
resolveSubgraphInputTarget(outerSubgraphNode, 'width')
|
||||
).toMatchObject({
|
||||
nodeId: '819',
|
||||
widgetName: 'width'
|
||||
})
|
||||
expect(
|
||||
resolveSubgraphInputTarget(outerSubgraphNode, 'audio')
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
test('returns target for widget-backed input on plain interior node', () => {
|
||||
const { outerSubgraph, outerSubgraphNode } = createOuterSubgraphSetup([
|
||||
'seed'
|
||||
])
|
||||
|
||||
const inputSlot = outerSubgraph.inputNode.slots.find(
|
||||
(slot) => slot.name === 'seed'
|
||||
)!
|
||||
const node = new LGraphNode('Interior-seed')
|
||||
node.id = 42
|
||||
const input = node.addInput('seed_input', '*')
|
||||
node.addWidget('number', 'seed', 0, () => undefined)
|
||||
input.widget = { name: 'seed' }
|
||||
outerSubgraph.add(node)
|
||||
inputSlot.connect(input, node)
|
||||
|
||||
const result = resolveSubgraphInputTarget(outerSubgraphNode, 'seed')
|
||||
|
||||
expect(result).toMatchObject({
|
||||
nodeId: '42',
|
||||
widgetName: 'seed'
|
||||
})
|
||||
})
|
||||
|
||||
test('returns undefined for non-widget input on plain interior node', () => {
|
||||
const { outerSubgraph, outerSubgraphNode } = createOuterSubgraphSetup([
|
||||
'image'
|
||||
])
|
||||
|
||||
const inputSlot = outerSubgraph.inputNode.slots.find(
|
||||
(slot) => slot.name === 'image'
|
||||
)!
|
||||
const node = new LGraphNode('Interior-image')
|
||||
const input = node.addInput('image_input', '*')
|
||||
outerSubgraph.add(node)
|
||||
inputSlot.connect(input, node)
|
||||
|
||||
const result = resolveSubgraphInputTarget(outerSubgraphNode, 'image')
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -16,9 +16,6 @@ export function resolveSubgraphInputTarget(
|
||||
inputName,
|
||||
({ inputNode, targetInput, getTargetWidget }) => {
|
||||
if (inputNode.isSubgraphNode()) {
|
||||
const targetWidget = getTargetWidget()
|
||||
if (!targetWidget) return undefined
|
||||
|
||||
return {
|
||||
nodeId: String(inputNode.id),
|
||||
widgetName: targetInput.name
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { computed, watch } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||
import { t } from '@/i18n'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
import type { TopbarBadge } from '@/types/comfy'
|
||||
|
||||
@@ -18,20 +17,16 @@ const badges = computed<TopbarBadge[]>(() => {
|
||||
tooltip: alert.tooltip
|
||||
})
|
||||
}
|
||||
|
||||
// Always add cloud badge last (furthest right)
|
||||
result.push({
|
||||
icon: 'icon-[lucide--cloud]',
|
||||
text: 'Comfy Cloud'
|
||||
})
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
watch(
|
||||
() => canvasStore.canvas,
|
||||
(canvas) => {
|
||||
if (canvas) {
|
||||
canvas.info_text = t('g.comfyCloud')
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.Cloud.Badges',
|
||||
get topbarBadges() {
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import { t } from '@/i18n'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { buildFeedbackUrl } from '@/platform/support/config'
|
||||
import { getDistribution, ZENDESK_FIELDS } from '@/platform/support/config'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
import type { ActionBarButton } from '@/types/comfy'
|
||||
|
||||
const feedbackUrl = buildFeedbackUrl()
|
||||
const ZENDESK_BASE_URL = 'https://support.comfy.org/hc/en-us/requests/new'
|
||||
const ZENDESK_FEEDBACK_FORM_ID = '43066738713236'
|
||||
|
||||
const distribution = getDistribution()
|
||||
const params = new URLSearchParams({
|
||||
ticket_form_id: ZENDESK_FEEDBACK_FORM_ID,
|
||||
[ZENDESK_FIELDS.DISTRIBUTION]: distribution
|
||||
})
|
||||
const feedbackUrl = `${ZENDESK_BASE_URL}?${params.toString()}`
|
||||
|
||||
const buttons: ActionBarButton[] = [
|
||||
{
|
||||
icon: 'icon-[lucide--message-square-text]',
|
||||
icon: 'icon-[lucide--message-circle-question-mark]',
|
||||
label: t('actionbar.feedback'),
|
||||
tooltip: t('actionbar.feedbackTooltip'),
|
||||
onClick: () => {
|
||||
@@ -18,10 +25,6 @@ const buttons: ActionBarButton[] = [
|
||||
]
|
||||
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.FeedbackButton',
|
||||
get actionBarButtons() {
|
||||
return useSettingStore().get('Comfy.UI.TabBarLayout') === 'Legacy'
|
||||
? buttons
|
||||
: []
|
||||
}
|
||||
name: 'Comfy.Cloud.FeedbackButton',
|
||||
actionBarButtons: buttons
|
||||
})
|
||||
|
||||
@@ -204,7 +204,8 @@ import { electronAPI as getElectronAPI } from '@/utils/envUtil'
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('desktopUpdate.errorInstallingUpdate')
|
||||
detail: t('desktopUpdate.errorInstallingUpdate'),
|
||||
life: 10_000
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -213,7 +214,8 @@ import { electronAPI as getElectronAPI } from '@/utils/envUtil'
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('desktopUpdate.errorCheckingUpdate')
|
||||
detail: t('desktopUpdate.errorCheckingUpdate'),
|
||||
life: 10_000
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { NodeOutputWith } from '@/schemas/apiSchema'
|
||||
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
@@ -28,6 +29,7 @@ useExtensionService().registerExtension({
|
||||
|
||||
const toUrl = (record: Record<string, string>) => {
|
||||
const params = new URLSearchParams(record)
|
||||
appendCloudResParam(params, record.filename)
|
||||
return api.apiURL(`/view?${params}${rand}`)
|
||||
}
|
||||
|
||||
|
||||
@@ -559,8 +559,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
clear_background_color: string
|
||||
render_only_selected: boolean
|
||||
show_info: boolean
|
||||
/** Additional text appended to the canvas info overlay (rendered by {@link renderInfo}). */
|
||||
info_text: string | undefined
|
||||
allow_dragcanvas: boolean
|
||||
allow_dragnodes: boolean
|
||||
allow_interaction: boolean
|
||||
@@ -5182,10 +5180,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
* draws some useful stats in the corner of the canvas
|
||||
*/
|
||||
renderInfo(ctx: CanvasRenderingContext2D, x: number, y: number): void {
|
||||
const lineHeight = 13
|
||||
const lineCount = (this.graph ? 5 : 1) + (this.info_text ? 1 : 0)
|
||||
x = x || 10
|
||||
y = y || this.canvas.offsetHeight - (lineCount + 1) * lineHeight
|
||||
y = y || this.canvas.offsetHeight - 80
|
||||
|
||||
ctx.save()
|
||||
ctx.translate(x, y)
|
||||
@@ -5193,26 +5189,18 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
ctx.font = `10px ${LiteGraph.DEFAULT_FONT}`
|
||||
ctx.fillStyle = '#888'
|
||||
ctx.textAlign = 'left'
|
||||
let line = 1
|
||||
if (this.graph) {
|
||||
ctx.fillText(
|
||||
`T: ${this.graph.globaltime.toFixed(2)}s`,
|
||||
5,
|
||||
lineHeight * line++
|
||||
)
|
||||
ctx.fillText(`I: ${this.graph.iteration}`, 5, lineHeight * line++)
|
||||
ctx.fillText(`T: ${this.graph.globaltime.toFixed(2)}s`, 5, 13 * 1)
|
||||
ctx.fillText(`I: ${this.graph.iteration}`, 5, 13 * 2)
|
||||
ctx.fillText(
|
||||
`N: ${this.graph._nodes.length} [${this.visible_nodes.length}]`,
|
||||
5,
|
||||
lineHeight * line++
|
||||
13 * 3
|
||||
)
|
||||
ctx.fillText(`V: ${this.graph._version}`, 5, lineHeight * line++)
|
||||
ctx.fillText(`FPS:${this.fps.toFixed(2)}`, 5, lineHeight * line++)
|
||||
ctx.fillText(`V: ${this.graph._version}`, 5, 13 * 4)
|
||||
ctx.fillText(`FPS:${this.fps.toFixed(2)}`, 5, 13 * 5)
|
||||
} else {
|
||||
ctx.fillText('No graph selected', 5, lineHeight * line++)
|
||||
}
|
||||
if (this.info_text) {
|
||||
ctx.fillText(this.info_text, 5, lineHeight * line++)
|
||||
ctx.fillText('No graph selected', 5, 13 * 1)
|
||||
}
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
@@ -952,7 +952,6 @@
|
||||
"collapseAll": "طي الكل",
|
||||
"color": "اللون",
|
||||
"comfy": "Comfy",
|
||||
"comfyCloud": "Comfy Cloud",
|
||||
"comfyOrgLogoAlt": "شعار ComfyOrg",
|
||||
"comingSoon": "قريباً",
|
||||
"command": "أمر",
|
||||
@@ -1460,14 +1459,12 @@
|
||||
"unknownWidget": "عنصر الواجهة غير مرئي"
|
||||
},
|
||||
"cancelThisRun": "إلغاء هذا التشغيل",
|
||||
"deleteAllAssets": "حذف جميع الأصول من هذه الجلسة",
|
||||
"downloadAll": "تنزيل الكل",
|
||||
"dragAndDropImage": "اسحب وأسقط صورة",
|
||||
"emptyWorkflowExplanation": "سير العمل الخاص بك فارغ. تحتاج إلى بعض العقد أولاً لبدء بناء التطبيق.",
|
||||
"enterNodeGraph": "دخول مخطط العقد",
|
||||
"giveFeedback": "إعطاء ملاحظات",
|
||||
"graphMode": "وضع الرسم البياني",
|
||||
"hasCreditCost": "يتطلب أرصدة إضافية",
|
||||
"linearMode": "وضع التطبيق",
|
||||
"loadTemplate": "تحميل قالب",
|
||||
"mobileControls": "تعديل وتشغيل",
|
||||
@@ -1858,6 +1855,7 @@
|
||||
"execute": "تنفيذ",
|
||||
"fullscreen": "ملء الشاشة",
|
||||
"help": "مساعدة",
|
||||
"helpAndFeedback": "المساعدة والتعليقات",
|
||||
"hideMenu": "إخفاء القائمة",
|
||||
"instant": "فوري",
|
||||
"instantTooltip": "سيتم وضع سير العمل في قائمة الانتظار فور انتهاء التوليد",
|
||||
@@ -3376,7 +3374,10 @@
|
||||
"addedToWorkspace": "تمت إضافتك إلى {workspaceName}",
|
||||
"inviteAccepted": "تم قبول الدعوة",
|
||||
"inviteFailed": "فشل في قبول الدعوة",
|
||||
"switchFailed": "فشل في تبديل مساحة العمل. يرجى المحاولة مرة أخرى.",
|
||||
"unsavedChanges": {
|
||||
"message": "لديك تغييرات غير محفوظة. هل تريد تجاهلها والانتقال إلى مساحة عمل أخرى؟",
|
||||
"title": "تغييرات غير محفوظة"
|
||||
},
|
||||
"viewWorkspace": "عرض مساحة العمل"
|
||||
},
|
||||
"workspaceAuth": {
|
||||
|
||||
@@ -400,7 +400,7 @@
|
||||
"name": "تخطيط شريط التبويبات",
|
||||
"options": {
|
||||
"Default": "افتراضي",
|
||||
"Legacy": "تقليدي"
|
||||
"Integrated": "مُدمج"
|
||||
},
|
||||
"tooltip": "يتحكم في تخطيط شريط التبويبات. \"مُدمج\" ينقل عناصر المساعدة والتحكمات الخاصة بالمستخدم إلى منطقة شريط التبويبات."
|
||||
},
|
||||
|
||||
@@ -301,7 +301,6 @@
|
||||
"1x": "1x",
|
||||
"2x": "2x",
|
||||
"beta": "BETA",
|
||||
"comfyCloud": "Comfy Cloud",
|
||||
"nightly": "NIGHTLY",
|
||||
"profile": "Profile",
|
||||
"noItems": "No items",
|
||||
@@ -969,6 +968,7 @@
|
||||
"customNodesManager": "Custom Nodes Manager",
|
||||
"settings": "Settings",
|
||||
"help": "Help",
|
||||
"helpAndFeedback": "Help & Feedback",
|
||||
"queue": "Queue Panel",
|
||||
"fullscreen": "Fullscreen"
|
||||
},
|
||||
@@ -3178,9 +3178,6 @@
|
||||
"backToWorkflow": "Back to workflow",
|
||||
"loadTemplate": "Load a template",
|
||||
"cancelThisRun": "Cancel this run",
|
||||
"deleteAllAssets": "Delete all assets from this run",
|
||||
"hasCreditCost": "Requires additional credits",
|
||||
"viewGraph": "View node graph",
|
||||
"welcome": {
|
||||
"title": "App Mode",
|
||||
"message": "A simplified view that hides the node graph so you can focus on creating.",
|
||||
@@ -3225,19 +3222,6 @@
|
||||
"outputPlaceholder": "Output nodes will show up here",
|
||||
"outputRequiredPlaceholder": "At least one node is required"
|
||||
},
|
||||
"error": {
|
||||
"header": "This app encountered an error",
|
||||
"log": "Error Logs",
|
||||
"mobileFixable": "Check {0} for errors",
|
||||
"requiresGraph": "Something went wrong during generation. This could be due to invalid hidden inputs, missing resources, or workflow configuration issues.",
|
||||
"promptVisitGraph": "View the node graph to see the full error.",
|
||||
"getHelp": "For help, view our {0}, {1}, or {2} with the copied error.",
|
||||
"goto": "Show errors in graph",
|
||||
"github": "submit a GitHub issue",
|
||||
"guide": "troubleshooting guide",
|
||||
"support": "contact our support",
|
||||
"promptShow": "Show error report"
|
||||
},
|
||||
"queue": {
|
||||
"clickToClear": "Click to clear queue",
|
||||
"clear": "Clear queue"
|
||||
@@ -3420,11 +3404,14 @@
|
||||
"retryDownload": "Retry download"
|
||||
},
|
||||
"workspace": {
|
||||
"unsavedChanges": {
|
||||
"title": "Unsaved Changes",
|
||||
"message": "You have unsaved changes. Do you want to discard them and switch workspaces?"
|
||||
},
|
||||
"inviteAccepted": "Invite Accepted",
|
||||
"addedToWorkspace": "You have been added to:",
|
||||
"inviteFailed": "Failed to Accept Invite",
|
||||
"viewWorkspace": "View workspace",
|
||||
"switchFailed": "Failed to switch workspace. Please try again."
|
||||
"viewWorkspace": "View workspace"
|
||||
},
|
||||
"workspaceAuth": {
|
||||
"errors": {
|
||||
|
||||
@@ -398,10 +398,10 @@
|
||||
},
|
||||
"Comfy_UI_TabBarLayout": {
|
||||
"name": "Tab Bar Layout",
|
||||
"tooltip": "Controls the elements contained in the integrated tab bar.",
|
||||
"tooltip": "Controls the layout of the tab bar. \"Integrated\" moves Help and User controls into the tab bar area.",
|
||||
"options": {
|
||||
"Default": "Default",
|
||||
"Legacy": "Legacy"
|
||||
"Integrated": "Integrated"
|
||||
}
|
||||
},
|
||||
"Comfy_UseNewMenu": {
|
||||
|
||||
@@ -952,7 +952,6 @@
|
||||
"collapseAll": "Colapsar todo",
|
||||
"color": "Color",
|
||||
"comfy": "Comfy",
|
||||
"comfyCloud": "Comfy Cloud",
|
||||
"comfyOrgLogoAlt": "Logo de ComfyOrg",
|
||||
"comingSoon": "Próximamente",
|
||||
"command": "Comando",
|
||||
@@ -1460,14 +1459,12 @@
|
||||
"unknownWidget": "Widget no visible"
|
||||
},
|
||||
"cancelThisRun": "Cancelar esta ejecución",
|
||||
"deleteAllAssets": "Eliminar todos los recursos de esta ejecución",
|
||||
"downloadAll": "Descargar todo",
|
||||
"dragAndDropImage": "Arrastra y suelta una imagen",
|
||||
"emptyWorkflowExplanation": "Tu flujo de trabajo está vacío. Necesitas algunos nodos primero para empezar a construir una aplicación.",
|
||||
"enterNodeGraph": "Entrar al grafo de nodos",
|
||||
"giveFeedback": "Enviar comentarios",
|
||||
"graphMode": "Modo gráfico",
|
||||
"hasCreditCost": "Requiere créditos adicionales",
|
||||
"linearMode": "Modo App",
|
||||
"loadTemplate": "Cargar una plantilla",
|
||||
"mobileControls": "Editar y ejecutar",
|
||||
@@ -1858,6 +1855,7 @@
|
||||
"execute": "Ejecutar",
|
||||
"fullscreen": "Pantalla completa",
|
||||
"help": "Ayuda",
|
||||
"helpAndFeedback": "Ayuda y comentarios",
|
||||
"hideMenu": "Ocultar menú",
|
||||
"instant": "Instantáneo",
|
||||
"instantTooltip": "El flujo de trabajo se encolará instantáneamente después de que finalice una generación",
|
||||
@@ -3376,7 +3374,10 @@
|
||||
"addedToWorkspace": "Has sido añadido a {workspaceName}",
|
||||
"inviteAccepted": "Invitación aceptada",
|
||||
"inviteFailed": "No se pudo aceptar la invitación",
|
||||
"switchFailed": "No se pudo cambiar de espacio de trabajo. Por favor, inténtalo de nuevo.",
|
||||
"unsavedChanges": {
|
||||
"message": "Tienes cambios no guardados. ¿Quieres descartarlos y cambiar de espacio de trabajo?",
|
||||
"title": "Cambios no guardados"
|
||||
},
|
||||
"viewWorkspace": "Ver espacio de trabajo"
|
||||
},
|
||||
"workspaceAuth": {
|
||||
|
||||
@@ -400,7 +400,7 @@
|
||||
"name": "Diseño de barra de pestañas",
|
||||
"options": {
|
||||
"Default": "Predeterminado",
|
||||
"Legacy": "Clásico"
|
||||
"Integrated": "Integrado"
|
||||
},
|
||||
"tooltip": "Controla el diseño de la barra de pestañas. \"Integrado\" mueve los controles de Ayuda y Usuario al área de la barra de pestañas."
|
||||
},
|
||||
|
||||