mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-18 01:38:03 +00:00
Compare commits
23 Commits
media/view
...
drjkl/cart
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
231184f0bf | ||
|
|
44e3f9a7b6 | ||
|
|
a631e7937f | ||
|
|
238da67eee | ||
|
|
5bad07f610 | ||
|
|
d4767e65de | ||
|
|
12992c51ed | ||
|
|
07c844aa6b | ||
|
|
a9537a7450 | ||
|
|
e03ffecf5b | ||
|
|
f91a42a4a7 | ||
|
|
27c72113db | ||
|
|
81860fc379 | ||
|
|
b7721f9247 | ||
|
|
86963c9435 | ||
|
|
8119bb45fb | ||
|
|
29c6a37e8b | ||
|
|
177535b1b5 | ||
|
|
c39151d653 | ||
|
|
ecedeb1515 | ||
|
|
90414f1da9 | ||
|
|
e421d1dc01 | ||
|
|
7e2fb8977c |
13
.github/workflows/cloud-dispatch-build.yaml
vendored
13
.github/workflows/cloud-dispatch-build.yaml
vendored
@@ -14,7 +14,7 @@ on:
|
||||
- 'cloud/*'
|
||||
- 'main'
|
||||
pull_request:
|
||||
types: [labeled, synchronize]
|
||||
types: [labeled]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions: {}
|
||||
@@ -26,18 +26,11 @@ concurrency:
|
||||
jobs:
|
||||
dispatch:
|
||||
# Fork guard: prevent forks from dispatching to the cloud repo.
|
||||
# For pull_request events, only dispatch for preview labels.
|
||||
# - labeled: fires when a label is added; check the added label name.
|
||||
# - synchronize: fires on push; check existing labels on the PR.
|
||||
# For pull_request events, only dispatch when the 'preview' label is added.
|
||||
if: >
|
||||
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
|
||||
(github.event_name != 'pull_request' ||
|
||||
(github.event.action == 'labeled' &&
|
||||
contains(fromJSON('["preview","preview-cpu","preview-gpu"]'), github.event.label.name)) ||
|
||||
(github.event.action == 'synchronize' &&
|
||||
(contains(github.event.pull_request.labels.*.name, 'preview') ||
|
||||
contains(github.event.pull_request.labels.*.name, 'preview-cpu') ||
|
||||
contains(github.event.pull_request.labels.*.name, 'preview-gpu'))))
|
||||
github.event.label.name == 'preview')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Build client payload
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<template v-if="filter.tasks.length === 0">
|
||||
<!-- Empty filter -->
|
||||
<Divider />
|
||||
<p class="w-full text-center text-neutral-400">
|
||||
<p class="text-neutral-400 w-full text-center">
|
||||
{{ $t('maintenance.allOk') }}
|
||||
</p>
|
||||
</template>
|
||||
@@ -25,7 +25,7 @@
|
||||
|
||||
<!-- Display: Cards -->
|
||||
<template v-else>
|
||||
<div class="pad-y my-4 flex flex-wrap justify-evenly gap-8">
|
||||
<div class="flex flex-wrap justify-evenly gap-8 pad-y my-4">
|
||||
<TaskCard
|
||||
v-for="task in filter.tasks"
|
||||
:key="task.id"
|
||||
@@ -45,8 +45,7 @@ import { useConfirm, useToast } from 'primevue'
|
||||
import ConfirmPopup from 'primevue/confirmpopup'
|
||||
import Divider from 'primevue/divider'
|
||||
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
import { useMaintenanceTaskStore } from '@/stores/maintenanceTaskStore'
|
||||
import type {
|
||||
MaintenanceFilter,
|
||||
@@ -56,7 +55,6 @@ import type {
|
||||
import TaskCard from './TaskCard.vue'
|
||||
import TaskListItem from './TaskListItem.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const confirm = useConfirm()
|
||||
const taskStore = useMaintenanceTaskStore()
|
||||
@@ -82,7 +80,8 @@ const executeTask = async (task: MaintenanceTask) => {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('maintenance.error.toastTitle'),
|
||||
detail: message ?? t('maintenance.error.defaultDescription')
|
||||
detail: message ?? t('maintenance.error.defaultDescription'),
|
||||
life: 10_000
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -189,7 +189,8 @@ const completeValidation = async () => {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('maintenance.error.cannotContinue')
|
||||
detail: t('maintenance.error.cannotContinue'),
|
||||
life: 5_000
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<BaseViewTemplate dark hide-language-selector>
|
||||
<div class="flex h-full flex-col items-center justify-center p-8 2xl:p-16">
|
||||
<div class="h-full p-8 2xl:p-16 flex flex-col items-center justify-center">
|
||||
<div
|
||||
class="flex w-full max-w-[600px] flex-col gap-6 rounded-lg bg-neutral-800 p-6 shadow-lg"
|
||||
class="bg-neutral-800 rounded-lg shadow-lg p-6 w-full max-w-[600px] flex flex-col gap-6"
|
||||
>
|
||||
<h2 class="text-3xl font-semibold text-neutral-100">
|
||||
{{ $t('install.helpImprove') }}
|
||||
@@ -15,7 +15,7 @@
|
||||
<a
|
||||
href="https://comfy.org/privacy"
|
||||
target="_blank"
|
||||
class="text-blue-400 underline hover:text-blue-300"
|
||||
class="text-blue-400 hover:text-blue-300 underline"
|
||||
>
|
||||
{{ $t('install.privacyPolicy') }} </a
|
||||
>.
|
||||
@@ -33,7 +33,7 @@
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-end pt-6">
|
||||
<div class="flex pt-6 justify-end">
|
||||
<Button
|
||||
:label="$t('g.ok')"
|
||||
icon="pi pi-check"
|
||||
@@ -72,7 +72,8 @@ const updateConsent = async () => {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('install.settings.errorUpdatingConsent'),
|
||||
detail: t('install.settings.errorUpdatingConsentDetail')
|
||||
detail: t('install.settings.errorUpdatingConsentDetail'),
|
||||
life: 3000
|
||||
})
|
||||
} finally {
|
||||
isUpdating.value = false
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 96 KiB |
@@ -1,62 +0,0 @@
|
||||
# Release Process
|
||||
|
||||
## Bump Types
|
||||
|
||||
All releases use `release-version-bump.yaml`. Effects differ by bump type:
|
||||
|
||||
| Bump | Target | Creates branches? | GitHub release |
|
||||
| ---------- | ---------- | ------------------------------------- | ---------------------------- |
|
||||
| Minor | `main` | `core/` + `cloud/` for previous minor | Published, "latest" |
|
||||
| Patch | `main` | No | Published, "latest" |
|
||||
| Patch | `core/X.Y` | No | **Draft** (uncheck "latest") |
|
||||
| Prerelease | any | No | Draft + prerelease |
|
||||
|
||||
**Minor bump** (e.g. 1.41→1.42): freezes the previous minor into `core/1.41`
|
||||
and `cloud/1.41`, branched from the commit _before_ the bump. Nightly patch
|
||||
bumps on `main` are convenience snapshots — no branches created.
|
||||
|
||||
**Patch on `core/X.Y`**: publishes a hotfix draft release. Must not be marked
|
||||
"latest" so `main` stays current.
|
||||
|
||||
### Dual-homed commits
|
||||
|
||||
When a minor bump happens, unreleased commits appear in both places:
|
||||
|
||||
```
|
||||
v1.40.1 ── A ── B ── C ── [bump to 1.41.0]
|
||||
│
|
||||
└── core/1.40
|
||||
```
|
||||
|
||||
A, B, C become v1.41.0 on `main` AND sit on `core/1.40` (where they could
|
||||
later ship as v1.40.2). Same commits, no divergence — the branch just prevents
|
||||
1.41+ features from mixing in so ComfyUI can stay on 1.40.x.
|
||||
|
||||
## Backporting
|
||||
|
||||
1. Add `needs-backport` + version label to the merged PR
|
||||
2. `pr-backport.yaml` cherry-picks and creates a backport PR
|
||||
3. Conflicts produce a comment with details and an agent prompt
|
||||
|
||||
## Publishing
|
||||
|
||||
Merged PRs with the `Release` label trigger `release-draft-create.yaml`,
|
||||
publishing to GitHub Releases (`dist.zip`), PyPI (`comfyui-frontend-package`),
|
||||
and npm (`@comfyorg/comfyui-frontend-types`).
|
||||
|
||||
## Bi-weekly ComfyUI Integration
|
||||
|
||||
`release-biweekly-comfyui.yaml` runs every other Monday — if the next `core/`
|
||||
branch has unreleased commits, it triggers a patch bump and drafts a PR to
|
||||
`Comfy-Org/ComfyUI` updating `requirements.txt`.
|
||||
|
||||
## Workflows
|
||||
|
||||
| Workflow | Purpose |
|
||||
| ------------------------------- | ------------------------------------------------ |
|
||||
| `release-version-bump.yaml` | Bump version, create Release PR |
|
||||
| `release-draft-create.yaml` | Build + publish to GitHub/PyPI/npm |
|
||||
| `release-branch-create.yaml` | Create `core/` + `cloud/` branches (minor/major) |
|
||||
| `release-biweekly-comfyui.yaml` | Auto-patch + ComfyUI requirements PR |
|
||||
| `pr-backport.yaml` | Cherry-pick fixes to stable branches |
|
||||
| `cloud-backport-tag.yaml` | Tag cloud branch merges |
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.42.2",
|
||||
"version": "1.42.0",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogRoot,
|
||||
DialogTitle,
|
||||
DialogTrigger
|
||||
} from 'reka-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
defineProps<{ title?: string; to?: string | HTMLElement }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
<template>
|
||||
<DialogRoot v-slot="{ close }">
|
||||
<DialogTrigger as-child>
|
||||
<slot name="button" />
|
||||
</DialogTrigger>
|
||||
<DialogPortal :to>
|
||||
<DialogOverlay
|
||||
class="data-[state=open]:animate-overlayShow fixed inset-0 z-30 bg-black/70"
|
||||
/>
|
||||
<DialogContent
|
||||
v-bind="$attrs"
|
||||
class="data-[state=open]:animate-contentShow fixed top-[50%] left-[50%] z-1700 max-h-[85vh] w-[90vw] max-w-[450px] translate-x-[-50%] translate-y-[-50%] rounded-2xl border border-border-subtle bg-base-background p-2 shadow-sm"
|
||||
>
|
||||
<div
|
||||
v-if="title"
|
||||
class="flex w-full items-center justify-between border-b border-border-subtle px-4"
|
||||
>
|
||||
<DialogTitle class="text-sm">{{ title }}</DialogTitle>
|
||||
<DialogClose as-child>
|
||||
<Button
|
||||
:aria-label="t('g.close')"
|
||||
size="icon"
|
||||
variant="muted-textonly"
|
||||
>
|
||||
<i class="icon-[lucide--x]" />
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
<slot :close />
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</DialogRoot>
|
||||
</template>
|
||||
95
src/components/common/SearchBox.stories.ts
Normal file
95
src/components/common/SearchBox.stories.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import type { ComponentExposed } from 'vue-component-type-helpers'
|
||||
interface GenericMeta<C> extends Omit<Meta<C>, 'component'> {
|
||||
component: Omit<ComponentExposed<C>, 'focus'>
|
||||
}
|
||||
|
||||
const meta: GenericMeta<typeof SearchBox> = {
|
||||
title: 'Components/Input/SearchBox',
|
||||
component: SearchBox,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
modelValue: {
|
||||
control: 'text'
|
||||
},
|
||||
placeholder: {
|
||||
control: 'text'
|
||||
},
|
||||
showBorder: {
|
||||
control: 'boolean',
|
||||
description: 'Toggle border prop'
|
||||
},
|
||||
size: {
|
||||
control: 'select',
|
||||
options: ['md', 'lg'],
|
||||
description: 'Size variant of the search box'
|
||||
},
|
||||
'onUpdate:modelValue': { action: 'update:modelValue' },
|
||||
onSearch: { action: 'search' }
|
||||
},
|
||||
args: {
|
||||
modelValue: '',
|
||||
placeholder: 'Search...',
|
||||
showBorder: false,
|
||||
size: 'md'
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: (args) => ({
|
||||
components: { SearchBox },
|
||||
setup() {
|
||||
const searchText = ref('')
|
||||
return { searchText, args }
|
||||
},
|
||||
template: `
|
||||
<div style="max-width: 320px;">
|
||||
<SearchBox v-bind="args" v-model="searchText" />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const WithBorder: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
showBorder: true
|
||||
}
|
||||
}
|
||||
|
||||
export const NoBorder: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
showBorder: false
|
||||
}
|
||||
}
|
||||
|
||||
export const MediumSize: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
size: 'md',
|
||||
showBorder: false
|
||||
}
|
||||
}
|
||||
|
||||
export const LargeSize: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
size: 'lg',
|
||||
showBorder: false
|
||||
}
|
||||
}
|
||||
|
||||
export const LargeSizeWithBorder: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
size: 'lg',
|
||||
showBorder: true
|
||||
}
|
||||
}
|
||||
193
src/components/common/SearchBox.test.ts
Normal file
193
src/components/common/SearchBox.test.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
templateWidgets: {
|
||||
sort: {
|
||||
searchPlaceholder: 'Search...'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('SearchBox', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
const createWrapper = (props = {}) => {
|
||||
return mount(SearchBox, {
|
||||
props: {
|
||||
modelValue: '',
|
||||
...props
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('debounced search functionality', () => {
|
||||
it('should debounce search input by 300ms', async () => {
|
||||
const wrapper = createWrapper()
|
||||
const input = wrapper.find('input')
|
||||
|
||||
// Type search query
|
||||
await input.setValue('test')
|
||||
|
||||
// Model should not update immediately
|
||||
expect(wrapper.emitted('search')).toBeFalsy()
|
||||
|
||||
// Advance timers by 299ms (just before debounce delay)
|
||||
await vi.advanceTimersByTimeAsync(299)
|
||||
await nextTick()
|
||||
expect(wrapper.emitted('search')).toBeFalsy()
|
||||
|
||||
// Advance timers by 1ms more (reaching 300ms)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await nextTick()
|
||||
|
||||
// Model should now be updated
|
||||
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
|
||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['test'])
|
||||
})
|
||||
|
||||
it('should reset debounce timer on each keystroke', async () => {
|
||||
const wrapper = createWrapper()
|
||||
const input = wrapper.find('input')
|
||||
|
||||
// Type first character
|
||||
await input.setValue('t')
|
||||
vi.advanceTimersByTime(200)
|
||||
await nextTick()
|
||||
|
||||
// Type second character (should reset timer)
|
||||
await input.setValue('te')
|
||||
vi.advanceTimersByTime(200)
|
||||
await nextTick()
|
||||
|
||||
// Type third character (should reset timer again)
|
||||
await input.setValue('tes')
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
await nextTick()
|
||||
|
||||
// Should not have emitted yet (only 200ms passed since last keystroke)
|
||||
expect(wrapper.emitted('search')).toBeFalsy()
|
||||
|
||||
// Advance final 100ms to reach 300ms
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await nextTick()
|
||||
|
||||
// Should now emit with final value
|
||||
expect(wrapper.emitted('search')).toBeTruthy()
|
||||
expect(wrapper.emitted('search')?.[0]).toEqual(['tes', []])
|
||||
})
|
||||
|
||||
it('should only emit final value after rapid typing', async () => {
|
||||
const wrapper = createWrapper()
|
||||
const input = wrapper.find('input')
|
||||
|
||||
// Simulate rapid typing
|
||||
const searchTerms = ['s', 'se', 'sea', 'sear', 'searc', 'search']
|
||||
for (const term of searchTerms) {
|
||||
await input.setValue(term)
|
||||
await vi.advanceTimersByTimeAsync(50) // Less than debounce delay
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
// Should not have emitted yet
|
||||
expect(wrapper.emitted('search')).toBeFalsy()
|
||||
|
||||
// Complete the debounce delay
|
||||
await vi.advanceTimersByTimeAsync(350)
|
||||
await nextTick()
|
||||
|
||||
// Should emit only once with final value
|
||||
expect(wrapper.emitted('search')).toHaveLength(1)
|
||||
expect(wrapper.emitted('search')?.[0]).toEqual(['search', []])
|
||||
})
|
||||
|
||||
describe('bidirectional model sync', () => {
|
||||
it('should sync external model changes to internal state', async () => {
|
||||
const wrapper = createWrapper({ modelValue: 'initial' })
|
||||
const input = wrapper.find('input')
|
||||
|
||||
expect(input.element.value).toBe('initial')
|
||||
|
||||
// Update model externally
|
||||
await wrapper.setProps({ modelValue: 'external update' })
|
||||
await nextTick()
|
||||
|
||||
// Internal state should sync
|
||||
expect(input.element.value).toBe('external update')
|
||||
})
|
||||
})
|
||||
|
||||
describe('placeholder', () => {
|
||||
it('should use custom placeholder when provided', () => {
|
||||
const wrapper = createWrapper({ placeholder: 'Custom search...' })
|
||||
const input = wrapper.find('input')
|
||||
|
||||
expect(input.attributes('placeholder')).toBe('Custom search...')
|
||||
expect(input.attributes('aria-label')).toBe('Custom search...')
|
||||
})
|
||||
|
||||
it('should use default placeholder when not provided', () => {
|
||||
const wrapper = createWrapper()
|
||||
const input = wrapper.find('input')
|
||||
|
||||
expect(input.attributes('placeholder')).toBe('Search...')
|
||||
expect(input.attributes('aria-label')).toBe('Search...')
|
||||
})
|
||||
})
|
||||
|
||||
describe('autofocus', () => {
|
||||
it('should focus input when autofocus is true', async () => {
|
||||
const wrapper = createWrapper({ autofocus: true })
|
||||
await nextTick()
|
||||
|
||||
const input = wrapper.find('input')
|
||||
const inputElement = input.element as HTMLInputElement
|
||||
|
||||
// Note: In JSDOM, focus() doesn't actually set document.activeElement
|
||||
// We can only verify that the focus method exists and doesn't throw
|
||||
expect(inputElement.focus).toBeDefined()
|
||||
})
|
||||
|
||||
it('should not autofocus when autofocus is false', () => {
|
||||
const wrapper = createWrapper({ autofocus: false })
|
||||
const input = wrapper.find('input')
|
||||
|
||||
expect(document.activeElement).not.toBe(input.element)
|
||||
})
|
||||
})
|
||||
|
||||
describe('click to focus', () => {
|
||||
it('should focus input when wrapper is clicked', async () => {
|
||||
const wrapper = createWrapper()
|
||||
const wrapperDiv = wrapper.find('[class*="flex"]')
|
||||
|
||||
await wrapperDiv.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
// Input should receive focus
|
||||
const input = wrapper.find('input').element as HTMLInputElement
|
||||
expect(input.focus).toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
139
src/components/common/SearchBox.vue
Normal file
139
src/components/common/SearchBox.vue
Normal file
@@ -0,0 +1,139 @@
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'relative flex w-full cursor-text items-center gap-2 bg-comfy-input text-comfy-input-foreground',
|
||||
customClass,
|
||||
wrapperStyle
|
||||
)
|
||||
"
|
||||
>
|
||||
<InputText
|
||||
ref="inputRef"
|
||||
v-model="modelValue"
|
||||
:placeholder
|
||||
:autofocus
|
||||
unstyled
|
||||
:class="
|
||||
cn(
|
||||
'absolute inset-0 size-full border-none bg-transparent text-sm outline-none',
|
||||
isLarge ? 'pl-11' : 'pl-8'
|
||||
)
|
||||
"
|
||||
:aria-label="placeholder"
|
||||
/>
|
||||
<Button
|
||||
v-if="filterIcon"
|
||||
size="icon"
|
||||
variant="textonly"
|
||||
class="filter-button absolute inset-y-0 right-0 m-0 p-0"
|
||||
@click="$emit('showFilter', $event)"
|
||||
>
|
||||
<i :class="filterIcon" />
|
||||
</Button>
|
||||
<InputIcon v-if="!modelValue" :class="icon" />
|
||||
<Button
|
||||
v-if="modelValue"
|
||||
:class="cn('clear-button absolute', isLarge ? 'left-2' : 'left-0')"
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
@click="modelValue = ''"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="filters?.length" class="search-filters flex flex-wrap gap-2 pt-2">
|
||||
<SearchFilterChip
|
||||
v-for="filter in filters"
|
||||
:key="filter.id"
|
||||
:text="filter.text"
|
||||
:badge="filter.badge"
|
||||
:badge-class="filter.badgeClass"
|
||||
@remove="$emit('removeFilter', filter)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" generic="TFilter extends SearchFilter">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import InputIcon from 'primevue/inputicon'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
import type { SearchFilter } from './SearchFilterChip.vue'
|
||||
import SearchFilterChip from './SearchFilterChip.vue'
|
||||
|
||||
const {
|
||||
placeholder = 'Search...',
|
||||
icon = 'pi pi-search',
|
||||
debounceTime = 300,
|
||||
filterIcon,
|
||||
filters = [],
|
||||
autofocus = false,
|
||||
showBorder = false,
|
||||
size = 'md',
|
||||
class: customClass
|
||||
} = defineProps<{
|
||||
placeholder?: string
|
||||
icon?: string
|
||||
debounceTime?: number
|
||||
filterIcon?: string
|
||||
filters?: TFilter[]
|
||||
autofocus?: boolean
|
||||
showBorder?: boolean
|
||||
size?: 'md' | 'lg'
|
||||
class?: string
|
||||
}>()
|
||||
|
||||
const isLarge = computed(() => size === 'lg')
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'search', value: string, filters: TFilter[]): void
|
||||
(e: 'showFilter', event: Event): void
|
||||
(e: 'removeFilter', filter: TFilter): void
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<string>({ required: true })
|
||||
|
||||
const inputRef = ref()
|
||||
|
||||
defineExpose({
|
||||
focus: () => {
|
||||
inputRef.value?.$el?.focus()
|
||||
}
|
||||
})
|
||||
|
||||
watchDebounced(
|
||||
modelValue,
|
||||
(value: string) => {
|
||||
emit('search', value, filters)
|
||||
},
|
||||
{ debounce: debounceTime }
|
||||
)
|
||||
|
||||
const wrapperStyle = computed(() => {
|
||||
if (showBorder) {
|
||||
return cn(
|
||||
'box-border rounded-sm border border-solid border-border-default p-2',
|
||||
isLarge.value ? 'h-10' : 'h-8'
|
||||
)
|
||||
}
|
||||
|
||||
// Size-specific classes matching button sizes for consistency
|
||||
const sizeClasses = {
|
||||
md: 'h-8 px-2 py-1.5', // Matches button sm size
|
||||
lg: 'h-10 px-4 py-2' // Matches button md size
|
||||
}[size]
|
||||
|
||||
return cn('rounded-lg', sizeClasses)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.p-inputtext) {
|
||||
--p-form-field-padding-x: 0.625rem;
|
||||
}
|
||||
</style>
|
||||
90
src/components/common/SearchBoxV2.test.ts
Normal file
90
src/components/common/SearchBoxV2.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import SearchBoxV2 from './SearchBoxV2.vue'
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
watchDebounced: vi.fn(() => vi.fn())
|
||||
}))
|
||||
|
||||
describe('SearchBoxV2', () => {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: {
|
||||
clear: 'Clear',
|
||||
searchPlaceholder: 'Search...'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function mountComponent(props = {}) {
|
||||
return mount(SearchBoxV2, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
ComboboxRoot: {
|
||||
template: '<div><slot /></div>'
|
||||
},
|
||||
ComboboxAnchor: {
|
||||
template: '<div><slot /></div>'
|
||||
},
|
||||
ComboboxInput: {
|
||||
template:
|
||||
'<input :placeholder="placeholder" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
|
||||
props: ['placeholder', 'modelValue', 'autoFocus']
|
||||
}
|
||||
}
|
||||
},
|
||||
props: {
|
||||
modelValue: '',
|
||||
...props
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('uses i18n placeholder when no placeholder prop provided', () => {
|
||||
const wrapper = mountComponent()
|
||||
const input = wrapper.find('input')
|
||||
expect(input.attributes('placeholder')).toBe('Search...')
|
||||
})
|
||||
|
||||
it('uses custom placeholder when provided', () => {
|
||||
const wrapper = mountComponent({
|
||||
placeholder: 'Custom placeholder'
|
||||
})
|
||||
const input = wrapper.find('input')
|
||||
expect(input.attributes('placeholder')).toBe('Custom placeholder')
|
||||
})
|
||||
|
||||
it('shows search icon when search term is empty', () => {
|
||||
const wrapper = mountComponent({ modelValue: '' })
|
||||
expect(wrapper.find('i.icon-\\[lucide--search\\]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('shows clear button when search term is not empty', () => {
|
||||
const wrapper = mountComponent({ modelValue: 'test' })
|
||||
expect(wrapper.find('button').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('clears search term when clear button is clicked', async () => {
|
||||
const wrapper = mountComponent({ modelValue: 'test' })
|
||||
const clearButton = wrapper.find('button')
|
||||
await clearButton.trigger('click')
|
||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([''])
|
||||
})
|
||||
|
||||
it('applies large size classes when size is lg', () => {
|
||||
const wrapper = mountComponent({ size: 'lg' })
|
||||
expect(wrapper.html()).toContain('size-5')
|
||||
})
|
||||
|
||||
it('applies medium size classes when size is md', () => {
|
||||
const wrapper = mountComponent({ size: 'md' })
|
||||
expect(wrapper.html()).toContain('size-4')
|
||||
})
|
||||
})
|
||||
117
src/components/common/SearchBoxV2.vue
Normal file
117
src/components/common/SearchBoxV2.vue
Normal file
@@ -0,0 +1,117 @@
|
||||
<template>
|
||||
<div class="flex flex-auto flex-col gap-2">
|
||||
<ComboboxRoot :ignore-filter="true" :open="false">
|
||||
<ComboboxAnchor
|
||||
:class="
|
||||
cn(
|
||||
'relative flex w-full cursor-text items-center',
|
||||
'rounded-lg bg-comfy-input text-comfy-input-foreground',
|
||||
showBorder &&
|
||||
'box-border border border-solid border-border-default',
|
||||
sizeClasses,
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<i
|
||||
v-if="!searchTerm"
|
||||
:class="cn('pointer-events-none absolute left-4', icon, iconClass)"
|
||||
/>
|
||||
<Button
|
||||
v-else
|
||||
class="absolute left-2"
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
:aria-label="$t('g.clear')"
|
||||
@click="clearSearch"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</Button>
|
||||
|
||||
<ComboboxInput
|
||||
ref="inputRef"
|
||||
v-model="searchTerm"
|
||||
:class="
|
||||
cn(
|
||||
'size-full border-none bg-transparent text-sm outline-none',
|
||||
inputPadding
|
||||
)
|
||||
"
|
||||
:placeholder="placeholderText"
|
||||
:auto-focus="autofocus"
|
||||
/>
|
||||
</ComboboxAnchor>
|
||||
</ComboboxRoot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import { ComboboxAnchor, ComboboxInput, ComboboxRoot } from 'reka-ui'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const {
|
||||
placeholder,
|
||||
icon = 'icon-[lucide--search]',
|
||||
debounceTime = 300,
|
||||
autofocus = false,
|
||||
showBorder = false,
|
||||
size = 'md',
|
||||
class: className
|
||||
} = defineProps<{
|
||||
placeholder?: string
|
||||
icon?: string
|
||||
debounceTime?: number
|
||||
autofocus?: boolean
|
||||
showBorder?: boolean
|
||||
size?: 'md' | 'lg'
|
||||
class?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
search: [value: string]
|
||||
}>()
|
||||
|
||||
const searchTerm = defineModel<string>({ required: true })
|
||||
|
||||
const inputRef = ref<InstanceType<typeof ComboboxInput> | null>(null)
|
||||
|
||||
defineExpose({
|
||||
focus: () => {
|
||||
inputRef.value?.$el?.focus()
|
||||
}
|
||||
})
|
||||
|
||||
const isLarge = computed(() => size === 'lg')
|
||||
const placeholderText = computed(
|
||||
() => placeholder ?? t('g.searchPlaceholder', { subject: '' })
|
||||
)
|
||||
|
||||
const sizeClasses = computed(() => {
|
||||
if (showBorder) {
|
||||
return isLarge.value ? 'h-10 p-2' : 'h-8 p-2'
|
||||
}
|
||||
return isLarge.value ? 'h-12 px-4 py-2' : 'h-10 px-4 py-2'
|
||||
})
|
||||
|
||||
const iconClass = computed(() => (isLarge.value ? 'size-5' : 'size-4'))
|
||||
const inputPadding = computed(() => (isLarge.value ? 'pl-8' : 'pl-6'))
|
||||
|
||||
function clearSearch() {
|
||||
searchTerm.value = ''
|
||||
}
|
||||
|
||||
watchDebounced(
|
||||
searchTerm,
|
||||
(value: string) => {
|
||||
emit('search', value)
|
||||
},
|
||||
{ debounce: debounceTime }
|
||||
)
|
||||
</script>
|
||||
@@ -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
|
||||
|
||||
@@ -14,10 +14,10 @@
|
||||
</template>
|
||||
|
||||
<template #header>
|
||||
<SearchInput
|
||||
<SearchBox
|
||||
v-model="searchQuery"
|
||||
size="lg"
|
||||
class="max-w-96 flex-1"
|
||||
class="max-w-[384px]"
|
||||
autofocus
|
||||
/>
|
||||
</template>
|
||||
@@ -389,7 +389,7 @@ import CardBottom from '@/components/card/CardBottom.vue'
|
||||
import CardContainer from '@/components/card/CardContainer.vue'
|
||||
import CardTop from '@/components/card/CardTop.vue'
|
||||
import SquareChip from '@/components/chip/SquareChip.vue'
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import MultiSelect from '@/components/input/MultiSelect.vue'
|
||||
import SingleSelect from '@/components/input/SingleSelect.vue'
|
||||
import AudioThumbnail from '@/components/templates/thumbnails/AudioThumbnail.vue'
|
||||
|
||||
@@ -138,7 +138,8 @@ onMounted(async () => {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('toastMessages.failedToFetchLogs')
|
||||
detail: t('toastMessages.failedToFetchLogs'),
|
||||
life: 5000
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -275,7 +275,8 @@ async function handleBuy() {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('credits.topUp.purchaseError'),
|
||||
detail: t('credits.topUp.purchaseErrorDetail', { error: errorMessage })
|
||||
detail: t('credits.topUp.purchaseErrorDetail', { error: errorMessage }),
|
||||
life: 5000
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="keybinding-panel flex flex-col gap-2">
|
||||
<SearchInput
|
||||
<SearchBox
|
||||
v-model="filters['global'].value"
|
||||
:placeholder="$t('g.searchPlaceholder', { subject: $t('g.keybindings') })"
|
||||
/>
|
||||
@@ -155,7 +155,7 @@ import { useToast } from 'primevue/usetoast'
|
||||
import { computed, ref, watchEffect } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { KeyComboImpl } from '@/platform/keybindings/keyCombo'
|
||||
import { KeybindingImpl } from '@/platform/keybindings/keybinding'
|
||||
|
||||
@@ -98,7 +98,8 @@ async function onConfirmCancel() {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('subscription.cancelDialog.failed'),
|
||||
detail: error instanceof Error ? error.message : t('g.unknownError')
|
||||
detail: error instanceof Error ? error.message : t('g.unknownError'),
|
||||
life: 5000
|
||||
})
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
|
||||
@@ -50,9 +50,7 @@
|
||||
{{ t('g.dismiss') }}
|
||||
</Button>
|
||||
<Button variant="secondary" size="lg" @click="seeErrors">
|
||||
{{
|
||||
appMode ? t('linearMode.error.goto') : t('errorOverlay.seeErrors')
|
||||
}}
|
||||
{{ t('errorOverlay.seeErrors') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -71,8 +69,6 @@ import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useErrorGroups } from '@/components/rightSidePanel/errors/useErrorGroups'
|
||||
|
||||
defineProps<{ appMode?: boolean }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
@@ -98,7 +94,6 @@ function dismiss() {
|
||||
}
|
||||
|
||||
function seeErrors() {
|
||||
canvasStore.linearMode = false
|
||||
if (canvasStore.canvas) {
|
||||
canvasStore.canvas.deselectAll()
|
||||
canvasStore.updateSelectedItems()
|
||||
|
||||
@@ -561,7 +561,7 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
vueNodeLifecycle.cleanup()
|
||||
vueNodeLifecycle.disposeNodeManagerAndSyncs()
|
||||
})
|
||||
function forwardPanEvent(e: PointerEvent) {
|
||||
if (
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,12 +93,13 @@
|
||||
#header
|
||||
>
|
||||
<div class="flex flex-col px-2 pt-2 pb-0">
|
||||
<SearchInput
|
||||
<SearchBox
|
||||
v-if="showSearchBox"
|
||||
v-model="searchQuery"
|
||||
:class="showSelectedCount || showClearButton ? 'mb-2' : ''"
|
||||
:placeholder="searchPlaceholder"
|
||||
size="sm"
|
||||
:show-order="true"
|
||||
:show-border="true"
|
||||
:place-holder="searchPlaceholder"
|
||||
/>
|
||||
<div
|
||||
v-if="showSelectedCount || showClearButton"
|
||||
@@ -181,7 +182,7 @@ import MultiSelect from 'primevue/multiselect'
|
||||
import { computed, useAttrs } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { usePopoverSizing } from '@/composables/usePopoverSizing'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
@@ -72,12 +72,12 @@
|
||||
/>
|
||||
</div>
|
||||
<SliderControl
|
||||
v-model="brushSizeSliderValue"
|
||||
v-model="brushSize"
|
||||
class="flex-1"
|
||||
label=""
|
||||
:min="0"
|
||||
:max="1"
|
||||
:step="0.001"
|
||||
:min="1"
|
||||
:max="250"
|
||||
:step="1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -182,26 +182,6 @@ const brushSize = computed({
|
||||
set: (value: number) => store.setBrushSize(value)
|
||||
})
|
||||
|
||||
const rawSliderValue = ref<number | null>(null)
|
||||
|
||||
const brushSizeSliderValue = computed({
|
||||
get: () => {
|
||||
if (rawSliderValue.value !== null) {
|
||||
const cachedSize = Math.round(Math.pow(250, rawSliderValue.value))
|
||||
if (cachedSize === brushSize.value) {
|
||||
return rawSliderValue.value
|
||||
}
|
||||
}
|
||||
|
||||
return Math.log(brushSize.value) / Math.log(250)
|
||||
},
|
||||
set: (value: number) => {
|
||||
rawSliderValue.value = value
|
||||
const size = Math.round(Math.pow(250, value))
|
||||
store.setBrushSize(size)
|
||||
}
|
||||
})
|
||||
|
||||
const brushOpacity = computed({
|
||||
get: () => store.brushSettings.opacity,
|
||||
set: (value: number) => store.setBrushOpacity(value)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<SearchInput
|
||||
<SearchBox
|
||||
v-if="showSearch"
|
||||
:model-value="searchQuery"
|
||||
class="min-w-0 flex-1"
|
||||
@@ -116,7 +116,7 @@
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { jobSortModes } from '@/composables/queue/useJobList'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { computed, reactive, ref, toValue, watch } from 'vue'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import Fuse from 'fuse.js'
|
||||
import type { IFuseOptions } from 'fuse.js'
|
||||
|
||||
@@ -227,7 +227,7 @@ function searchErrorGroups(groups: ErrorGroup[], query: string) {
|
||||
}
|
||||
|
||||
export function useErrorGroups(
|
||||
searchQuery: MaybeRefOrGetter<string>,
|
||||
searchQuery: Ref<string>,
|
||||
t: (key: string) => string
|
||||
) {
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
@@ -584,7 +584,7 @@ export function useErrorGroups(
|
||||
})
|
||||
|
||||
const filteredGroups = computed<ErrorGroup[]>(() => {
|
||||
const query = toValue(searchQuery).trim()
|
||||
const query = searchQuery.value.trim()
|
||||
return searchErrorGroups(tabErrorGroups.value, query)
|
||||
})
|
||||
|
||||
|
||||
@@ -15,9 +15,10 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
|
||||
import { getWidgetDefaultValue, promptWidgetLabel } from '@/utils/widgetUtil'
|
||||
import { getWidgetDefaultValue } from '@/utils/widgetUtil'
|
||||
import type { WidgetValue } from '@/utils/widgetUtil'
|
||||
|
||||
const {
|
||||
@@ -41,6 +42,7 @@ const label = defineModel<string>('label', { required: true })
|
||||
const canvasStore = useCanvasStore()
|
||||
const favoritedWidgetsStore = useFavoritedWidgetsStore()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const dialogService = useDialogService()
|
||||
const { t } = useI18n()
|
||||
|
||||
const hasParents = computed(() => parents?.length > 0)
|
||||
@@ -65,8 +67,15 @@ const isCurrentValueDefault = computed(() => {
|
||||
})
|
||||
|
||||
async function handleRename() {
|
||||
const newLabel = await promptWidgetLabel(widget, t)
|
||||
if (newLabel !== null) label.value = newLabel
|
||||
const newLabel = await dialogService.prompt({
|
||||
title: t('g.rename'),
|
||||
message: t('g.enterNewNamePrompt'),
|
||||
defaultValue: widget.label,
|
||||
placeholder: widget.name
|
||||
})
|
||||
|
||||
if (newLabel === null) return
|
||||
label.value = newLabel
|
||||
}
|
||||
|
||||
function handleHideInput() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
</template>
|
||||
<template #header>
|
||||
<div class="px-2 2xl:px-4">
|
||||
<SearchInput
|
||||
<SearchBox
|
||||
ref="searchBoxRef"
|
||||
v-model:model-value="searchQuery"
|
||||
class="workflows-search-box"
|
||||
@@ -146,7 +146,7 @@ import { computed, nextTick, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import TextDivider from '@/components/common/TextDivider.vue'
|
||||
import TreeExplorer from '@/components/common/TreeExplorer.vue'
|
||||
import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue'
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
</template>
|
||||
<template #header>
|
||||
<div class="px-2 2xl:px-4">
|
||||
<SearchInput
|
||||
<SearchBox
|
||||
ref="searchBoxRef"
|
||||
v-model:model-value="searchQuery"
|
||||
:placeholder="
|
||||
@@ -56,7 +56,7 @@
|
||||
import { Divider } from 'primevue'
|
||||
import { computed, nextTick, onMounted, ref, toRef, watch } from 'vue'
|
||||
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import TreeExplorer from '@/components/common/TreeExplorer.vue'
|
||||
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
|
||||
import ElectronDownloadItems from '@/components/sidebar/tabs/modelLibrary/ElectronDownloadItems.vue'
|
||||
|
||||
@@ -86,40 +86,18 @@
|
||||
</template>
|
||||
<template #header>
|
||||
<div class="px-2 2xl:px-4">
|
||||
<div class="flex items-center gap-1">
|
||||
<SearchInput
|
||||
ref="searchBoxRef"
|
||||
v-model="searchQuery"
|
||||
data-testid="node-library-search"
|
||||
class="node-lib-search-box"
|
||||
:placeholder="
|
||||
$t('g.searchPlaceholder', { subject: $t('g.nodes') })
|
||||
"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
class="filter-button shrink-0"
|
||||
:aria-label="$t('g.filter')"
|
||||
@click="(e: Event) => searchFilter?.toggle(e)"
|
||||
>
|
||||
<i class="pi pi-filter" />
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
v-if="filters?.length"
|
||||
class="search-filters flex flex-wrap gap-2 pt-2"
|
||||
>
|
||||
<SearchFilterChip
|
||||
v-for="filter in filters"
|
||||
:key="filter.id"
|
||||
:text="filter.text"
|
||||
:badge="filter.badge"
|
||||
:badge-class="filter.badgeClass"
|
||||
@remove="onRemoveFilter(filter)"
|
||||
/>
|
||||
</div>
|
||||
<SearchBox
|
||||
ref="searchBoxRef"
|
||||
v-model:model-value="searchQuery"
|
||||
data-testid="node-library-search"
|
||||
class="node-lib-search-box"
|
||||
:placeholder="$t('g.searchPlaceholder', { subject: $t('g.nodes') })"
|
||||
filter-icon="pi pi-filter"
|
||||
:filters
|
||||
@search="handleSearch"
|
||||
@show-filter="($event) => searchFilter?.toggle($event)"
|
||||
@remove-filter="onRemoveFilter"
|
||||
/>
|
||||
|
||||
<Popover ref="searchFilter" class="ml-[-13px]">
|
||||
<NodeSearchFilter @add-filter="onAddFilter" />
|
||||
@@ -177,9 +155,8 @@ import {
|
||||
} from 'vue'
|
||||
|
||||
import { resolveEssentialsDisplayName } from '@/constants/essentialsDisplayNames'
|
||||
import SearchFilterChip from '@/components/common/SearchFilterChip.vue'
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import type { SearchFilter } from '@/components/common/SearchFilterChip.vue'
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import TreeExplorer from '@/components/common/TreeExplorer.vue'
|
||||
import NodePreview from '@/components/node/NodePreview.vue'
|
||||
import NodeSearchFilter from '@/components/searchbox/NodeSearchFilter.vue'
|
||||
|
||||
@@ -69,7 +69,7 @@ vi.mock('./nodeLibrary/NodeDragPreview.vue', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/search-input/SearchInput.vue', () => ({
|
||||
vi.mock('@/components/common/SearchBoxV2.vue', () => ({
|
||||
default: {
|
||||
name: 'SearchBox',
|
||||
template: '<input data-testid="search-box" />',
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<template #header>
|
||||
<TabsRoot v-model="selectedTab" class="flex flex-col">
|
||||
<div class="flex items-center justify-between gap-2 px-2 pb-2 2xl:px-4">
|
||||
<SearchInput
|
||||
<SearchBox
|
||||
ref="searchBoxRef"
|
||||
v-model="searchQuery"
|
||||
:placeholder="$t('g.search') + '...'"
|
||||
@@ -180,7 +180,7 @@ import { computed, nextTick, onMounted, ref, watchEffect } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { resolveEssentialsDisplayName } from '@/constants/essentialsDisplayNames'
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import SearchBox from '@/components/common/SearchBoxV2.vue'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
|
||||
import { usePerTabState } from '@/composables/usePerTabState'
|
||||
@@ -253,7 +253,7 @@ const filterOptions = ref<Record<NodeCategoryId, boolean>>({
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const searchBoxRef = ref<InstanceType<typeof SearchInput> | null>(null)
|
||||
const searchBoxRef = ref<InstanceType<typeof SearchBox> | null>(null)
|
||||
const searchQuery = ref('')
|
||||
const expandedKeysByTab = ref<Record<TabId, string[]>>({
|
||||
essentials: [],
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -24,7 +24,7 @@ function handleWheel(e: WheelEvent) {
|
||||
|
||||
let dragging = false
|
||||
function handleDown(e: PointerEvent) {
|
||||
if (e.button !== 0 && e.button !== 1) return
|
||||
if (e.button !== 0) return
|
||||
|
||||
const zoomPaneEl = zoomPane.value
|
||||
if (!zoomPaneEl) return
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, watch } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import SearchInput from './SearchInput.vue'
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
watchDebounced: vi.fn((source, cb, opts) => {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
return watch(source, (val: string) => {
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = setTimeout(() => cb(val), opts?.debounce ?? 300)
|
||||
})
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: {
|
||||
clear: 'Clear',
|
||||
searchPlaceholder: 'Search...'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('SearchInput', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
function mountComponent(props = {}) {
|
||||
return mount(SearchInput, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
ComboboxRoot: {
|
||||
template: '<div><slot /></div>'
|
||||
},
|
||||
ComboboxAnchor: {
|
||||
template: '<div @click="$emit(\'click\')"><slot /></div>',
|
||||
emits: ['click']
|
||||
},
|
||||
ComboboxInput: {
|
||||
template:
|
||||
'<input :placeholder="placeholder" :value="modelValue" :autofocus="autoFocus || undefined" @input="$emit(\'update:modelValue\', $event.target.value)" />',
|
||||
props: ['placeholder', 'modelValue', 'autoFocus']
|
||||
}
|
||||
}
|
||||
},
|
||||
props: {
|
||||
modelValue: '',
|
||||
...props
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('debounced search', () => {
|
||||
it('should debounce search input by 300ms', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const input = wrapper.find('input')
|
||||
|
||||
await input.setValue('test')
|
||||
|
||||
expect(wrapper.emitted('search')).toBeFalsy()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(299)
|
||||
await nextTick()
|
||||
expect(wrapper.emitted('search')).toBeFalsy()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.emitted('search')).toEqual([['test']])
|
||||
})
|
||||
|
||||
it('should reset debounce timer on each keystroke', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const input = wrapper.find('input')
|
||||
|
||||
await input.setValue('t')
|
||||
vi.advanceTimersByTime(200)
|
||||
await nextTick()
|
||||
|
||||
await input.setValue('te')
|
||||
vi.advanceTimersByTime(200)
|
||||
await nextTick()
|
||||
|
||||
await input.setValue('tes')
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.emitted('search')).toBeFalsy()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.emitted('search')).toBeTruthy()
|
||||
expect(wrapper.emitted('search')?.[0]).toEqual(['tes'])
|
||||
})
|
||||
|
||||
it('should only emit final value after rapid typing', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const input = wrapper.find('input')
|
||||
|
||||
const searchTerms = ['s', 'se', 'sea', 'sear', 'searc', 'search']
|
||||
for (const term of searchTerms) {
|
||||
await input.setValue(term)
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.emitted('search')).toBeFalsy()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(350)
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.emitted('search')).toHaveLength(1)
|
||||
expect(wrapper.emitted('search')?.[0]).toEqual(['search'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('model sync', () => {
|
||||
it('should sync external model changes to internal state', async () => {
|
||||
const wrapper = mountComponent({ modelValue: 'initial' })
|
||||
const input = wrapper.find('input')
|
||||
|
||||
expect(input.element.value).toBe('initial')
|
||||
|
||||
await wrapper.setProps({ modelValue: 'external update' })
|
||||
await nextTick()
|
||||
|
||||
expect(input.element.value).toBe('external update')
|
||||
})
|
||||
})
|
||||
|
||||
describe('placeholder', () => {
|
||||
it('should use custom placeholder when provided', () => {
|
||||
const wrapper = mountComponent({ placeholder: 'Custom search...' })
|
||||
const input = wrapper.find('input')
|
||||
|
||||
expect(input.attributes('placeholder')).toBe('Custom search...')
|
||||
})
|
||||
|
||||
it('should use i18n placeholder when not provided', () => {
|
||||
const wrapper = mountComponent()
|
||||
const input = wrapper.find('input')
|
||||
|
||||
expect(input.attributes('placeholder')).toBe('Search...')
|
||||
})
|
||||
})
|
||||
|
||||
describe('autofocus', () => {
|
||||
it('should pass autofocus prop to ComboboxInput', () => {
|
||||
const wrapper = mountComponent({ autofocus: true })
|
||||
const input = wrapper.find('input')
|
||||
expect(input.attributes('autofocus')).toBeDefined()
|
||||
})
|
||||
|
||||
it('should not autofocus by default', () => {
|
||||
const wrapper = mountComponent()
|
||||
const input = wrapper.find('input')
|
||||
expect(input.attributes('autofocus')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('focus method', () => {
|
||||
it('should expose focus method via ref', () => {
|
||||
const wrapper = mountComponent()
|
||||
expect(wrapper.vm.focus).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('clear button', () => {
|
||||
it('shows search icon when value is empty', () => {
|
||||
const wrapper = mountComponent({ modelValue: '' })
|
||||
expect(wrapper.find('button[aria-label="Clear"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows clear button when value is not empty', () => {
|
||||
const wrapper = mountComponent({ modelValue: 'test' })
|
||||
expect(wrapper.find('button[aria-label="Clear"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('clears value when clear button is clicked', async () => {
|
||||
const wrapper = mountComponent({ modelValue: 'test' })
|
||||
const clearButton = wrapper.find('button')
|
||||
await clearButton.trigger('click')
|
||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([''])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,15 +1,11 @@
|
||||
<template>
|
||||
<ComboboxRoot
|
||||
:ignore-filter="true"
|
||||
:open="false"
|
||||
:disabled="disabled"
|
||||
:class="className"
|
||||
>
|
||||
<ComboboxRoot :ignore-filter="true" :open="false" :disabled="disabled">
|
||||
<ComboboxAnchor
|
||||
:class="
|
||||
cn(
|
||||
searchInputVariants({ size }),
|
||||
disabled && 'pointer-events-none opacity-50'
|
||||
disabled && 'pointer-events-none opacity-50',
|
||||
className
|
||||
)
|
||||
"
|
||||
@click="focus"
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import type {
|
||||
ComponentPropsAndSlots,
|
||||
Meta,
|
||||
StoryObj
|
||||
} from '@storybook/vue3-vite'
|
||||
import { computed, ref, toRefs } from 'vue'
|
||||
|
||||
import Slider from './Slider.vue'
|
||||
|
||||
interface StoryArgs extends ComponentPropsAndSlots<typeof Slider> {
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
const meta: Meta<StoryArgs> = {
|
||||
title: 'Components/Slider',
|
||||
component: Slider,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'centered' },
|
||||
argTypes: {
|
||||
min: { control: 'number' },
|
||||
max: { control: 'number' },
|
||||
step: { control: 'number' },
|
||||
disabled: { control: 'boolean' }
|
||||
},
|
||||
args: {
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
disabled: false
|
||||
},
|
||||
decorators: [
|
||||
(story) => ({
|
||||
components: { story },
|
||||
template: '<div class="w-72"><story /></div>'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: (args) => ({
|
||||
components: { Slider },
|
||||
setup() {
|
||||
const { min, max, step, disabled } = toRefs(args)
|
||||
const value = ref([36])
|
||||
const display = computed(() => value.value[0])
|
||||
return { value, display, min, max, step, disabled }
|
||||
},
|
||||
template: `
|
||||
<div class="flex items-center gap-4 rounded-lg bg-component-node-widget-background px-3 py-2">
|
||||
<Slider v-model="value" :min :max :step :disabled class="flex-1" />
|
||||
<span class="w-14 shrink-0 text-right text-xs text-component-node-foreground">{{ display }}</span>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: { disabled: true },
|
||||
render: (args) => ({
|
||||
components: { Slider },
|
||||
setup() {
|
||||
const { min, max, step, disabled } = toRefs(args)
|
||||
const value = ref([36])
|
||||
const display = computed(() => value.value[0])
|
||||
return { value, display, min, max, step, disabled }
|
||||
},
|
||||
template: `
|
||||
<div class="flex items-center gap-4 rounded-lg bg-component-node-widget-background px-3 py-2">
|
||||
<Slider v-model="value" :min :max :step :disabled class="flex-1" />
|
||||
<span class="w-14 shrink-0 text-right text-xs text-component-node-foreground">{{ display }}</span>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
</template>
|
||||
|
||||
<template #header>
|
||||
<SearchInput v-model="searchQuery" size="lg" class="max-w-96 flex-1" />
|
||||
<SearchBox v-model="searchQuery" size="lg" class="max-w-[384px]" />
|
||||
</template>
|
||||
|
||||
<template #header-right-area>
|
||||
@@ -130,7 +130,7 @@ import CardBottom from '@/components/card/CardBottom.vue'
|
||||
import CardContainer from '@/components/card/CardContainer.vue'
|
||||
import CardTop from '@/components/card/CardTop.vue'
|
||||
import SquareChip from '@/components/chip/SquareChip.vue'
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import MultiSelect from '@/components/input/MultiSelect.vue'
|
||||
import SingleSelect from '@/components/input/SingleSelect.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
@@ -8,7 +8,7 @@ import CardContainer from '@/components/card/CardContainer.vue'
|
||||
import CardTop from '@/components/card/CardTop.vue'
|
||||
import SquareChip from '@/components/chip/SquareChip.vue'
|
||||
import MultiSelect from '@/components/input/MultiSelect.vue'
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import SingleSelect from '@/components/input/SingleSelect.vue'
|
||||
import type { NavGroupData, NavItemData } from '@/types/navTypes'
|
||||
import { OnCloseKey } from '@/types/widgetTypes'
|
||||
@@ -68,7 +68,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
|
||||
components: {
|
||||
BaseModalLayout,
|
||||
LeftSidePanel,
|
||||
SearchInput,
|
||||
SearchBox,
|
||||
MultiSelect,
|
||||
SingleSelect,
|
||||
Button,
|
||||
@@ -186,7 +186,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
|
||||
|
||||
<!-- Header -->
|
||||
<template v-if="args.hasHeader" #header>
|
||||
<SearchInput
|
||||
<SearchBox
|
||||
class="max-w-[384px]"
|
||||
size="lg"
|
||||
:modelValue="searchQuery"
|
||||
@@ -309,7 +309,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
|
||||
|
||||
<!-- Header -->
|
||||
<template v-if="args.hasHeader" #header>
|
||||
<SearchInput
|
||||
<SearchBox
|
||||
class="max-w-[384px]"
|
||||
size="lg"
|
||||
:modelValue="searchQuery"
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -81,7 +81,8 @@ function getParentNodes(): SubgraphNode[] {
|
||||
useToastStore().add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('subgraphStore.promoteOutsideSubgraph')
|
||||
detail: t('subgraphStore.promoteOutsideSubgraph'),
|
||||
life: 2000
|
||||
})
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({})
|
||||
}))
|
||||
vi.mock('@/stores/domWidgetStore', () => ({
|
||||
useDomWidgetStore: () => ({ widgetStates: new Map() })
|
||||
}))
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ updatePreviews: () => ({}) })
|
||||
}))
|
||||
|
||||
function createOuterSubgraphSetup(inputNames: string[]): {
|
||||
outerSubgraph: Subgraph
|
||||
outerSubgraphNode: SubgraphNode
|
||||
} {
|
||||
const outerSubgraph = createTestSubgraph({
|
||||
inputs: inputNames.map((name) => ({ name, type: '*' }))
|
||||
})
|
||||
const outerSubgraphNode = createTestSubgraphNode(outerSubgraph, { id: 1 })
|
||||
return { outerSubgraph, outerSubgraphNode }
|
||||
}
|
||||
|
||||
function addLinkedNestedSubgraphNode(
|
||||
outerSubgraph: Subgraph,
|
||||
inputName: string,
|
||||
linkedInputName: string,
|
||||
options: { widget?: string } = {}
|
||||
): { innerSubgraphNode: SubgraphNode } {
|
||||
const innerSubgraph = createTestSubgraph({
|
||||
inputs: [{ name: linkedInputName, type: '*' }]
|
||||
})
|
||||
const innerSubgraphNode = createTestSubgraphNode(innerSubgraph, { id: 819 })
|
||||
outerSubgraph.add(innerSubgraphNode)
|
||||
|
||||
const inputSlot = outerSubgraph.inputNode.slots.find(
|
||||
(slot) => slot.name === inputName
|
||||
)
|
||||
if (!inputSlot) throw new Error(`Missing subgraph input slot: ${inputName}`)
|
||||
|
||||
const input = innerSubgraphNode.addInput(linkedInputName, '*')
|
||||
if (options.widget) {
|
||||
innerSubgraphNode.addWidget('number', options.widget, 0, () => undefined)
|
||||
input.widget = { name: options.widget }
|
||||
}
|
||||
inputSlot.connect(input, innerSubgraphNode)
|
||||
|
||||
if (input.link == null) {
|
||||
throw new Error(`Expected link to be created for input ${linkedInputName}`)
|
||||
}
|
||||
|
||||
return { innerSubgraphNode }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('resolveSubgraphInputTarget', () => {
|
||||
test('returns target for widget-backed input on nested SubgraphNode', () => {
|
||||
const { outerSubgraph, outerSubgraphNode } = createOuterSubgraphSetup([
|
||||
'width'
|
||||
])
|
||||
addLinkedNestedSubgraphNode(outerSubgraph, 'width', 'width', {
|
||||
widget: 'width'
|
||||
})
|
||||
|
||||
const result = resolveSubgraphInputTarget(outerSubgraphNode, 'width')
|
||||
|
||||
expect(result).toMatchObject({
|
||||
nodeId: '819',
|
||||
widgetName: 'width'
|
||||
})
|
||||
})
|
||||
|
||||
test('returns undefined for non-widget input on nested SubgraphNode', () => {
|
||||
const { outerSubgraph, outerSubgraphNode } = createOuterSubgraphSetup([
|
||||
'audio'
|
||||
])
|
||||
addLinkedNestedSubgraphNode(outerSubgraph, 'audio', 'audio')
|
||||
|
||||
const result = resolveSubgraphInputTarget(outerSubgraphNode, 'audio')
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
test('resolves widget inputs but not non-widget inputs on the same nested SubgraphNode', () => {
|
||||
const { outerSubgraph, outerSubgraphNode } = createOuterSubgraphSetup([
|
||||
'width',
|
||||
'audio'
|
||||
])
|
||||
addLinkedNestedSubgraphNode(outerSubgraph, 'width', 'width', {
|
||||
widget: 'width'
|
||||
})
|
||||
addLinkedNestedSubgraphNode(outerSubgraph, 'audio', 'audio')
|
||||
|
||||
expect(
|
||||
resolveSubgraphInputTarget(outerSubgraphNode, 'width')
|
||||
).toMatchObject({
|
||||
nodeId: '819',
|
||||
widgetName: 'width'
|
||||
})
|
||||
expect(
|
||||
resolveSubgraphInputTarget(outerSubgraphNode, 'audio')
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
test('returns target for widget-backed input on plain interior node', () => {
|
||||
const { outerSubgraph, outerSubgraphNode } = createOuterSubgraphSetup([
|
||||
'seed'
|
||||
])
|
||||
|
||||
const inputSlot = outerSubgraph.inputNode.slots.find(
|
||||
(slot) => slot.name === 'seed'
|
||||
)!
|
||||
const node = new LGraphNode('Interior-seed')
|
||||
node.id = 42
|
||||
const input = node.addInput('seed_input', '*')
|
||||
node.addWidget('number', 'seed', 0, () => undefined)
|
||||
input.widget = { name: 'seed' }
|
||||
outerSubgraph.add(node)
|
||||
inputSlot.connect(input, node)
|
||||
|
||||
const result = resolveSubgraphInputTarget(outerSubgraphNode, 'seed')
|
||||
|
||||
expect(result).toMatchObject({
|
||||
nodeId: '42',
|
||||
widgetName: 'seed'
|
||||
})
|
||||
})
|
||||
|
||||
test('returns undefined for non-widget input on plain interior node', () => {
|
||||
const { outerSubgraph, outerSubgraphNode } = createOuterSubgraphSetup([
|
||||
'image'
|
||||
])
|
||||
|
||||
const inputSlot = outerSubgraph.inputNode.slots.find(
|
||||
(slot) => slot.name === 'image'
|
||||
)!
|
||||
const node = new LGraphNode('Interior-image')
|
||||
const input = node.addInput('image_input', '*')
|
||||
outerSubgraph.add(node)
|
||||
inputSlot.connect(input, node)
|
||||
|
||||
const result = resolveSubgraphInputTarget(outerSubgraphNode, 'image')
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -16,9 +16,6 @@ export function resolveSubgraphInputTarget(
|
||||
inputName,
|
||||
({ inputNode, targetInput, getTargetWidget }) => {
|
||||
if (inputNode.isSubgraphNode()) {
|
||||
const targetWidget = getTargetWidget()
|
||||
if (!targetWidget) return undefined
|
||||
|
||||
return {
|
||||
nodeId: String(inputNode.id),
|
||||
widgetName: targetInput.name
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -204,7 +204,8 @@ import { electronAPI as getElectronAPI } from '@/utils/envUtil'
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('desktopUpdate.errorInstallingUpdate')
|
||||
detail: t('desktopUpdate.errorInstallingUpdate'),
|
||||
life: 10_000
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -213,7 +214,8 @@ import { electronAPI as getElectronAPI } from '@/utils/envUtil'
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('desktopUpdate.errorCheckingUpdate')
|
||||
detail: t('desktopUpdate.errorCheckingUpdate'),
|
||||
life: 10_000
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { NodeOutputWith } from '@/schemas/apiSchema'
|
||||
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
@@ -28,6 +29,7 @@ useExtensionService().registerExtension({
|
||||
|
||||
const toUrl = (record: Record<string, string>) => {
|
||||
const params = new URLSearchParams(record)
|
||||
appendCloudResParam(params, record.filename)
|
||||
return api.apiURL(`/view?${params}${rand}`)
|
||||
}
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
143
src/lib/litegraph/src/LGraphGroup.scopeInvariants.test.ts
Normal file
143
src/lib/litegraph/src/LGraphGroup.scopeInvariants.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
233
src/lib/litegraph/src/LGraphNode.geometryMutationGuards.test.ts
Normal file
233
src/lib/litegraph/src/LGraphNode.geometryMutationGuards.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
206
src/lib/litegraph/src/LGraphNode.presentationTracking.test.ts
Normal file
206
src/lib/litegraph/src/LGraphNode.presentationTracking.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -107,8 +107,6 @@ export {
|
||||
LGraph,
|
||||
type GroupNodeConfigEntry,
|
||||
type GroupNodeWorkflowData,
|
||||
type LGraphTriggerAction,
|
||||
type LGraphTriggerParam,
|
||||
type GraphAddOptions
|
||||
} from './LGraph'
|
||||
export type { LGraphTriggerEvent } from './types/graphTriggers'
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -400,7 +400,7 @@
|
||||
"name": "تخطيط شريط التبويبات",
|
||||
"options": {
|
||||
"Default": "افتراضي",
|
||||
"Legacy": "تقليدي"
|
||||
"Integrated": "مُدمج"
|
||||
},
|
||||
"tooltip": "يتحكم في تخطيط شريط التبويبات. \"مُدمج\" ينقل عناصر المساعدة والتحكمات الخاصة بالمستخدم إلى منطقة شريط التبويبات."
|
||||
},
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -398,10 +398,10 @@
|
||||
},
|
||||
"Comfy_UI_TabBarLayout": {
|
||||
"name": "Tab Bar Layout",
|
||||
"tooltip": "Controls the elements contained in the integrated tab bar.",
|
||||
"tooltip": "Controls the layout of the tab bar. \"Integrated\" moves Help and User controls into the tab bar area.",
|
||||
"options": {
|
||||
"Default": "Default",
|
||||
"Legacy": "Legacy"
|
||||
"Integrated": "Integrated"
|
||||
}
|
||||
},
|
||||
"Comfy_UseNewMenu": {
|
||||
|
||||
@@ -952,7 +952,6 @@
|
||||
"collapseAll": "Colapsar todo",
|
||||
"color": "Color",
|
||||
"comfy": "Comfy",
|
||||
"comfyCloud": "Comfy Cloud",
|
||||
"comfyOrgLogoAlt": "Logo de ComfyOrg",
|
||||
"comingSoon": "Próximamente",
|
||||
"command": "Comando",
|
||||
@@ -1460,14 +1459,12 @@
|
||||
"unknownWidget": "Widget no visible"
|
||||
},
|
||||
"cancelThisRun": "Cancelar esta ejecución",
|
||||
"deleteAllAssets": "Eliminar todos los recursos de esta ejecución",
|
||||
"downloadAll": "Descargar todo",
|
||||
"dragAndDropImage": "Arrastra y suelta una imagen",
|
||||
"emptyWorkflowExplanation": "Tu flujo de trabajo está vacío. Necesitas algunos nodos primero para empezar a construir una aplicación.",
|
||||
"enterNodeGraph": "Entrar al grafo de nodos",
|
||||
"giveFeedback": "Enviar comentarios",
|
||||
"graphMode": "Modo gráfico",
|
||||
"hasCreditCost": "Requiere créditos adicionales",
|
||||
"linearMode": "Modo App",
|
||||
"loadTemplate": "Cargar una plantilla",
|
||||
"mobileControls": "Editar y ejecutar",
|
||||
@@ -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": {
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -400,7 +400,7 @@
|
||||
"name": "چیدمان نوار تب",
|
||||
"options": {
|
||||
"Default": "پیشفرض",
|
||||
"Legacy": "قدیمی"
|
||||
"Integrated": "یکپارچه"
|
||||
},
|
||||
"tooltip": "چیدمان نوار تب را کنترل میکند. «یکپارچه» کنترلهای راهنما و کاربر را به ناحیه نوار تب منتقل میکند."
|
||||
},
|
||||
|
||||
@@ -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 d’espace de travail. Veuillez réessayer.",
|
||||
"unsavedChanges": {
|
||||
"message": "Vous avez des modifications non enregistrées. Voulez-vous les abandonner et changer d’espace de travail ?",
|
||||
"title": "Modifications non enregistrées"
|
||||
},
|
||||
"viewWorkspace": "Voir l’espace de travail"
|
||||
},
|
||||
"workspaceAuth": {
|
||||
|
||||
@@ -400,7 +400,7 @@
|
||||
"name": "Disposition de la barre d’onglets",
|
||||
"options": {
|
||||
"Default": "Par défaut",
|
||||
"Legacy": "Héritage"
|
||||
"Integrated": "Intégrée"
|
||||
},
|
||||
"tooltip": "Contrôle la disposition de la barre d’onglets. « Intégrée » déplace les contrôles Aide et Utilisateur dans la zone de la barre d’onglets."
|
||||
},
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -400,7 +400,7 @@
|
||||
"name": "タブバーのレイアウト",
|
||||
"options": {
|
||||
"Default": "デフォルト",
|
||||
"Legacy": "レガシー"
|
||||
"Integrated": "統合"
|
||||
},
|
||||
"tooltip": "タブバーのレイアウトを制御します。「統合」を選択すると、ヘルプとユーザーコントロールがタブバーエリアに移動します。"
|
||||
},
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -400,7 +400,7 @@
|
||||
"name": "탭 바 레이아웃",
|
||||
"options": {
|
||||
"Default": "기본값",
|
||||
"Legacy": "레거시"
|
||||
"Integrated": "통합"
|
||||
},
|
||||
"tooltip": "탭 바의 레이아웃을 제어합니다. \"통합\"을 선택하면 도움말과 사용자 컨트롤이 탭 바 영역으로 이동합니다."
|
||||
},
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -400,7 +400,7 @@
|
||||
"name": "Макет панели вкладок",
|
||||
"options": {
|
||||
"Default": "По умолчанию",
|
||||
"Legacy": "Классический"
|
||||
"Integrated": "Интегрированный"
|
||||
},
|
||||
"tooltip": "Управляет расположением панели вкладок. «Интегрированный» перемещает элементы управления Справкой и Пользователем в область панели вкладок."
|
||||
},
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -400,7 +400,7 @@
|
||||
"name": "分頁列佈局",
|
||||
"options": {
|
||||
"Default": "預設",
|
||||
"Legacy": "傳統"
|
||||
"Integrated": "整合"
|
||||
},
|
||||
"tooltip": "控制分頁列的佈局。「整合」會將說明和使用者控制項移至分頁列區域。"
|
||||
},
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -400,7 +400,7 @@
|
||||
"name": "标签栏布局",
|
||||
"options": {
|
||||
"Default": "默认",
|
||||
"Legacy": "传统"
|
||||
"Integrated": "集成"
|
||||
},
|
||||
"tooltip": "控制标签栏的布局。“集成”会将帮助和用户控件移动到标签栏区域。"
|
||||
},
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user