Compare commits

..

23 Commits

Author SHA1 Message Date
GitHub Action
231184f0bf [automated] Apply ESLint and Oxfmt fixes 2026-03-08 00:58:47 +00:00
Alexander Brown
44e3f9a7b6 fix: resolve test failures from SSOT migration
- Make graphId lazy in extractVueNodeData computed so nodes not yet attached to graph resolve correctly (fixes ghost placement + error state tests)

- Add optional chaining for rootGraph access in LGraphNode and useMinimapGraph (fixes minimap unit test crashes)

- Set up testing Pinia in litegraph test fixtures and ColorWidget tests

Amp-Thread-ID: https://ampcode.com/threads/T-019ccad5-5703-772f-876e-0f1530481bcb
Co-authored-by: Amp <amp@ampcode.com>
2026-03-07 16:53:06 -08:00
Alexander Brown
a631e7937f fix: remove redundant cleanup(), use disposeNodeManagerAndSyncs directly
cleanup() bypassed nodeDisplayStore.clearGraph(), leaving stale display entries after unmount. Removed the redundant function and updated GraphCanvas to call disposeNodeManagerAndSyncs() directly.

Amp-Thread-ID: https://ampcode.com/threads/T-019cca90-0bae-717a-aac7-4ad8908a0ed7
Co-authored-by: Amp <amp@ampcode.com>
2026-03-07 15:30:46 -08:00
Alexander Brown
238da67eee refactor: remove unnecessary comments across 10 files
Remove migration breadcrumbs, restating JSDoc, CRDT future-work claims, and comment-only additions that violate self-documenting code guidelines.

Amp-Thread-ID: https://ampcode.com/threads/T-019cca7c-2b1f-741a-b464-e454ac5215e7
Co-authored-by: Amp <amp@ampcode.com>
2026-03-07 15:05:34 -08:00
Alexander Brown
5bad07f610 refactor: apply PR review fixes for node layout SSOT
- Add private modifier to _title backing field

- Remove pres() wrapper, use presentationRef.value directly

- Replace unsafe cast with Object.assign in nodeDisplayStore

- Inline syncDisplayStore into applyPresentationChange

- Simplify getMinZIndex to use Math.min like getMaxZIndex

- Pre-build zIndex map before sort in useLayoutSync

Amp-Thread-ID: https://ampcode.com/threads/T-019cca5a-87df-7179-b8e5-820f0453b730
Co-authored-by: Amp <amp@ampcode.com>
2026-03-07 14:26:55 -08:00
Alexander Brown
d4767e65de refactor: simplicity cleanup — remove dead code and YAGNI markers
- Delete unused projectLayoutToSerialized/projectPresentationToSerialized
- Remove unused LGraphTriggerAction/LGraphTriggerParam barrel re-exports
- Remove isViewStateOnly YAGNI marker and change-detector test
- Remove unused version ref from nodeSelectionStore
- Un-export NodeDisplayFlags (internal only)
- Gate verifyStoreConsistency behind import.meta.env.DEV

Amp-Thread-ID: https://ampcode.com/threads/T-019cca2f-7ef0-70d3-b8f7-975989767adf
Co-authored-by: Amp <amp@ampcode.com>
2026-03-07 13:41:58 -08:00
Alexander Brown
12992c51ed merge: resolve conflict in app.ts, take origin/main
Kept try/finally structure with ChangeTracker.isLoadingGraph guard from main

Amp-Thread-ID: https://ampcode.com/threads/T-019cca21-24e3-771f-b386-6731222e3dfa
Co-authored-by: Amp <amp@ampcode.com>
2026-03-07 13:09:33 -08:00
Alexander Brown
07c844aa6b refactor: remove old nodePresentationStore and dead types
Amp-Thread-ID: https://ampcode.com/threads/T-019cca09-51fb-732b-a7af-1f3b9e269134
Co-authored-by: Amp <amp@ampcode.com>
2026-03-07 12:57:26 -08:00
Alexander Brown
a9537a7450 refactor: migrate useMinimapGraph to useNodeDisplayStore
Amp-Thread-ID: https://ampcode.com/threads/T-019cca09-51fb-732b-a7af-1f3b9e269134
Co-authored-by: Amp <amp@ampcode.com>
2026-03-07 12:51:05 -08:00
Alexander Brown
e03ffecf5b refactor: migrate useVueNodeLifecycle to useNodeDisplayStore
Amp-Thread-ID: https://ampcode.com/threads/T-019cca09-51fb-732b-a7af-1f3b9e269134
Co-authored-by: Amp <amp@ampcode.com>
2026-03-07 12:49:25 -08:00
Alexander Brown
f91a42a4a7 refactor: migrate LGraphNode to useNodeDisplayStore
Amp-Thread-ID: https://ampcode.com/threads/T-019cca09-51fb-732b-a7af-1f3b9e269134
Co-authored-by: Amp <amp@ampcode.com>
2026-03-07 12:47:08 -08:00
Alexander Brown
27c72113db refactor: migrate useGraphNodeManager to useNodeDisplayStore
Amp-Thread-ID: https://ampcode.com/threads/T-019cc9fd-772b-746e-92ac-f12858267c22
Co-authored-by: Amp <amp@ampcode.com>
2026-03-07 12:39:15 -08:00
Alexander Brown
81860fc379 test: add useNodeDisplayStore tests
Amp-Thread-ID: https://ampcode.com/threads/T-019cc9fd-772b-746e-92ac-f12858267c22
Co-authored-by: Amp <amp@ampcode.com>
2026-03-07 12:34:00 -08:00
Alexander Brown
b7721f9247 feat: add useNodeDisplayStore Pinia store
Amp-Thread-ID: https://ampcode.com/threads/T-019cc9fd-772b-746e-92ac-f12858267c22
Co-authored-by: Amp <amp@ampcode.com>
2026-03-07 12:32:17 -08:00
Alexander Brown
86963c9435 refactor: migrate presentation state to nodePresentationStore
- Remove LGraphNodeProperties and node:property:changed trigger
- Presentation setters on LGraphNode sync directly to nodePresentationStore
- VueNodeData reads presentation fields via store-backed getters
- Delete applyStorePresentationProjection (zero runtime callers)
- Delete applyPresentationStoreChange subscription-and-copy mechanism
- Make resizable a derived getter (!pinned && _resizable !== false)
- Minimap listens to nodePresentationStore.onChange for visual updates
- Add behavioral guard tests for presentation tracking

Amp-Thread-ID: https://ampcode.com/threads/T-019cc9b4-739e-7541-9ec4-22fb45addfb2
Co-authored-by: Amp <amp@ampcode.com>
2026-03-07 11:44:23 -08:00
Alexander Brown
8119bb45fb fix: z-index sync and presentation store ref cache consolidation
- Sync graph._nodes render order from store zIndex on setNodeZIndex operations

- Consolidate nodeRefs + nodeTriggers into single nodeRefCache map

- Clean up ref cache on removeNode to prevent memory leak

- Add test for remove-then-reinit ref lifecycle

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019cc766-d450-7778-86b7-931180d6ce68
2026-03-07 01:04:32 -08:00
Alexander Brown
29c6a37e8b feat: Phase 7 - group scope decision and out-of-scope invariants
- Decision: groups remain legacy-owned (LiteGraph-authoritative)

- Add 9 scope invariant tests verifying layout store exclusion boundary

- Add contract JSDoc to LGraphGroup referencing decision record

- No group operations, methods, or state in layoutStore domain

Amp-Thread-ID: https://ampcode.com/threads/T-019cc65d-7478-71e4-8b9e-cfdd86a322bb
Co-authored-by: Amp <amp@ampcode.com>
2026-03-07 00:24:37 -08:00
Alexander Brown
177535b1b5 feat: Phase 6 — post-configure stabilization checkpoint, clipboard store-aware hydration, serialization regression tests
Task 6.1: Add stabilization checkpoint comment in loadGraphData after all post-configure work completes. Document configured event timing in LGraph.ts. Update notifyStoresAfterConfigure JSDoc.

Task 6.2: Document store hydration contract in _deserializeItems (4-step pipeline). Add contract references in usePaste/useCopy.

Task 6.3: Add 8 regression tests covering serialization field preservation, layout persistence adapter round-trips, and clipboard data passthrough.

Amp-Thread-ID: https://ampcode.com/threads/T-019cc644-03a2-74a9-b7b5-b1c693589b87
Co-authored-by: Amp <amp@ampcode.com>
2026-03-07 00:24:36 -08:00
Alexander Brown
c39151d653 feat: phase 5 — store-aware persistence/history contracts
- Add layoutPersistenceAdapter with pure Store↔ISerialisedNode converters
- Add LGraphNode.notifyStoresAfterConfigure() for post-configure store projection
- Add verifyStoreConsistency() to changeTracker for hybrid history contract
- Mark subgraphNavigationStore as view-state only with contract marker
- Add changeTracker.storeContract tests (13) and viewport boundary tests (9)

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019cc75f-f06c-701e-a2b4-ab9fb969ea82
2026-03-07 00:24:29 -08:00
Alexander Brown
ecedeb1515 feat: phase 4 — canonicalize z-order, minimap projection, selection store
Task 4.1: add getMaxZIndex/getMinZIndex to layoutStore, sendNodeToBack mutation, z-order tests

Task 4.2: wire LGraphCanvas bringToFront/sendToBack to layoutStore, sort minimap by zIndex

Task 4.3: create nodeSelectionStore as canonical reactive selection authority, wire canvasStore sync

Amp-Thread-ID: https://ampcode.com/threads/T-019cc617-ccf0-77df-961e-831e7ca07216
Co-authored-by: Amp <amp@ampcode.com>
2026-03-06 18:37:53 -08:00
Alexander Brown
90414f1da9 feat: Phase 3 — NodePresentationStore SSOT foundation
- Create NodePresentationStore domain (types, store, actions, tests)
- Add dedup guard to LGraphNodeProperties._emitPropertyChange
- Add suppressEvents flag for store→LiteGraph projection
- Add LGraphNode.applyStorePresentationProjection() method
- Wire presentation store init/teardown into node lifecycle bridge
- Forward property change events to presentation store

Amp-Thread-ID: https://ampcode.com/threads/T-019cc5fa-8d65-704c-be27-e4e76cdfc3d8
Co-authored-by: Amp <amp@ampcode.com>
2026-03-06 18:18:55 -08:00
Alexander Brown
e421d1dc01 feat: harden core mutation contracts (Phase 2 SSOT)
- Add LGraphNode.applyStoreProjection() for explicit store→LiteGraph
  projection without triggering setter feedback loops
- Refactor useLayoutSync to use applyStoreProjection instead of direct
  array-element writes, making one-way sync self-documenting
- Add geometry mutation guard tests (13 tests) covering pos/size setter
  store integration, applyStoreProjection bypass, and presentation
  property change events
- Document LGraphNodeProperties instrumentation bypass of class setters

Amp-Thread-ID: https://ampcode.com/threads/T-019cc5ee-e659-778b-a92c-eff2e38e1e13
Co-authored-by: Amp <amp@ampcode.com>
2026-03-06 17:46:34 -08:00
Alexander Brown
7e2fb8977c feat: instrument node mutation pathways for SSOT migration (Phase 1)
- Replace direct node.size[n] element writes with setter-triggering
  assignments in dynamicWidgets.ts and widgetInputs.ts
- Add title/mode getter/setter pairs to LGraphNode with
  node:property:changed event firing (matching existing shape pattern)
- Add clarifying comment to ensureCorrectLayoutScale.ts explaining
  intentional batch-write optimization

Amp-Thread-ID: https://ampcode.com/threads/T-019cc5c5-943a-73ea-9090-16f8f00caa9a
Co-authored-by: Amp <amp@ampcode.com>
2026-03-06 17:34:17 -08:00
203 changed files with 3328 additions and 3082 deletions

View File

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

View File

@@ -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
})
}

View File

@@ -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
})
}
}

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 96 KiB

View File

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

View File

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

View File

@@ -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,43 +182,6 @@ 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'
)
@@ -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'

View File

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

View File

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

View File

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

View 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
}
}

View 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()
})
})
})
})

View 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>

View 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')
})
})

View 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>

View File

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

View File

@@ -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'

View File

@@ -138,7 +138,8 @@ onMounted(async () => {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('toastMessages.failedToFetchLogs')
detail: t('toastMessages.failedToFetchLogs'),
life: 5000
})
}
})

View File

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

View File

@@ -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'

View File

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

View File

@@ -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()

View File

@@ -561,7 +561,7 @@ onMounted(async () => {
})
onUnmounted(() => {
vueNodeLifecycle.cleanup()
vueNodeLifecycle.disposeNodeManagerAndSyncs()
})
function forwardPanEvent(e: PointerEvent) {
if (

View File

@@ -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
})
}
}

View File

@@ -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'

View File

@@ -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)

View File

@@ -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'

View File

@@ -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)
})

View File

@@ -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() {

View File

@@ -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,16 +44,12 @@
)
"
: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="
showAssetNames ? getAssetPrimaryText(item.asset) : undefined
"
:secondary-text="
showAssetDetails ? getAssetSecondaryText(item.asset) : undefined
"
:stack-count="groupByJob ? getStackCount(item.asset) : undefined"
:primary-text="getAssetPrimaryText(item.asset)"
:secondary-text="getAssetSecondaryText(item.asset)"
:stack-count="getStackCount(item.asset)"
:stack-indicator-label="t('mediaAsset.actions.seeMoreOutputs')"
:stack-expanded="isStackExpanded(item.asset)"
@mouseenter="onAssetEnter(item.asset.id)"
@@ -110,10 +104,7 @@ const {
isSelected,
isStackExpanded,
toggleStack,
assetType = 'output',
groupByJob = true,
showAssetNames = true,
showAssetDetails = true
assetType = 'output'
} = defineProps<{
assetItems: OutputStackListItem[]
selectableAssets: AssetItem[]
@@ -121,9 +112,6 @@ const {
isStackExpanded: (asset: AssetItem) => boolean
toggleStack: (asset: AssetItem) => Promise<void>
assetType?: 'input' | 'output'
groupByJob?: boolean
showAssetNames?: boolean
showAssetDetails?: boolean
}>()
const assetsStore = useAssetsStore()
@@ -145,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) {

View File

@@ -50,9 +50,6 @@
v-model:sort-by="sortBy"
v-model:view-mode="viewMode"
v-model:media-type-filters="mediaTypeFilters"
v-model:group-by-job="groupByJob"
v-model:show-asset-names="showAssetNames"
v-model:show-asset-details="showAssetDetails"
class="px-2 pb-1 2xl:px-4"
:show-generation-time-sort="activeTab === 'output'"
/>
@@ -91,17 +88,12 @@
<div v-else class="relative size-full" @click="handleEmptySpaceClick">
<AssetsSidebarListView
v-if="isListView"
:asset-items="
groupByJob ? listViewAssetItems : ungroupedListViewItems
"
:asset-items="listViewAssetItems"
:is-selected="isSelected"
:selectable-assets="listViewVisibleAssets"
:selectable-assets="listViewSelectableAssets"
:is-stack-expanded="isListViewStackExpanded"
:toggle-stack="toggleListViewStack"
:asset-type="activeTab"
:group-by-job="groupByJob"
:show-asset-names="showAssetNames"
:show-asset-details="showAssetDetails"
@select-asset="handleAssetSelect"
@preview-asset="handleZoomClick"
@context-menu="handleAssetContextMenu"
@@ -109,7 +101,7 @@
/>
<AssetsSidebarGridView
v-else
:assets="ungroupedAssets"
:assets="displayAssets"
:is-selected="isSelected"
:asset-type="activeTab"
:show-output-count="shouldShowOutputCount"
@@ -219,7 +211,6 @@ import {
nextTick,
onMounted,
onUnmounted,
provide,
ref,
watch
} from 'vue'
@@ -241,17 +232,11 @@ import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAsse
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
import { useMediaAssetFiltering } from '@/platform/assets/composables/useMediaAssetFiltering'
import type { OutputStackListItem } from '@/platform/assets/composables/useOutputStacks'
import { useOutputStacks } from '@/platform/assets/composables/useOutputStacks'
import { useUngroupedAssets } from '@/platform/assets/composables/useUngroupedAssets'
import type { OutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type { MediaKind } from '@/platform/assets/schemas/mediaAssetSchema'
import {
ShowAssetDetailsKey,
ShowAssetNamesKey
} from '@/platform/assets/schemas/mediaAssetSchema'
import { resolveOutputAssetItems } from '@/platform/assets/utils/outputAssetUtil'
import { isCloud } from '@/platform/distribution/types'
import { useDialogStore } from '@/stores/dialogStore'
@@ -281,14 +266,6 @@ const viewMode = useStorage<'list' | 'grid'>(
'grid'
)
const isListView = computed(() => viewMode.value === 'list')
const groupByJob = useStorage('Comfy.Assets.Sidebar.GroupByJob', true)
const showAssetNames = useStorage('Comfy.Assets.Sidebar.ShowAssetNames', true)
const showAssetDetails = useStorage(
'Comfy.Assets.Sidebar.ShowAssetDetails',
true
)
provide(ShowAssetNamesKey, showAssetNames)
provide(ShowAssetDetailsKey, showAssetDetails)
const contextMenuRef = ref<InstanceType<typeof MediaAssetContextMenu>>()
const contextMenuAsset = ref<AssetItem | null>(null)
@@ -309,7 +286,6 @@ const contextMenuFileKind = computed<MediaKind>(() =>
)
const shouldShowOutputCount = (item: AssetItem): boolean => {
if (!groupByJob.value) return false
if (activeTab.value !== 'output' || isInFolderView.value) {
return false
}
@@ -416,10 +392,9 @@ const baseAssets = computed(() => {
const { searchQuery, sortBy, mediaTypeFilters, filteredAssets } =
useMediaAssetFiltering(baseAssets)
const { ungroupedAssets, isResolving } = useUngroupedAssets(
filteredAssets,
groupByJob
)
const displayAssets = computed(() => {
return filteredAssets.value
})
const {
assetItems: listViewAssetItems,
@@ -427,22 +402,11 @@ const {
isStackExpanded: isListViewStackExpanded,
toggleStack: toggleListViewStack
} = useOutputStacks({
assets: filteredAssets
assets: computed(() => displayAssets.value)
})
const ungroupedListViewItems = computed<OutputStackListItem[]>(() =>
ungroupedAssets.value.map((asset) => ({
key: `asset-${asset.id}`,
asset
}))
)
const listViewVisibleAssets = computed(() =>
groupByJob.value ? listViewSelectableAssets.value : ungroupedAssets.value
)
const visibleAssets = computed(() => {
if (!isListView.value || !groupByJob.value) return ungroupedAssets.value
if (!isListView.value) return displayAssets.value
return listViewSelectableAssets.value
})
@@ -462,20 +426,14 @@ const isFolderLoading = computed(
() => isInFolderView.value && folderLoading.value
)
const showLoadingState = computed(() => {
const isPrimaryLoading =
(loading.value || isFolderLoading.value) &&
filteredAssets.value.length === 0
const isUngroupedResolvingEmpty =
!groupByJob.value && isResolving.value && ungroupedAssets.value.length === 0
return isPrimaryLoading || isUngroupedResolvingEmpty
})
const showLoadingState = computed(
() =>
(loading.value || isFolderLoading.value) && displayAssets.value.length === 0
)
const showEmptyState = computed(
() =>
!loading.value &&
!isFolderLoading.value &&
filteredAssets.value.length === 0
!loading.value && !isFolderLoading.value && displayAssets.value.length === 0
)
watch(visibleAssets, (newAssets) => {
@@ -611,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 || ''
@@ -657,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()
}
@@ -703,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
})
}
}

View File

@@ -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'

View File

@@ -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'

View File

@@ -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'

View File

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

View File

@@ -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: [],

View File

@@ -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
})

View File

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

View File

@@ -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([''])
})
})
})

View File

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

View File

@@ -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>
`
})
}

View File

@@ -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'

View File

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

View File

@@ -11,6 +11,7 @@ import {
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import { useNodeDisplayStore } from '@/stores/nodeDisplayStore'
import { usePromotionStore } from '@/stores/promotionStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
@@ -316,3 +317,57 @@ describe('Nested promoted widget mapping', () => {
)
})
})
describe('Display store integration', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('registers node in display store on node add', () => {
const graph = new LGraph()
useGraphNodeManager(graph)
const node = new LGraphNode('test')
node.title = 'My Node'
graph.add(node)
const displayStore = useNodeDisplayStore()
const state = displayStore.getNode(graph.rootGraph.id, String(node.id))
expect(state).toMatchObject({
id: String(node.id),
title: 'My Node'
})
})
it('removes from display store on node removal', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
graph.add(node)
useGraphNodeManager(graph)
const displayStore = useNodeDisplayStore()
expect(
displayStore.getNode(graph.rootGraph.id, String(node.id))
).toBeDefined()
graph.remove(node)
expect(
displayStore.getNode(graph.rootGraph.id, String(node.id))
).toBeUndefined()
})
it('updates vue node data when display store is updated', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
graph.add(node)
const { vueNodeData } = useGraphNodeManager(graph)
const displayStore = useNodeDisplayStore()
displayStore.updateNode(graph.rootGraph.id, String(node.id), {
title: 'Updated Title'
})
expect(vueNodeData.get(String(node.id))?.title).toBe('Updated Title')
})
})

View File

@@ -3,7 +3,7 @@
* Provides event-driven reactivity with performance optimizations
*/
import { reactiveComputed } from '@vueuse/core'
import { reactive, shallowReactive } from 'vue'
import { computed, reactive, shallowReactive } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
@@ -19,6 +19,7 @@ import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMuta
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { LayoutSource } from '@/renderer/core/layout/types'
import type { NodeId } from '@/renderer/core/layout/types'
import { useNodeDisplayStore } from '@/stores/nodeDisplayStore'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { isDOMWidget } from '@/scripts/domWidget'
import { useNodeDefStore } from '@/stores/nodeDefStore'
@@ -29,9 +30,7 @@ import type {
LGraph,
LGraphBadge,
LGraphNode,
LGraphTriggerAction,
LGraphTriggerEvent,
LGraphTriggerParam
LGraphTriggerEvent
} from '@/lib/litegraph/src/litegraph'
import type { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
@@ -413,15 +412,25 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
const apiNode = node.constructor?.nodeData?.api_node ?? false
const badges = node.badges
const nodeId = String(node.id)
const nodeDisplayStore = useNodeDisplayStore()
const presentationRef = computed(() => {
const graphId = node.graph?.rootGraph?.id
return graphId ? nodeDisplayStore.getNode(graphId, nodeId) : undefined
})
return {
id: String(node.id),
title: typeof node.title === 'string' ? node.title : '',
const data: VueNodeData = {
id: nodeId,
get title() {
return presentationRef.value?.title ?? ''
},
type: nodeType,
mode: node.mode || 0,
get mode() {
return presentationRef.value?.mode ?? 0
},
titleMode: node.title_mode,
selected: node.selected || false,
executing: false, // Will be updated separately based on execution state
executing: false,
subgraphId,
apiNode,
badges,
@@ -429,18 +438,33 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
widgets: safeWidgets,
inputs: reactiveInputs,
outputs: node.outputs ? [...node.outputs] : undefined,
flags: node.flags ? { ...node.flags } : undefined,
color: node.color || undefined,
bgcolor: node.bgcolor || undefined,
get flags() {
const f = presentationRef.value?.flags
return f ? { ...f } : undefined
},
get color() {
return presentationRef.value?.color
},
get bgcolor() {
return presentationRef.value?.bgcolor
},
resizable: node.resizable,
shape: node.shape,
showAdvanced: node.showAdvanced
get shape() {
return presentationRef.value?.shape
},
get showAdvanced() {
return presentationRef.value?.showAdvanced
}
}
return data
}
export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
// Get layout mutations composable
const { createNode, deleteNode, setSource } = useLayoutMutations()
const nodeDisplayStore = useNodeDisplayStore()
const graphId = graph.rootGraph.id
// Safe reactive data extracted from LiteGraph nodes
const vueNodeData = reactive(new Map<string, VueNodeData>())
@@ -485,6 +509,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
// Remove deleted nodes
for (const id of Array.from(vueNodeData.keys())) {
if (!currentNodes.has(id)) {
nodeDisplayStore.removeNode(graphId, id)
nodeRefs.delete(id)
vueNodeData.delete(id)
}
@@ -526,6 +551,21 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
const nodePosition = { x: node.pos[0], y: node.pos[1] }
const nodeSize = { width: node.size[0], height: node.size[1] }
nodeDisplayStore.registerNode(graphId, id, {
id,
title: typeof node.title === 'string' ? node.title : '',
mode: node.mode || 0,
shape: node.shape,
showAdvanced: node.showAdvanced,
color: node.color || undefined,
bgcolor: node.bgcolor || undefined,
flags: {
collapsed: node.flags?.collapsed,
pinned: node.flags?.pinned,
ghost: node.flags?.ghost
}
})
// Skip layout creation if it already exists
// (e.g. in-place node replacement where the old node's layout is reused for the new node with the same ID).
const existingLayout = layoutStore.getNodeLayoutRef(id).value
@@ -578,6 +618,8 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
setSource(LayoutSource.Canvas)
void deleteNode(id)
nodeDisplayStore.removeNode(graphId, id)
// Clean up all tracking references
nodeRefs.delete(id)
vueNodeData.delete(id)
@@ -626,97 +668,16 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
handleNodeRemoved(node, originalOnNodeRemoved)
}
const triggerHandlers: {
[K in LGraphTriggerAction]: (event: LGraphTriggerParam<K>) => void
} = {
'node:property:changed': (propertyEvent) => {
const nodeId = String(propertyEvent.nodeId)
const currentData = vueNodeData.get(nodeId)
if (currentData) {
switch (propertyEvent.property) {
case 'title':
vueNodeData.set(nodeId, {
...currentData,
title: String(propertyEvent.newValue)
})
break
case 'flags.collapsed':
vueNodeData.set(nodeId, {
...currentData,
flags: {
...currentData.flags,
collapsed: Boolean(propertyEvent.newValue)
}
})
break
case 'flags.ghost':
vueNodeData.set(nodeId, {
...currentData,
flags: {
...currentData.flags,
ghost: Boolean(propertyEvent.newValue)
}
})
break
case 'flags.pinned':
vueNodeData.set(nodeId, {
...currentData,
flags: {
...currentData.flags,
pinned: Boolean(propertyEvent.newValue)
}
})
break
case 'mode':
vueNodeData.set(nodeId, {
...currentData,
mode:
typeof propertyEvent.newValue === 'number'
? propertyEvent.newValue
: 0
})
break
case 'color':
vueNodeData.set(nodeId, {
...currentData,
color:
typeof propertyEvent.newValue === 'string'
? propertyEvent.newValue
: undefined
})
break
case 'bgcolor':
vueNodeData.set(nodeId, {
...currentData,
bgcolor:
typeof propertyEvent.newValue === 'string'
? propertyEvent.newValue
: undefined
})
break
case 'shape':
vueNodeData.set(nodeId, {
...currentData,
shape:
typeof propertyEvent.newValue === 'number'
? propertyEvent.newValue
: undefined
})
break
case 'showAdvanced':
vueNodeData.set(nodeId, {
...currentData,
showAdvanced: Boolean(propertyEvent.newValue)
})
break
}
}
},
'node:slot-errors:changed': (slotErrorsEvent) => {
const triggerHandlers = {
'node:slot-errors:changed': (slotErrorsEvent: {
nodeId: string | number
}) => {
refreshNodeSlots(String(slotErrorsEvent.nodeId))
},
'node:slot-links:changed': (slotLinksEvent) => {
'node:slot-links:changed': (slotLinksEvent: {
nodeId: string | number
slotType: NodeSlotType
}) => {
if (slotLinksEvent.slotType === NodeSlotType.INPUT) {
refreshNodeSlots(String(slotLinksEvent.nodeId))
}
@@ -725,9 +686,6 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
graph.onTrigger = (event: LGraphTriggerEvent) => {
switch (event.type) {
case 'node:property:changed':
triggerHandlers['node:property:changed'](event)
break
case 'node:slot-errors:changed':
triggerHandlers['node:slot-errors:changed'](event)
break

View File

@@ -6,6 +6,7 @@ import type { GraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useNodeDisplayStore } from '@/stores/nodeDisplayStore'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { useLayoutSync } from '@/renderer/core/layout/sync/useLayoutSync'
@@ -62,6 +63,11 @@ function useVueNodeLifecycleIndividual() {
const disposeNodeManagerAndSyncs = () => {
if (!nodeManager.value) return
const graphId = comfyApp.canvas?.graph?.rootGraph.id
if (graphId) {
useNodeDisplayStore().clearGraph(graphId)
}
try {
nodeManager.value.cleanup()
} catch {
@@ -136,22 +142,13 @@ function useVueNodeLifecycleIndividual() {
}
}
// Cleanup function for component unmounting
const cleanup = () => {
if (nodeManager.value) {
nodeManager.value.cleanup()
nodeManager.value = null
}
}
return {
nodeManager,
// Lifecycle methods
initializeNodeManager,
disposeNodeManagerAndSyncs,
setupEmptyGraphListener,
cleanup
setupEmptyGraphListener
}
}

View File

@@ -239,32 +239,4 @@ describe('useCachedRequest', () => {
await cachedRequest.call(123)
expect(mockRequestFn).toHaveBeenCalledTimes(1)
})
it('should not cache aborted requests', async () => {
vi.unstubAllGlobals()
let callCount = 0
const abortFn = vi.fn(async (_params: unknown, _signal?: AbortSignal) => {
callCount++
if (callCount === 1) {
const error = new DOMException(
'The operation was aborted.',
'AbortError'
)
throw error
}
return { data: 'success' }
})
const cachedRequest = useCachedRequest(abortFn)
// First call throws AbortError — should NOT be cached
const result1 = await cachedRequest.call('key')
expect(result1).toBeNull()
// Second call should retry (not use cached null)
const result2 = await cachedRequest.call('key')
expect(result2).toEqual({ data: 'success' })
expect(abortFn).toHaveBeenCalledTimes(2)
})
})

View File

@@ -48,10 +48,6 @@ export function useCachedRequest<TParams, TResult>(
return result
} catch (err) {
// Don't cache aborted requests — they should be retried
if (err instanceof DOMException && err.name === 'AbortError') {
return null
}
// Set cache on error to prevent retrying bad requests
cache.set(cacheKey, null)
return null

View File

@@ -102,4 +102,32 @@ describe('Clipboard UTF-8 base64 encoding/decoding', () => {
// This demonstrates why we need TextEncoder - plain btoa fails
expect(() => btoa(original)).toThrow()
})
it('should round-trip node data with layout and presentation fields', () => {
const nodeData = {
nodes: [
{
id: 1,
type: 'LoadImage',
pos: [100, 200],
size: [300, 150],
title: 'My Image Loader',
mode: 0,
flags: { collapsed: false },
color: '#ff0000'
}
],
links: []
}
const original = JSON.stringify(nodeData)
const encoded = encodeClipboardData(original)
const decoded = decodeClipboardData(encoded)
const parsed = JSON.parse(decoded)
expect(parsed.nodes[0].pos).toEqual([100, 200])
expect(parsed.nodes[0].size).toEqual([300, 150])
expect(parsed.nodes[0].title).toBe('My Image Loader')
expect(parsed.nodes[0].color).toBe('#ff0000')
expect(parsed.nodes[0].flags).toEqual({ collapsed: false })
})
})

View File

@@ -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
}

View File

@@ -595,6 +595,50 @@ describe('usePaste', () => {
)
})
})
it('should pass layout-bearing clipboard data to _deserializeItems', async () => {
const clipboardData = {
nodes: [
{
id: 1,
type: 'LoadImage',
pos: [100, 200],
size: [300, 150],
title: 'Copied Node',
mode: 0,
flags: {}
}
],
links: [],
groups: [],
reroutes: []
}
const encoded = btoa(JSON.stringify(clipboardData))
const html = `<div data-metadata="${encoded}"></div>`
usePaste()
const dataTransfer = new DataTransfer()
dataTransfer.setData('text/html', html)
const event = new ClipboardEvent('paste', { clipboardData: dataTransfer })
document.dispatchEvent(event)
await vi.waitFor(() => {
expect(mockCanvas._deserializeItems).toHaveBeenCalledWith(
expect.objectContaining({
nodes: expect.arrayContaining([
expect.objectContaining({
pos: [100, 200],
size: [300, 150],
title: 'Copied Node'
})
])
}),
expect.any(Object)
)
})
})
})
describe('cloneDataTransfer', () => {

View File

@@ -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 []
}

View File

@@ -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()
})
})

View File

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

View File

@@ -175,7 +175,8 @@ function dynamicComboWidget(
}
}
node.size[1] = node.computeSize([...node.size])[1]
const computed = node.computeSize([...node.size])
node.size = [node.size[0], computed[1]]
if (!node.graph) return
node._setConcreteSlots()
node.arrange()
@@ -523,7 +524,8 @@ function autogrowInputDisconnected(index: number, node: AutogrowNode) {
for (const widget of remove(node.widgets, (w) => w.name === widgetName))
widget.onRemove?.()
}
node.size[1] = node.computeSize([...node.size])[1]
const computed = node.computeSize([...node.size])
node.size = [node.size[0], computed[1]]
}
function withComfyAutogrow(node: LGraphNode): asserts node is AutogrowNode {

View File

@@ -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
})
}
}

View File

@@ -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}`)
}

View File

@@ -336,11 +336,10 @@ export class PrimitiveNode extends LGraphNode {
if (!recreating) {
const sz = this.computeSize()
if (this.size[0] < sz[0]) {
this.size[0] = sz[0]
}
if (this.size[1] < sz[1]) {
this.size[1] = sz[1]
const newWidth = Math.max(this.size[0], sz[0])
const newHeight = Math.max(this.size[1], sz[1])
if (newWidth !== this.size[0] || newHeight !== this.size[1]) {
this.size = [newWidth, newHeight]
}
requestAnimationFrame(() => {

View File

@@ -79,11 +79,6 @@ import type {
import { getAllNestedItems } from './utils/collections'
import { deduplicateSubgraphNodeIds } from './utils/subgraphDeduplication'
export type {
LGraphTriggerAction,
LGraphTriggerParam
} from './types/graphTriggers'
export type RendererType = 'LG' | 'Vue'
export interface LGraphState {
@@ -1330,8 +1325,7 @@ export class LGraph
// Convert to discriminated union format for typed handlers
const validEventTypes = new Set([
'node:slot-links:changed',
'node:slot-errors:changed',
'node:property:changed'
'node:slot-errors:changed'
])
if (validEventTypes.has(action) && param && typeof param === 'object') {

View File

@@ -7,6 +7,10 @@ import type {
ExportedSubgraph,
ISerialisedNode
} from '@/lib/litegraph/src/types/serialisation'
import {
extractLayoutFromSerialized,
extractPresentationFromSerialized
} from '@/renderer/core/layout/persistence/layoutPersistenceAdapter'
function createSerialisedNode(
id: number,
@@ -100,3 +104,80 @@ describe('remapClipboardSubgraphNodeIds', () => {
])
})
})
describe('Serialization layout field preservation', () => {
it('serialized node preserves pos and size arrays', () => {
const node = new LGraphNode('test')
node.pos = [100, 200]
node.size = [300, 400]
const serialized = node.serialize()
expect(serialized.pos).toEqual([100, 200])
expect(serialized.size).toEqual([300, 400])
})
it('serialized node preserves presentation fields', () => {
const node = new LGraphNode('test')
node.title = 'Custom Title'
node.mode = 2
node.color = '#ff0000'
node.bgcolor = '#00ff00'
const serialized = node.serialize()
expect(serialized.title).toBe('Custom Title')
expect(serialized.mode).toBe(2)
expect(serialized.color).toBe('#ff0000')
expect(serialized.bgcolor).toBe('#00ff00')
})
it('serialized node preserves flags', () => {
const node = new LGraphNode('test')
node.flags.collapsed = true
node.flags.pinned = true
const serialized = node.serialize()
expect(serialized.flags?.collapsed).toBe(true)
expect(serialized.flags?.pinned).toBe(true)
})
})
describe('Layout persistence adapter round-trip', () => {
it('extractLayoutFromSerialized uses provided zIndex', () => {
const serializedNode: ISerialisedNode = {
id: 42,
type: 'test',
pos: [10, 20],
size: [100, 50],
flags: {},
order: 0,
mode: 0
}
const layout = extractLayoutFromSerialized(serializedNode, 7)
expect(layout.zIndex).toBe(7)
expect(layout.id).toBe('42')
})
it('extractPresentationFromSerialized handles missing optional fields', () => {
const serializedNode: ISerialisedNode = {
id: 1,
type: 'test',
pos: [0, 0],
size: [100, 100],
flags: {},
order: 0,
mode: 0
}
const presentation = extractPresentationFromSerialized(serializedNode)
expect(presentation.title).toBe('')
expect(presentation.mode).toBe(0)
expect(presentation.color).toBeUndefined()
expect(presentation.bgcolor).toBeUndefined()
expect(presentation.flags.collapsed).toBeUndefined()
expect(presentation.flags.pinned).toBeUndefined()
})
})

View File

@@ -6,6 +6,7 @@ import { MovingInputLink } from '@/lib/litegraph/src/canvas/MovingInputLink'
import { LitegraphLinkAdapter } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
import type { LinkRenderContext } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
import { getSlotPosition } from '@/renderer/core/canvas/litegraph/slotCalculations'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { LayoutSource } from '@/renderer/core/layout/types'
import { forEachNode } from '@/utils/graphTraversalUtil'
@@ -3620,13 +3621,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
this.deselect(node)
this.graph?.remove(node)
} else {
delete node.flags.ghost
this.graph?.trigger('node:property:changed', {
nodeId: node.id,
property: 'flags.ghost',
oldValue: true,
newValue: false
})
node.flags.ghost = undefined
this.state.selectionChanged = true
this.onSelectionChange?.(this.selected_nodes)
@@ -3968,7 +3963,14 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
): ClipboardPasteResult | undefined {
const data = localStorage.getItem('litegrapheditor_clipboard')
if (!data) return
return this._deserializeItems(JSON.parse(data), options)
let parsed: ClipboardItems
try {
parsed = JSON.parse(data)
} catch {
console.warn('Failed to parse clipboard data')
return
}
return this._deserializeItems(parsed, options)
}
_deserializeItems(
@@ -4133,7 +4135,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
}
// Adjust positions - use move/setPos to ensure layout store is updated
const dx = position[0] - offsetX
const dy = position[1] - offsetY
for (const item of created) {
@@ -4722,6 +4723,9 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
graph._nodes.splice(i, 1)
graph._nodes.push(node)
const { bringNodeToFront } = useLayoutMutations()
bringNodeToFront(String(node.id))
}
/**
@@ -4736,6 +4740,9 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
graph._nodes.splice(i, 1)
graph._nodes.unshift(node)
const { sendNodeToBack } = useLayoutMutations()
sendNodeToBack(String(node.id))
}
/**
@@ -7674,8 +7681,13 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
value = Number(value)
}
if (type == 'array' || type == 'object') {
// @ts-expect-error JSON.parse doesn't care.
value = JSON.parse(value)
try {
// @ts-expect-error JSON.parse doesn't care.
value = JSON.parse(value)
} catch {
console.warn(`Failed to parse property "${property}" as ${type}`)
return
}
}
node.properties[property] = value
if (node.graph) {
@@ -8130,7 +8142,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
node.onShowCustomPanelInfo?.(panel)
// clear
panel.footer.innerHTML = ''
panel.footer.replaceChildren()
panel
.addButton('Delete', function () {
if (node.block_delete) return
@@ -8146,9 +8158,9 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
panel.classList.remove('settings')
panel.classList.add('centered')
panel.alt_content.innerHTML = "<textarea class='code'></textarea>"
const textarea: HTMLTextAreaElement =
panel.alt_content.querySelector('textarea')!
const textarea = document.createElement('textarea')
textarea.className = 'code'
panel.alt_content.replaceChildren(textarea)
const fDoneWith = function () {
panel.toggleAltContent(false)
panel.toggleFooterVisibility(true)

View File

@@ -0,0 +1,143 @@
/**
* Group Scope Invariant Tests
*
* Verifies that LGraphGroup geometry and state remain legacy-owned
* (LiteGraph-authoritative) and are not partially centralized in
* the layout store domain.
*
* Decision record: temp/node-layout-ssot-group-scope.md
*/
import { describe, expect, it } from 'vitest'
import { LGraph, LGraphGroup } from '@/lib/litegraph/src/litegraph'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import type { LayoutOperation } from '@/renderer/core/layout/types'
describe('LGraphGroup scope invariants', () => {
describe('layout store exclusion boundary', () => {
it('layoutStore operation types do not include group operations', () => {
const nodeOperationTypes: LayoutOperation['type'][] = [
'moveNode',
'resizeNode',
'setNodeZIndex',
'createNode',
'deleteNode',
'setNodeVisibility',
'batchUpdateBounds',
'createLink',
'deleteLink',
'createReroute',
'deleteReroute',
'moveReroute'
]
for (const opType of nodeOperationTypes) {
expect(opType).not.toContain('group')
expect(opType).not.toContain('Group')
}
})
it('layoutStore has no group-related public methods', () => {
const storeKeys = Object.getOwnPropertyNames(
Object.getPrototypeOf(layoutStore)
)
const groupMethods = storeKeys.filter(
(key) => key.toLowerCase().includes('group') && key !== 'constructor'
)
expect(groupMethods).toEqual([])
})
})
describe('group geometry remains self-contained', () => {
it('group move does not interact with layoutStore', () => {
const group = new LGraphGroup('test')
group.pos = [100, 200]
const initialPos = [...group.pos]
group.move(50, 30)
expect(group.pos[0]).toBe(initialPos[0] + 50)
expect(group.pos[1]).toBe(initialPos[1] + 30)
})
it('group resize does not interact with layoutStore', () => {
const group = new LGraphGroup('test')
group.size = [300, 200]
expect(group.resize(400, 300)).toBe(true)
expect(group.size[0]).toBe(400)
expect(group.size[1]).toBe(300)
})
it('group serialization round-trips without store involvement', () => {
const group = new LGraphGroup('Round Trip', 42)
group.pos = [150, 250]
group.size = [500, 400]
group.color = '#f00'
group.font_size = 32
const serialized = group.serialize()
const restored = new LGraphGroup()
restored.configure(serialized)
expect(restored.id).toBe(42)
expect(restored.title).toBe('Round Trip')
expect(restored.pos[0]).toBe(150)
expect(restored.pos[1]).toBe(250)
expect(restored.size[0]).toBe(500)
expect(restored.size[1]).toBe(400)
expect(restored.color).toBe('#f00')
expect(restored.font_size).toBe(32)
})
})
describe('group lifecycle is graph-owned', () => {
it('graph.add(group) does not create layoutStore entries', () => {
layoutStore.initializeFromLiteGraph([])
const graph = new LGraph()
const group = new LGraphGroup('test')
group.pos = [0, 0]
group.size = [200, 200]
graph.add(group)
const allNodes = layoutStore.getAllNodes()
expect(allNodes.value.size).toBe(0)
})
it('graph.remove(group) does not affect layoutStore', () => {
layoutStore.initializeFromLiteGraph([])
const graph = new LGraph()
const group = new LGraphGroup('test')
graph.add(group)
graph.remove(group)
const allNodes = layoutStore.getAllNodes()
expect(allNodes.value.size).toBe(0)
})
it('pinned group prevents move without store involvement', () => {
const group = new LGraphGroup('pinned test')
group.pos = [100, 100]
group.pin(true)
group.move(50, 50)
expect(group.pos[0]).toBe(100)
expect(group.pos[1]).toBe(100)
})
it('pinned group prevents resize without store involvement', () => {
const group = new LGraphGroup('pinned test')
group.size = [300, 200]
group.pin(true)
expect(group.resize(500, 400)).toBe(false)
expect(group.size[0]).toBe(300)
expect(group.size[1]).toBe(200)
})
})
})

View File

@@ -0,0 +1,233 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
layoutStore: {
getNodeLayoutRef: vi.fn(() => ({
value: {
id: '1',
position: { x: 0, y: 0 },
size: { width: 100, height: 50 },
zIndex: 0,
visible: true,
bounds: { x: 0, y: 0, width: 100, height: 50 }
}
})),
applyOperation: vi.fn(),
getCurrentSource: vi.fn(() => 'canvas'),
getCurrentActor: vi.fn(() => 'test'),
setSource: vi.fn(),
setActor: vi.fn(),
batchUpdateNodeBounds: vi.fn()
}
}))
describe('LGraphNode geometry mutation guards', () => {
let node: LGraphNode
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
Object.assign(LiteGraph, {
NODE_TITLE_HEIGHT: 20,
NODE_SLOT_HEIGHT: 15,
NODE_TEXT_SIZE: 14
})
node = new LGraphNode('TestNode')
node.id = 1
vi.clearAllMocks()
})
it('preserves constructor title value', () => {
expect(node.title).toBe('TestNode')
})
describe('pos setter triggers store mutation', () => {
it('calls moveNode on layoutStore via pos setter', () => {
node.pos = [200, 300]
expect(layoutStore.applyOperation).toHaveBeenCalledWith(
expect.objectContaining({
type: 'moveNode',
nodeId: '1',
position: { x: 200, y: 300 }
})
)
})
it('calls moveNode on layoutStore via setPos', () => {
node.setPos(400, 500)
expect(layoutStore.applyOperation).toHaveBeenCalledWith(
expect.objectContaining({
type: 'moveNode',
nodeId: '1',
position: { x: 400, y: 500 }
})
)
})
})
describe('size setter triggers store mutation', () => {
it('calls resizeNode on layoutStore via size setter', () => {
node.size = [250, 150]
expect(layoutStore.applyOperation).toHaveBeenCalledWith(
expect.objectContaining({
type: 'resizeNode',
nodeId: '1',
size: { width: 250, height: 150 }
})
)
})
})
describe('applyStoreProjection bypasses store mutations', () => {
it('updates backing arrays without triggering store writes', () => {
node.applyStoreProjection({ x: 300, y: 400 }, { width: 500, height: 200 })
expect(node.pos[0]).toBe(300)
expect(node.pos[1]).toBe(400)
expect(node.size[0]).toBe(500)
expect(node.size[1]).toBe(200)
// Must NOT have triggered any store operations
expect(layoutStore.applyOperation).not.toHaveBeenCalled()
})
it('returns true when values changed', () => {
const changed = node.applyStoreProjection(
{ x: 999, y: 888 },
{ width: 777, height: 666 }
)
expect(changed).toBe(true)
})
it('returns false when values are unchanged', () => {
node.applyStoreProjection({ x: 100, y: 200 }, { width: 300, height: 400 })
vi.clearAllMocks()
const changed = node.applyStoreProjection(
{ x: 100, y: 200 },
{ width: 300, height: 400 }
)
expect(changed).toBe(false)
expect(layoutStore.applyOperation).not.toHaveBeenCalled()
})
it('calls onResize when size changes', () => {
const onResize = vi.fn()
node.onResize = onResize
node.applyStoreProjection({ x: 0, y: 0 }, { width: 999, height: 888 })
expect(onResize).toHaveBeenCalledOnce()
})
it('does not call onResize when only position changes', () => {
const onResize = vi.fn()
node.onResize = onResize
// Set initial size
node.applyStoreProjection({ x: 0, y: 0 }, { width: 100, height: 50 })
onResize.mockClear()
// Change only position
node.applyStoreProjection({ x: 999, y: 888 }, { width: 100, height: 50 })
expect(onResize).not.toHaveBeenCalled()
})
})
describe('presentation property setters avoid graph trigger emissions', () => {
it('title setter does not emit graph trigger events', () => {
const graph = { trigger: vi.fn() }
node.graph = graph as unknown as LGraphNode['graph']
node.title = 'New Title'
expect(graph.trigger).not.toHaveBeenCalled()
})
it('mode setter does not emit graph trigger events', () => {
const graph = { trigger: vi.fn() }
node.graph = graph as unknown as LGraphNode['graph']
node.mode = 2
expect(graph.trigger).not.toHaveBeenCalled()
})
it('shape setter does not emit graph trigger events', () => {
const graph = { trigger: vi.fn() }
node.graph = graph as unknown as LGraphNode['graph']
node.shape = 'round'
expect(graph.trigger).not.toHaveBeenCalled()
})
it('color setter does not emit graph trigger events', () => {
const graph = { trigger: vi.fn() }
node.graph = graph as unknown as LGraphNode['graph']
node.color = '#ffffff'
expect(graph.trigger).not.toHaveBeenCalled()
})
it('bgcolor setter does not emit graph trigger events', () => {
const graph = { trigger: vi.fn() }
node.graph = graph as unknown as LGraphNode['graph']
node.bgcolor = '#222222'
expect(graph.trigger).not.toHaveBeenCalled()
})
it('showAdvanced setter does not emit graph trigger events', () => {
const graph = { trigger: vi.fn() }
node.graph = graph as unknown as LGraphNode['graph']
node.showAdvanced = true
expect(graph.trigger).not.toHaveBeenCalled()
})
it('flags.ghost mutation does not emit graph trigger events', () => {
const graph = { trigger: vi.fn() }
node.graph = graph as unknown as LGraphNode['graph']
node.flags.ghost = true
expect(graph.trigger).not.toHaveBeenCalled()
})
it('title setter does NOT fire when value is unchanged', () => {
const graph = { trigger: vi.fn() }
node.graph = graph as unknown as LGraphNode['graph']
node.title = 'Same'
graph.trigger.mockClear()
node.title = 'Same'
expect(graph.trigger).not.toHaveBeenCalled()
})
it('mode setter does NOT fire when value is unchanged', () => {
const graph = { trigger: vi.fn() }
node.graph = graph as unknown as LGraphNode['graph']
node.mode = 4
graph.trigger.mockClear()
node.mode = 4
expect(graph.trigger).not.toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,206 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import { useNodeDisplayStore } from '@/stores/nodeDisplayStore'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import { RenderShape } from '@/lib/litegraph/src/types/globalEnums'
vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
layoutStore: {
getNodeLayoutRef: vi.fn(() => ({
value: {
id: '1',
position: { x: 0, y: 0 },
size: { width: 100, height: 50 },
zIndex: 0,
visible: true,
bounds: { x: 0, y: 0, width: 100, height: 50 }
}
})),
applyOperation: vi.fn(),
getCurrentSource: vi.fn(() => 'canvas'),
getCurrentActor: vi.fn(() => 'test'),
setSource: vi.fn(),
setActor: vi.fn(),
batchUpdateNodeBounds: vi.fn()
}
}))
const TEST_GRAPH_ID = 'test-graph-id' as UUID
function attachMockGraph(node: LGraphNode): void {
node.graph = {
_version: 0,
rootGraph: { id: TEST_GRAPH_ID }
} as unknown as LGraphNode['graph']
}
describe('LGraphNode presentation tracking', () => {
let node: LGraphNode
let store: ReturnType<typeof useNodeDisplayStore>
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
store = useNodeDisplayStore()
Object.assign(LiteGraph, {
NODE_TITLE_HEIGHT: 20,
NODE_SLOT_HEIGHT: 15,
NODE_TEXT_SIZE: 14
})
node = new LGraphNode('TestNode')
node.id = 1
attachMockGraph(node)
store.registerNode(TEST_GRAPH_ID, '1', {
id: '1',
title: 'TestNode',
mode: 0,
flags: {}
})
vi.clearAllMocks()
})
describe('flag mutation → store sync', () => {
it('collapsed = true propagates to store', () => {
node.flags.collapsed = true
expect(store.getNode(TEST_GRAPH_ID, '1')?.flags.collapsed).toBe(true)
})
it('pinned = true propagates to store', () => {
node.flags.pinned = true
expect(store.getNode(TEST_GRAPH_ID, '1')?.flags.pinned).toBe(true)
})
it('ghost = true propagates to store', () => {
node.flags.ghost = true
expect(store.getNode(TEST_GRAPH_ID, '1')?.flags.ghost).toBe(true)
})
it('setting flag back to undefined propagates to store', () => {
node.flags.collapsed = true
expect(store.getNode(TEST_GRAPH_ID, '1')?.flags.collapsed).toBe(true)
node.flags.collapsed = undefined
expect(store.getNode(TEST_GRAPH_ID, '1')?.flags.collapsed).toBeUndefined()
})
})
describe('pinned → resizable side effect', () => {
it('pinned = true sets resizable to false', () => {
node.flags.pinned = true
expect(node.resizable).toBe(false)
})
it('pinned = false restores resizable to true', () => {
node.flags.pinned = true
node.flags.pinned = false
expect(node.resizable).toBe(true)
})
it('pinned = undefined restores resizable to true', () => {
node.flags.pinned = true
node.flags.pinned = undefined
expect(node.resizable).toBe(true)
})
it('pin() method toggles pinned and resizable', () => {
node.pin(true)
expect(node.pinned).toBe(true)
expect(node.resizable).toBe(false)
node.pin(false)
expect(node.pinned).toBe(false)
expect(node.resizable).toBe(true)
})
})
describe('serialization round-trip for flags', () => {
it('ghost = true appears in serialized flags', () => {
node.flags.ghost = true
const serialized = node.serialize()
expect(serialized.flags.ghost).toBe(true)
})
it('ghost set then cleared omits key from serialized flags', () => {
node.flags.ghost = true
node.flags.ghost = undefined
const serialized = node.serialize()
expect('ghost' in serialized.flags).toBe(false)
})
it('pinned set then cleared omits key from serialized flags', () => {
node.flags.pinned = true
node.flags.pinned = undefined
const serialized = node.serialize()
expect('pinned' in serialized.flags).toBe(false)
})
it('collapsed = true is serialized', () => {
node.flags.collapsed = true
const serialized = node.serialize()
expect(serialized.flags.collapsed).toBe(true)
})
it('collapsed = false is serialized (false is still a value)', () => {
node.flags.collapsed = true
node.flags.collapsed = false
const serialized = node.serialize()
expect(serialized.flags.collapsed).toBe(false)
})
it('collapsed = undefined omits key from serialized flags', () => {
node.flags.collapsed = true
node.flags.collapsed = undefined
const serialized = node.serialize()
expect('collapsed' in serialized.flags).toBe(false)
})
})
describe('presentation setter → store sync', () => {
it('title setter syncs to store', () => {
node.title = 'New'
expect(store.getNode(TEST_GRAPH_ID, '1')?.title).toBe('New')
})
it('mode setter syncs to store', () => {
node.mode = 2
expect(store.getNode(TEST_GRAPH_ID, '1')?.mode).toBe(2)
})
it('color setter syncs to store', () => {
node.color = '#ff0000'
expect(store.getNode(TEST_GRAPH_ID, '1')?.color).toBe('#ff0000')
})
it('bgcolor setter syncs to store', () => {
node.bgcolor = '#00ff00'
expect(store.getNode(TEST_GRAPH_ID, '1')?.bgcolor).toBe('#00ff00')
})
it('shape setter converts string and syncs to store', () => {
node.shape = 'round'
expect(store.getNode(TEST_GRAPH_ID, '1')?.shape).toBe(RenderShape.ROUND)
})
it('showAdvanced setter syncs to store', () => {
node.showAdvanced = true
expect(store.getNode(TEST_GRAPH_ID, '1')?.showAdvanced).toBe(true)
})
it('same-value title assignment does not update store', () => {
const updateSpy = vi.spyOn(store, 'updateNode')
node.title = 'X'
expect(store.getNode(TEST_GRAPH_ID, '1')?.title).toBe('X')
expect(updateSpy).toHaveBeenCalledTimes(1)
node.title = 'X'
expect(updateSpy).toHaveBeenCalledTimes(1)
})
})
})

View File

@@ -1,13 +1,19 @@
import { toValue } from 'vue'
import { LGraphNodeProperties } from '@/lib/litegraph/src/LGraphNodeProperties'
import {
calculateInputSlotPosFromSlot,
getSlotPosition
} from '@/renderer/core/canvas/litegraph/slotCalculations'
import type { SlotPositionContext } from '@/renderer/core/canvas/litegraph/slotCalculations'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import {
extractLayoutFromSerialized,
extractPresentationFromSerialized
} from '@/renderer/core/layout/persistence/layoutPersistenceAdapter'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { LayoutSource } from '@/renderer/core/layout/types'
import { useNodeDisplayStore } from '@/stores/nodeDisplayStore'
import type { NodeDisplayUpdate } from '@/stores/nodeDisplayStore'
import { adjustColor } from '@/utils/colorUtil'
import type { ColorAdjustOptions } from '@/utils/colorUtil'
import {
@@ -255,7 +261,84 @@ export class LGraphNode
static keepAllLinksOnBypass: boolean = false
/** The title text of the node. */
title: string
private _title: string = ''
private _mode: LGraphEventMode = LGraphEventMode.ALWAYS
private _color?: string
private _bgcolor?: string
private _showAdvanced?: boolean
private _flags: INodeFlags = {}
private applyPresentationChange(
oldValue: unknown,
newValue: unknown,
update: NodeDisplayUpdate
): void {
if (oldValue === newValue) return
const graphId = this.graph?.rootGraph?.id
if (!graphId) return
useNodeDisplayStore().updateNode(graphId, String(this.id), update)
}
private setTrackedFlagEnumerable(
flag: keyof Pick<INodeFlags, 'collapsed' | 'pinned' | 'ghost'>,
value: boolean | undefined
): void {
const descriptor = Object.getOwnPropertyDescriptor(this._flags, flag)
const shouldBeEnumerable = value !== undefined
if (!descriptor || descriptor.enumerable === shouldBeEnumerable) return
Object.defineProperty(this._flags, flag, {
...descriptor,
enumerable: shouldBeEnumerable
})
}
private ensureTrackedFlagProperty(
flag: keyof Pick<INodeFlags, 'collapsed' | 'pinned' | 'ghost'>
): void {
const current = this._flags[flag]
let value =
typeof current === 'boolean' || current === undefined
? current
: undefined
Object.defineProperty(this._flags, flag, {
get: () => value,
set: (newValue: boolean | undefined) => {
const oldValue = value
if (oldValue === newValue) return
value = newValue
this.setTrackedFlagEnumerable(flag, newValue)
const flagsUpdate: NonNullable<NodeDisplayUpdate['flags']> = {}
flagsUpdate[flag] = newValue
this.applyPresentationChange(oldValue, newValue, { flags: flagsUpdate })
},
enumerable: value !== undefined,
configurable: true
})
}
private initializeTrackedFlags(): void {
this.ensureTrackedFlagProperty('collapsed')
this.ensureTrackedFlagProperty('pinned')
this.ensureTrackedFlagProperty('ghost')
}
get title(): string {
return this._title
}
set title(value: string) {
const oldValue = this._title
this._title = value
this.applyPresentationChange(oldValue, value, { title: value })
}
/**
* The font style used to render the node's title text.
*/
@@ -282,11 +365,17 @@ export class LGraphNode
properties: Dictionary<NodeProperty | undefined> = {}
properties_info: INodePropertyInfo[] = []
flags: INodeFlags = {}
widgets?: IBaseWidget[]
/** Property manager for this node */
changeTracker: LGraphNodeProperties
get flags(): INodeFlags {
return this._flags
}
set flags(value: INodeFlags) {
this._flags = value || {}
this.initializeTrackedFlags()
}
widgets?: IBaseWidget[]
/**
* The amount of space available for widgets to grow into.
@@ -298,19 +387,46 @@ export class LGraphNode
/** Execution order, automatically computed during run @see {@link LGraph.computeExecutionOrder} */
order: number = 0
mode: LGraphEventMode = LGraphEventMode.ALWAYS
get mode(): LGraphEventMode {
return this._mode
}
set mode(value: LGraphEventMode) {
const oldValue = this._mode
this._mode = value
this.applyPresentationChange(oldValue, value, { mode: value })
}
last_serialization?: ISerialisedNode
serialize_widgets?: boolean
/**
* The overridden fg color used to render the node.
* @see {@link renderingColor}
*/
color?: string
get color(): string | undefined {
return this._color
}
set color(value: string | undefined) {
const oldValue = this._color
this._color = value
this.applyPresentationChange(oldValue, value, { color: value })
}
/**
* The overridden bg color used to render the node.
* @see {@link renderingBgColor}
*/
bgcolor?: string
get bgcolor(): string | undefined {
return this._bgcolor
}
set bgcolor(value: string | undefined) {
const oldValue = this._bgcolor
this._bgcolor = value
this.applyPresentationChange(oldValue, value, { bgcolor: value })
}
/**
* The overridden box color used to render the node.
* @see {@link renderingBoxColor}
@@ -425,7 +541,15 @@ export class LGraphNode
_shape?: RenderShape
mouseOver?: IMouseOverData
redraw_on_mouse?: boolean
resizable?: boolean
private _resizable?: boolean
get resizable(): boolean {
return !this.pinned && this._resizable !== false
}
set resizable(value: boolean | undefined) {
this._resizable = value
}
clonable?: boolean
_relative_id?: number
clip_area?: boolean
@@ -434,7 +558,18 @@ export class LGraphNode
removable?: boolean
block_delete?: boolean
selected?: boolean
showAdvanced?: boolean
get showAdvanced(): boolean | undefined {
return this._showAdvanced
}
set showAdvanced(value: boolean | undefined) {
const oldValue = this._showAdvanced
this._showAdvanced = value
this.applyPresentationChange(oldValue, value, {
showAdvanced: value
})
}
declare comfyDynamic?: Record<string, object>
declare comfyClass?: string
@@ -503,6 +638,33 @@ export class LGraphNode
this.pos = [x, y]
}
/**
* Apply position and size from the layout store without triggering
* store mutations. Used exclusively by store→LiteGraph projection
* (e.g. useLayoutSync) to avoid feedback loops.
*/
applyStoreProjection(
pos: { x: number; y: number },
size: { width: number; height: number }
): boolean {
let changed = false
if (this._pos[0] !== pos.x || this._pos[1] !== pos.y) {
this._pos[0] = pos.x
this._pos[1] = pos.y
changed = true
}
if (this._size[0] !== size.width || this._size[1] !== size.height) {
this._size[0] = size.width
this._size[1] = size.height
this.onResize?.(this._size)
changed = true
}
return changed
}
public get size() {
return this._size
}
@@ -553,14 +715,9 @@ export class LGraphNode
default:
this._shape = v
}
if (oldValue !== this._shape) {
this.graph?.trigger('node:property:changed', {
nodeId: this.id,
property: 'shape',
oldValue,
newValue: this._shape
})
}
this.applyPresentationChange(oldValue, this._shape, {
shape: this._shape
})
}
/**
@@ -807,12 +964,11 @@ export class LGraphNode
this.type = type ?? ''
this.size = [LiteGraph.NODE_WIDTH, 60]
this.pos = [10, 10]
this.initializeTrackedFlags()
this.strokeStyles = {
error: this._getErrorStrokeStyle,
selected: this._getSelectedStrokeStyle
}
// Initialize property manager with tracked properties
this.changeTracker = new LGraphNodeProperties(this)
}
/** Internal callback for subgraph nodes. Do not implement externally. */
@@ -916,9 +1072,6 @@ export class LGraphNode
}
}
// Sync the state of this.resizable.
if (this.pinned) this.resizable = false
if (this.widgets_up) {
console.warn(
`[LiteGraph] Node type "${this.type}" uses deprecated property "widgets_up". ` +
@@ -930,6 +1083,40 @@ export class LGraphNode
this.onConfigure?.(info)
}
/**
* Project serialized node data into layout and presentation stores.
*
* Store initialization is primarily handled by the node lifecycle bridge
* (useGraphNodeManager.handleNodeAdded → initializeVueNodeLayout), which
* fires on graph.add(). Property setters (title, mode, flags, etc.)
* incrementally sync presentation state during configure() via
* useNodeDisplayStore().updateNode(). This method provides an explicit
* bulk-sync alternative for scenarios needing deterministic projection.
*
* @param info The serialized node data
* @param nodeIndex Optional z-order index (array position during graph load)
*/
notifyStoresAfterConfigure(info: ISerialisedNode, nodeIndex?: number): void {
const nodeId = String(this.id)
const layout = extractLayoutFromSerialized(info, nodeIndex)
layoutStore.applyOperation({
type: 'createNode',
entity: 'node',
nodeId,
layout,
timestamp: Date.now(),
source: layoutStore.getCurrentSource(),
actor: layoutStore.getCurrentActor()
})
const presentation = extractPresentationFromSerialized(info)
const graphId = this.graph?.rootGraph?.id
if (graphId) {
useNodeDisplayStore().registerNode(graphId, nodeId, presentation)
}
}
/**
* serialize the content
*/
@@ -1860,7 +2047,7 @@ export class LGraphNode
canvasX: number,
canvasY: number
): CompassCorners | undefined {
if (this.resizable === false) return
if (!this.resizable) return
const { boundingRect } = this
if (!boundingRect.containsXy(canvasX, canvasY)) return
@@ -3553,7 +3740,6 @@ export class LGraphNode
this.graph._version++
this.flags.pinned = v ?? !this.flags.pinned
this.resizable = !this.pinned
if (!this.pinned) this.flags.pinned = undefined
}
@@ -3800,7 +3986,7 @@ export class LGraphNode
if (this.collapsed) {
// For collapsed nodes, limit to 20 chars as before
displayTitle = title.substr(0, 20)
displayTitle = title.slice(0, 20)
} else if (availableWidth > 0) {
// For regular nodes, truncate based on available width
displayTitle = truncateText(ctx, title, availableWidth)

View File

@@ -1,155 +0,0 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { LGraphNodeProperties } from '@/lib/litegraph/src/LGraphNodeProperties'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
createMockLGraph,
createMockLGraphNode
} from '@/utils/__tests__/litegraphTestUtils'
describe('LGraphNodeProperties', () => {
let mockNode: LGraphNode
let mockGraph: LGraph
beforeEach(() => {
mockGraph = createMockLGraph()
mockNode = createMockLGraphNode({
id: 123,
title: 'Test Node',
flags: {},
graph: mockGraph
})
})
describe('property tracking', () => {
it('should track changes to existing properties', () => {
new LGraphNodeProperties(mockNode)
mockNode.title = 'New Title'
expect(mockGraph.trigger).toHaveBeenCalledWith('node:property:changed', {
nodeId: mockNode.id,
property: 'title',
oldValue: 'Test Node',
newValue: 'New Title'
})
})
it('should track changes to nested properties', () => {
new LGraphNodeProperties(mockNode)
mockNode.flags.collapsed = true
expect(mockGraph.trigger).toHaveBeenCalledWith('node:property:changed', {
nodeId: mockNode.id,
property: 'flags.collapsed',
oldValue: undefined,
newValue: true
})
})
it('should emit event when value is set to the same value', () => {
new LGraphNodeProperties(mockNode)
mockNode.title = 'Test Node' // Same value as original
expect(mockGraph.trigger).toHaveBeenCalledTimes(1)
})
it('should not emit events when node has no graph', () => {
mockNode.graph = null
new LGraphNodeProperties(mockNode)
// Should not throw
expect(() => {
mockNode.title = 'New Title'
}).not.toThrow()
})
})
describe('isTracked', () => {
it('should correctly identify tracked properties', () => {
const propManager = new LGraphNodeProperties(mockNode)
expect(propManager.isTracked('title')).toBe(true)
expect(propManager.isTracked('flags.collapsed')).toBe(true)
expect(propManager.isTracked('untracked')).toBe(false)
})
})
describe('serialization behavior', () => {
it('should not make non-existent properties enumerable', () => {
new LGraphNodeProperties(mockNode)
// flags.collapsed doesn't exist initially
const descriptor = Object.getOwnPropertyDescriptor(
mockNode.flags,
'collapsed'
)
expect(descriptor?.enumerable).toBe(false)
})
it('should make properties enumerable when set to non-default values', () => {
new LGraphNodeProperties(mockNode)
mockNode.flags.collapsed = true
const descriptor = Object.getOwnPropertyDescriptor(
mockNode.flags,
'collapsed'
)
expect(descriptor?.enumerable).toBe(true)
})
it('should make properties non-enumerable when set back to undefined', () => {
new LGraphNodeProperties(mockNode)
mockNode.flags.collapsed = true
mockNode.flags.collapsed = undefined
const descriptor = Object.getOwnPropertyDescriptor(
mockNode.flags,
'collapsed'
)
expect(descriptor?.enumerable).toBe(false)
})
it('should keep existing properties enumerable', () => {
// title exists initially
const initialDescriptor = Object.getOwnPropertyDescriptor(
mockNode,
'title'
)
expect(initialDescriptor?.enumerable).toBe(true)
new LGraphNodeProperties(mockNode)
const afterDescriptor = Object.getOwnPropertyDescriptor(mockNode, 'title')
expect(afterDescriptor?.enumerable).toBe(true)
})
it('should only include non-undefined values in JSON.stringify', () => {
new LGraphNodeProperties(mockNode)
// Initially, flags.collapsed shouldn't appear
let json = JSON.parse(JSON.stringify(mockNode))
expect(json.flags.collapsed).toBeUndefined()
// After setting to true, it should appear
mockNode.flags.collapsed = true
json = JSON.parse(JSON.stringify(mockNode))
expect(json.flags.collapsed).toBe(true)
// After setting to false, it should still appear (false is not undefined)
mockNode.flags.collapsed = false
json = JSON.parse(JSON.stringify(mockNode))
expect(json.flags.collapsed).toBe(false)
// After setting back to undefined, it should disappear
mockNode.flags.collapsed = undefined
json = JSON.parse(JSON.stringify(mockNode))
expect(json.flags.collapsed).toBeUndefined()
})
})
})

View File

@@ -1,228 +0,0 @@
import type { LGraphNode } from './LGraphNode'
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
/**
* Default properties to track
*/
const DEFAULT_TRACKED_PROPERTIES: string[] = [
'title',
'flags.collapsed',
'flags.pinned',
'mode',
'color',
'bgcolor',
'shape',
'showAdvanced'
]
/**
* Manages node properties with optional change tracking and instrumentation.
*/
export class LGraphNodeProperties {
/** The node this property manager belongs to */
node: LGraphNode
/** Set of property paths that have been instrumented */
private _instrumentedPaths = new Set<string>()
constructor(node: LGraphNode) {
this.node = node
this._setupInstrumentation()
}
/**
* Sets up property instrumentation for all tracked properties
*/
private _setupInstrumentation(): void {
for (const path of DEFAULT_TRACKED_PROPERTIES) {
this._instrumentProperty(path)
}
}
private _resolveTargetObject(parts: string[]): {
targetObject: Record<string, unknown>
propertyName: string
} {
// LGraphNode supports dynamic property access at runtime
let targetObject: Record<string, unknown> = this.node as unknown as Record<
string,
unknown
>
if (parts.length === 1) {
return { targetObject, propertyName: parts[0] }
}
for (let i = 0; i < parts.length - 1; i++) {
const key = parts[i]
const next = targetObject[key]
if (isRecord(next)) {
targetObject = next
}
}
return {
targetObject,
propertyName: parts[parts.length - 1]
}
}
/**
* Instruments a single property to track changes
*/
private _instrumentProperty(path: string): void {
const parts = path.split('.')
if (parts.length > 1) {
this._ensureNestedPath(path)
}
const { targetObject, propertyName } = this._resolveTargetObject(parts)
const hasProperty = Object.prototype.hasOwnProperty.call(
targetObject,
propertyName
)
const currentValue = targetObject[propertyName]
if (!hasProperty) {
let value: unknown = undefined
Object.defineProperty(targetObject, propertyName, {
get: () => value,
set: (newValue: unknown) => {
const oldValue = value
value = newValue
this._emitPropertyChange(path, oldValue, newValue)
// Update enumerable: true for non-undefined values, false for undefined
const shouldBeEnumerable = newValue !== undefined
const currentDescriptor = Object.getOwnPropertyDescriptor(
targetObject,
propertyName
)
if (
currentDescriptor &&
currentDescriptor.enumerable !== shouldBeEnumerable
) {
Object.defineProperty(targetObject, propertyName, {
...currentDescriptor,
enumerable: shouldBeEnumerable
})
}
},
enumerable: false,
configurable: true
})
} else {
Object.defineProperty(
targetObject,
propertyName,
this._createInstrumentedDescriptor(path, currentValue)
)
}
this._instrumentedPaths.add(path)
}
/**
* Creates a property descriptor that emits change events
*/
private _createInstrumentedDescriptor(
propertyPath: string,
initialValue: unknown
): PropertyDescriptor {
return this._createInstrumentedDescriptorTyped(propertyPath, initialValue)
}
private _createInstrumentedDescriptorTyped<TValue>(
propertyPath: string,
initialValue: TValue
): PropertyDescriptor {
let value: TValue = initialValue
return {
get: () => value,
set: (newValue: TValue) => {
const oldValue = value
value = newValue
this._emitPropertyChange(propertyPath, oldValue, newValue)
},
enumerable: true,
configurable: true
}
}
/**
* Emits a property change event if the node is connected to a graph
*/
private _emitPropertyChange(
propertyPath: string,
oldValue: unknown,
newValue: unknown
): void {
this._emitPropertyChangeTyped(propertyPath, oldValue, newValue)
}
private _emitPropertyChangeTyped<TValue>(
propertyPath: string,
oldValue: TValue,
newValue: TValue
): void {
this.node.graph?.trigger('node:property:changed', {
nodeId: this.node.id,
property: propertyPath,
oldValue,
newValue
})
}
/**
* Ensures parent objects exist for nested properties
*/
private _ensureNestedPath(path: string): void {
const parts = path.split('.')
// LGraphNode supports dynamic property access at runtime
let current: Record<string, unknown> = this.node as unknown as Record<
string,
unknown
>
// Create all parent objects except the last property
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i]
if (!current[part]) {
current[part] = {}
}
const next = current[part]
if (isRecord(next)) {
current = next
}
}
}
/**
* Checks if a property is being tracked
*/
isTracked(path: string): boolean {
return this._instrumentedPaths.has(path)
}
/**
* Gets the list of tracked properties
*/
getTrackedProperties(): string[] {
return [...DEFAULT_TRACKED_PROPERTIES]
}
/**
* Custom toJSON method for JSON.stringify
* Returns undefined to exclude from serialization since we only use defaults
*/
toJSON(): undefined {
return undefined
}
}

View File

@@ -1,4 +1,6 @@
// oxlint-disable no-empty-pattern
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { test as baseTest } from 'vitest'
import { LGraph } from '@/lib/litegraph/src/LGraph'
@@ -33,13 +35,22 @@ interface DirtyFixtures {
basicSerialisableGraph: SerialisableGraph
}
export const test = baseTest.extend<LitegraphFixtures>({
const withPinia = baseTest.extend({
// Auto-fixture: sets up Pinia for every test
_pinia: [
async ({}, use) => {
setActivePinia(createTestingPinia({ stubActions: false }))
await use(undefined)
},
{ auto: true }
]
})
export const test = withPinia.extend<LitegraphFixtures>({
minimalGraph: async ({}, use) => {
// Before each test function
const serialisable = structuredClone(minimalSerialisableGraph)
const lGraph = new LGraph(serialisable)
// use the fixture value
await use(lGraph)
},
minimalSerialisableGraph: structuredClone(minimalSerialisableGraph),

View File

@@ -107,8 +107,6 @@ export {
LGraph,
type GroupNodeConfigEntry,
type GroupNodeWorkflowData,
type LGraphTriggerAction,
type LGraphTriggerParam,
type GraphAddOptions
} from './LGraph'
export type { LGraphTriggerEvent } from './types/graphTriggers'

View File

@@ -1,14 +1,6 @@
import type { NodeId } from '../LGraphNode'
import type { NodeSlotType } from './globalEnums'
interface NodePropertyChangedEvent {
type: 'node:property:changed'
nodeId: NodeId
property: string
oldValue: unknown
newValue: unknown
}
interface NodeSlotErrorsChangedEvent {
type: 'node:slot-errors:changed'
nodeId: NodeId
@@ -24,7 +16,6 @@ interface NodeSlotLinksChangedEvent {
}
export type LGraphTriggerEvent =
| NodePropertyChangedEvent
| NodeSlotErrorsChangedEvent
| NodeSlotLinksChangedEvent

View File

@@ -1,3 +1,5 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
@@ -44,6 +46,7 @@ describe('ColorWidget', () => {
vi.useFakeTimers()
// Reset modules to get fresh globalColorInput state
vi.resetModules()
setActivePinia(createTestingPinia({ stubActions: false }))
const litegraph = await import('@/lib/litegraph/src/litegraph')
LGraphNode = litegraph.LGraphNode

View File

@@ -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": "تعديل وتشغيل",
@@ -3376,7 +3373,10 @@
"addedToWorkspace": "تمت إضافتك إلى {workspaceName}",
"inviteAccepted": "تم قبول الدعوة",
"inviteFailed": "فشل في قبول الدعوة",
"switchFailed": "فشل في تبديل مساحة العمل. يرجى المحاولة مرة أخرى.",
"unsavedChanges": {
"message": "لديك تغييرات غير محفوظة. هل تريد تجاهلها والانتقال إلى مساحة عمل أخرى؟",
"title": "تغييرات غير محفوظة"
},
"viewWorkspace": "عرض مساحة العمل"
},
"workspaceAuth": {

View File

@@ -400,7 +400,7 @@
"name": "تخطيط شريط التبويبات",
"options": {
"Default": "افتراضي",
"Legacy": "تقليدي"
"Integrated": "مُدمج"
},
"tooltip": "يتحكم في تخطيط شريط التبويبات. \"مُدمج\" ينقل عناصر المساعدة والتحكمات الخاصة بالمستخدم إلى منطقة شريط التبويبات."
},

View File

@@ -763,10 +763,7 @@
"filterAudio": "Audio",
"filter3D": "3D",
"filterText": "Text",
"viewSettings": "View settings",
"groupByJob": "Group assets by job",
"showAssetNames": "Show asset names",
"showAssetDetails": "Show asset details"
"viewSettings": "View settings"
},
"backToAssets": "Back to all assets",
"folderView": {
@@ -3182,8 +3179,6 @@
"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.",
@@ -3228,19 +3223,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"
@@ -3423,11 +3405,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": {

View File

@@ -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": {

View File

@@ -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",
@@ -3376,7 +3373,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": {

View File

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

View File

@@ -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": "جریان کاری شما خالی است. ابتدا باید چند node اضافه کنید تا بتوانید یک برنامه بسازید.",
"enterNodeGraph": "ورود به گراف node",
"giveFeedback": "ارسال بازخورد",
"graphMode": "حالت گراف",
"hasCreditCost": "نیازمند اعتبار اضافی",
"linearMode": "حالت برنامه",
"loadTemplate": "بارگذاری قالب",
"mobileControls": "ویرایش و اجرا",
@@ -3388,7 +3385,10 @@
"addedToWorkspace": "شما به {workspaceName} اضافه شدید",
"inviteAccepted": "دعوت پذیرفته شد",
"inviteFailed": "پذیرش دعوت ناموفق بود",
"switchFailed": "تغییر ورک‌اسپیس ناموفق بود. لطفاً دوباره تلاش کنید.",
"unsavedChanges": {
"message": "شما تغییرات ذخیره‌نشده دارید. آیا می‌خواهید آن‌ها را رها کرده و فضای کاری را تغییر دهید؟",
"title": "تغییرات ذخیره‌نشده"
},
"viewWorkspace": "مشاهده workspace"
},
"workspaceAuth": {

View File

@@ -400,7 +400,7 @@
"name": "چیدمان نوار تب",
"options": {
"Default": "پیش‌فرض",
"Legacy": "قدیمی"
"Integrated": "یکپارچه"
},
"tooltip": "چیدمان نوار تب را کنترل می‌کند. «یکپارچه» کنترل‌های راهنما و کاربر را به ناحیه نوار تب منتقل می‌کند."
},

View File

@@ -952,7 +952,6 @@
"collapseAll": "Tout réduire",
"color": "Couleur",
"comfy": "Comfy",
"comfyCloud": "Comfy Cloud",
"comfyOrgLogoAlt": "Logo ComfyOrg",
"comingSoon": "Bientôt disponible",
"command": "Commande",
@@ -1460,14 +1459,12 @@
"unknownWidget": "Widget non visible"
},
"cancelThisRun": "Annuler cette exécution",
"deleteAllAssets": "Supprimer toutes les ressources de cette exécution",
"downloadAll": "Tout télécharger",
"dragAndDropImage": "Glissez-déposez une image",
"emptyWorkflowExplanation": "Votre workflow est vide. Vous devez d'abord ajouter des nodes pour commencer à créer une application.",
"enterNodeGraph": "Entrer dans le graphique de nœuds",
"giveFeedback": "Donner un avis",
"graphMode": "Mode graphique",
"hasCreditCost": "Nécessite des crédits supplémentaires",
"linearMode": "Mode App",
"loadTemplate": "Charger un modèle",
"mobileControls": "Éditer & Exécuter",
@@ -3376,7 +3373,10 @@
"addedToWorkspace": "Vous avez été ajouté à {workspaceName}",
"inviteAccepted": "Invitation acceptée",
"inviteFailed": "Échec de l'acceptation de l'invitation",
"switchFailed": "Échec du changement despace de travail. Veuillez réessayer.",
"unsavedChanges": {
"message": "Vous avez des modifications non enregistrées. Voulez-vous les abandonner et changer despace de travail ?",
"title": "Modifications non enregistrées"
},
"viewWorkspace": "Voir lespace de travail"
},
"workspaceAuth": {

View File

@@ -400,7 +400,7 @@
"name": "Disposition de la barre donglets",
"options": {
"Default": "Par défaut",
"Legacy": "Héritage"
"Integrated": "Intégrée"
},
"tooltip": "Contrôle la disposition de la barre donglets. « Intégrée » déplace les contrôles Aide et Utilisateur dans la zone de la barre donglets."
},

View File

@@ -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": "編集と実行",
@@ -3376,7 +3373,10 @@
"addedToWorkspace": "{workspaceName}に追加されました",
"inviteAccepted": "招待を承諾しました",
"inviteFailed": "招待の承諾に失敗しました",
"switchFailed": "ワークスペースの切り替えに失敗しました。もう一度お試しください。",
"unsavedChanges": {
"message": "未保存の変更があります。破棄してワークスペースを切り替えますか?",
"title": "未保存の変更"
},
"viewWorkspace": "ワークスペースを見る"
},
"workspaceAuth": {

View File

@@ -400,7 +400,7 @@
"name": "タブバーのレイアウト",
"options": {
"Default": "デフォルト",
"Legacy": "レガシー"
"Integrated": "統合"
},
"tooltip": "タブバーのレイアウトを制御します。「統合」を選択すると、ヘルプとユーザーコントロールがタブバーエリアに移動します。"
},

View File

@@ -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": "편집 및 실행",
@@ -3376,7 +3373,10 @@
"addedToWorkspace": "{workspaceName} 워크스페이스에 추가되었습니다",
"inviteAccepted": "초대 수락됨",
"inviteFailed": "초대 수락에 실패했습니다",
"switchFailed": "워크스페이스 전환에 실패했습니다. 다시 시도해 주세요.",
"unsavedChanges": {
"message": "저장되지 않은 변경 사항이 있습니다. 변경 사항을 취소하고 워크스페이스를 전환하시겠습니까?",
"title": "저장되지 않은 변경 사항"
},
"viewWorkspace": "워크스페이스 보기"
},
"workspaceAuth": {

View File

@@ -400,7 +400,7 @@
"name": "탭 바 레이아웃",
"options": {
"Default": "기본값",
"Legacy": "레거시"
"Integrated": "통합"
},
"tooltip": "탭 바의 레이아웃을 제어합니다. \"통합\"을 선택하면 도움말과 사용자 컨트롤이 탭 바 영역으로 이동합니다."
},

View File

@@ -952,7 +952,6 @@
"collapseAll": "Recolher tudo",
"color": "Cor",
"comfy": "Comfy",
"comfyCloud": "Comfy Cloud",
"comfyOrgLogoAlt": "Logo do ComfyOrg",
"comingSoon": "Em breve",
"command": "Comando",
@@ -1460,14 +1459,12 @@
"unknownWidget": "Widget não visível"
},
"cancelThisRun": "Cancelar esta execução",
"deleteAllAssets": "Excluir todos os ativos desta execução",
"downloadAll": "Baixar tudo",
"dragAndDropImage": "Arraste e solte uma imagem",
"emptyWorkflowExplanation": "Seu fluxo de trabalho está vazio. Você precisa adicionar alguns nós primeiro para começar a construir um app.",
"enterNodeGraph": "Entrar no grafo de nós",
"giveFeedback": "Enviar feedback",
"graphMode": "Modo Gráfico",
"hasCreditCost": "Requer créditos adicionais",
"linearMode": "Modo App",
"loadTemplate": "Carregar um modelo",
"mobileControls": "Editar e Executar",
@@ -3388,7 +3385,10 @@
"addedToWorkspace": "Você foi adicionado ao {workspaceName}",
"inviteAccepted": "Convite aceito",
"inviteFailed": "Falha ao aceitar convite",
"switchFailed": "Falha ao alternar o workspace. Por favor, tente novamente.",
"unsavedChanges": {
"message": "Você tem alterações não salvas. Deseja descartá-las e trocar de espaço de trabalho?",
"title": "Alterações não salvas"
},
"viewWorkspace": "Ver workspace"
},
"workspaceAuth": {

View File

@@ -400,7 +400,7 @@
"name": "Layout da Barra de Abas",
"options": {
"Default": "Padrão",
"Legacy": "Legado"
"Integrated": "Integrado"
},
"tooltip": "Controla o layout da barra de abas. \"Integrado\" move os controles de Ajuda e Usuário para a área da barra de abas."
},

View File

@@ -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": "Редактировать и запустить",
@@ -3376,7 +3373,10 @@
"addedToWorkspace": "Вы были добавлены в {workspaceName}",
"inviteAccepted": "Приглашение принято",
"inviteFailed": "Не удалось принять приглашение",
"switchFailed": "Не удалось переключить рабочее пространство. Пожалуйста, попробуйте еще раз.",
"unsavedChanges": {
"message": "У вас есть несохранённые изменения. Хотите их отменить и переключиться на другое рабочее пространство?",
"title": "Несохранённые изменения"
},
"viewWorkspace": "Просмотреть рабочее пространство"
},
"workspaceAuth": {

View File

@@ -400,7 +400,7 @@
"name": "Макет панели вкладок",
"options": {
"Default": "По умолчанию",
"Legacy": "Классический"
"Integrated": "Интегрированный"
},
"tooltip": "Управляет расположением панели вкладок. «Интегрированный» перемещает элементы управления Справкой и Пользователем в область панели вкладок."
},

View File

@@ -952,7 +952,6 @@
"collapseAll": "Hepsini daralt",
"color": "Renk",
"comfy": "Comfy",
"comfyCloud": "Comfy Cloud",
"comfyOrgLogoAlt": "ComfyOrg Logosu",
"comingSoon": "Çok Yakında",
"command": "Komut",
@@ -1460,14 +1459,12 @@
"unknownWidget": "Widget görünür değil"
},
"cancelThisRun": "Bu çalıştırmayı iptal et",
"deleteAllAssets": "Bu çalışmadaki tüm varlıkları sil",
"downloadAll": "Tümünü İndir",
"dragAndDropImage": "Bir görseli sürükleyip bırakın",
"emptyWorkflowExplanation": "Çalışma akışınız boş. Bir uygulama oluşturmaya başlamak için önce bazı düğümler eklemelisiniz.",
"enterNodeGraph": "Düğüm grafiğine gir",
"giveFeedback": "Geri bildirim ver",
"graphMode": "Grafik Modu",
"hasCreditCost": "Ekstra kredi gerektirir",
"linearMode": "Uygulama Modu",
"loadTemplate": "Şablon yükle",
"mobileControls": "Düzenle ve Çalıştır",
@@ -3376,7 +3373,10 @@
"addedToWorkspace": "{workspaceName} çalışma alanına eklendiniz",
"inviteAccepted": "Davet kabul edildi",
"inviteFailed": "Davet kabul edilemedi",
"switchFailed": "Çalışma alanı değiştirilemedi. Lütfen tekrar deneyin.",
"unsavedChanges": {
"message": "Kaydedilmemiş değişiklikleriniz var. Bunları iptal edip çalışma alanlarını değiştirmek istiyor musunuz?",
"title": "Kaydedilmemiş Değişiklikler"
},
"viewWorkspace": "Çalışma alanını görüntüle"
},
"workspaceAuth": {

View File

@@ -400,7 +400,7 @@
"name": "Sekme Çubuğu Düzeni",
"options": {
"Default": "Varsayılan",
"Legacy": "Klasik"
"Integrated": "Entegre"
},
"tooltip": "Sekme çubuğu düzenini kontrol eder. \"Entegre\" seçeneği, Yardım ve Kullanıcı kontrollerini sekme çubuğu alanına taşır."
},

View File

@@ -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": "App 模式",
"loadTemplate": "載入範本",
"mobileControls": "編輯與執行",
@@ -3376,7 +3373,10 @@
"addedToWorkspace": "你已被加入 {workspaceName}",
"inviteAccepted": "已接受邀請",
"inviteFailed": "接受邀請失敗",
"switchFailed": "切換工作區失敗。請再試一次。",
"unsavedChanges": {
"message": "您有未儲存的變更。是否要捨棄這些變更並切換工作區?",
"title": "未儲存的變更"
},
"viewWorkspace": "檢視工作區"
},
"workspaceAuth": {

View File

@@ -400,7 +400,7 @@
"name": "分頁列佈局",
"options": {
"Default": "預設",
"Legacy": "傳統"
"Integrated": "整合"
},
"tooltip": "控制分頁列的佈局。「整合」會將說明和使用者控制項移至分頁列區域。"
},

View File

@@ -952,7 +952,6 @@
"collapseAll": "全部折叠",
"color": "颜色",
"comfy": "舒适",
"comfyCloud": "Comfy Cloud",
"comfyOrgLogoAlt": "ComfyOrg 徽标",
"comingSoon": "即将推出",
"command": "指令",
@@ -1460,14 +1459,12 @@
"unknownWidget": "组件不可见"
},
"cancelThisRun": "取消本次运行",
"deleteAllAssets": "删除本次运行的所有资源",
"downloadAll": "全部下载",
"dragAndDropImage": "拖拽图片到此处",
"emptyWorkflowExplanation": "你的工作流为空。你需要先添加一些节点,才能开始构建应用。",
"enterNodeGraph": "进入节点图",
"giveFeedback": "提供反馈",
"graphMode": "图形模式",
"hasCreditCost": "需要额外积分",
"linearMode": "App 模式",
"loadTemplate": "加载模板",
"mobileControls": "编辑与运行",
@@ -3388,7 +3385,10 @@
"addedToWorkspace": "您已被加入 {workspaceName}",
"inviteAccepted": "邀请已接受",
"inviteFailed": "接受邀请失败",
"switchFailed": "切换工作区失败。请重试。",
"unsavedChanges": {
"message": "您有未保存的更改。是否要放弃这些更改并切换工作区?",
"title": "未保存的更改"
},
"viewWorkspace": "查看工作区"
},
"workspaceAuth": {

View File

@@ -400,7 +400,7 @@
"name": "标签栏布局",
"options": {
"Default": "默认",
"Legacy": "传统"
"Integrated": "集成"
},
"tooltip": "控制标签栏的布局。“集成”会将帮助和用户控件移动到标签栏区域。"
},

View File

@@ -26,12 +26,12 @@
class="flex w-full items-center justify-between gap-2"
@click.self="focusedAsset = null"
>
<SearchInput
<SearchBox
v-model="searchQuery"
:autofocus="true"
size="lg"
:placeholder="$t('g.searchPlaceholder', { subject: '' })"
class="max-w-lg flex-1"
class="max-w-96"
/>
<Button
v-if="isUploadButtonEnabled"
@@ -88,7 +88,7 @@ import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
import { computed, provide, ref } 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 BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'

View File

@@ -186,7 +186,7 @@ const tooltipDelay = computed<number>(() =>
const { isLoading, error } = useImage({
src: asset.preview_url ?? '',
alt: asset.display_name || asset.name
alt: asset.name
})
function handleSelect() {

View File

@@ -5,7 +5,7 @@
:aria-label="
asset
? $t('assetBrowser.ariaLabel.assetCard', {
name: asset.display_name || asset.name,
name: asset.name,
type: fileKind
})
: $t('assetBrowser.ariaLabel.loadingAsset')
@@ -105,11 +105,10 @@
>
<!-- Left side: Media name and metadata -->
<div class="flex flex-col gap-1">
<MediaTitle v-if="showAssetNames" :file-name="fileName" />
<div
v-if="showAssetDetails"
class="flex gap-1.5 text-xs text-muted-foreground"
>
<!-- Title -->
<MediaTitle :file-name="fileName" />
<!-- Metadata -->
<div class="flex gap-1.5 text-xs text-muted-foreground">
<span v-if="formattedDuration">{{ formattedDuration }}</span>
<span v-if="metaInfo">{{ metaInfo }}</span>
</div>
@@ -135,14 +134,7 @@
<script setup lang="ts">
import { useElementHover } from '@vueuse/core'
import {
computed,
defineAsyncComponent,
inject,
provide,
ref,
toRef
} from 'vue'
import { computed, defineAsyncComponent, provide, ref, toRef } from 'vue'
import IconGroup from '@/components/button/IconGroup.vue'
import LoadingOverlay from '@/components/common/LoadingOverlay.vue'
@@ -161,11 +153,7 @@ import { getAssetType } from '../composables/media/assetMappers'
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
import type { AssetItem } from '../schemas/assetSchema'
import type { MediaKind } from '../schemas/mediaAssetSchema'
import {
MediaAssetKey,
ShowAssetDetailsKey,
ShowAssetNamesKey
} from '../schemas/mediaAssetSchema'
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
import MediaTitle from './MediaTitle.vue'
type PreviewKind = ReturnType<typeof getMediaTypeFromFilename>
@@ -193,9 +181,6 @@ const { asset, loading, selected, showOutputCount, outputCount } = defineProps<{
outputCount?: number
}>()
const showAssetNames = inject(ShowAssetNamesKey, ref(true))
const showAssetDetails = inject(ShowAssetDetailsKey, ref(true))
const assetsStore = useAssetsStore()
// Get deletion state from store
@@ -240,7 +225,7 @@ const canInspect = computed(() => isPreviewableMediaType(fileKind.value))
// Get filename without extension
const fileName = computed(() => {
return getFilenameDetails(asset?.display_name || asset?.name || '').filename
return getFilenameDetails(asset?.name || '').filename
})
// Adapt AssetItem to legacy AssetMeta format for existing components
@@ -249,9 +234,8 @@ const adaptedAsset = computed(() => {
return {
id: asset.id,
name: asset.name,
display_name: asset.display_name,
kind: fileKind.value,
src: asset.thumbnail_url || asset.preview_url || '',
src: asset.preview_url || '',
size: asset.size,
tags: asset.tags || [],
created_at: asset.created_at,

View File

@@ -1,6 +1,6 @@
<template>
<div class="flex items-center gap-3 px-3 pt-2 2xl:px-4">
<SearchInput
<div class="flex items-center gap-3">
<SearchBox
:model-value="searchQuery"
:placeholder="
$t('g.searchPlaceholder', { subject: $t('sideToolbar.labels.assets') })
@@ -27,9 +27,6 @@
<MediaAssetSettingsMenu
v-model:view-mode="viewMode"
v-model:sort-by="sortBy"
v-model:group-by-job="groupByJob"
v-model:show-asset-names="showAssetNames"
v-model:show-asset-details="showAssetDetails"
:show-sort-options="isCloud"
:show-generation-time-sort
/>
@@ -40,7 +37,7 @@
</template>
<script setup lang="ts">
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import SearchBox from '@/components/common/SearchBox.vue'
import { isCloud } from '@/platform/distribution/types'
import MediaAssetFilterButton from './MediaAssetFilterButton.vue'
@@ -62,13 +59,6 @@ const emit = defineEmits<{
const sortBy = defineModel<SortBy>('sortBy', { required: true })
const viewMode = defineModel<'list' | 'grid'>('viewMode', { required: true })
const groupByJob = defineModel<boolean>('groupByJob', { required: true })
const showAssetNames = defineModel<boolean>('showAssetNames', {
required: true
})
const showAssetDetails = defineModel<boolean>('showAssetDetails', {
required: true
})
const handleSearchChange = (value: string | undefined) => {
emit('update:searchQuery', value ?? '')

Some files were not shown because too many files have changed in this diff Show More