Compare commits

..

2 Commits

Author SHA1 Message Date
GitHub Action
ee4599368c [automated] Apply ESLint and Prettier fixes 2025-11-21 04:07:08 +00:00
Terry Jia
c781421cad live preview - String length and concatenate node 2025-11-20 22:46:44 -05:00
40 changed files with 648 additions and 768 deletions

View File

@@ -78,7 +78,8 @@ jobs:
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
LABELS=$(gh pr view ${{ inputs.pr_number }} --json labels | jq -r '.labels[].name')
else
LABELS=$(jq -r '.pull_request.labels[].name' "$GITHUB_EVENT_PATH")
LABELS='${{ toJSON(github.event.pull_request.labels) }}'
LABELS=$(echo "$LABELS" | jq -r '.[].name')
fi
add_target() {

View File

@@ -65,9 +65,7 @@ export class VueNodeHelpers {
* Select a specific Vue node by ID
*/
async selectNode(nodeId: string): Promise<void> {
await this.page
.locator(`[data-node-id="${nodeId}"] .lg-node-header`)
.click()
await this.page.locator(`[data-node-id="${nodeId}"]`).click()
}
/**
@@ -79,13 +77,11 @@ export class VueNodeHelpers {
// Select first node normally
await this.selectNode(nodeIds[0])
// Add additional nodes with Ctrl+click on header
// Add additional nodes with Ctrl+click
for (let i = 1; i < nodeIds.length; i++) {
await this.page
.locator(`[data-node-id="${nodeIds[i]}"] .lg-node-header`)
.click({
modifiers: ['Control']
})
await this.page.locator(`[data-node-id="${nodeIds[i]}"]`).click({
modifiers: ['Control']
})
}
}

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.33.5",
"version": "1.33.4",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",

View File

@@ -10,6 +10,7 @@
severity="primary"
size="small"
:model="queueModeMenuItems"
:disabled="hasMissingNodes"
data-testid="queue-button"
@click="queuePrompt"
>

View File

@@ -24,7 +24,7 @@ import {
import { cn } from '@/utils/tailwindUtil'
interface IconButtonProps extends BaseButtonProps {
onClick?: (event: MouseEvent) => void
onClick: (event: Event) => void
}
defineOptions({

View File

@@ -47,7 +47,7 @@ const {
} = defineProps<IconTextButtonProps>()
const buttonStyle = computed(() => {
const baseClasses = `${getBaseButtonClasses()} justify-start gap-2`
const baseClasses = `${getBaseButtonClasses()} justify-start! gap-2`
const sizeClasses = getButtonSizeClasses(size)
const typeClasses = border
? getBorderButtonTypeClasses(type)

View File

@@ -68,8 +68,4 @@ const toggle = (event: Event) => {
const hide = () => {
popover.value?.hide()
}
defineExpose({
hide
})
</script>

View File

@@ -35,6 +35,7 @@ import { ValidationState } from '@/utils/validationUtil'
const props = defineProps<{
modelValue: string
validateUrlFn?: (url: string) => Promise<boolean>
disableValidation?: boolean
}>()
const emit = defineEmits<{
@@ -101,6 +102,8 @@ const defaultValidateUrl = async (url: string): Promise<boolean> => {
}
const validateUrl = async (value: string) => {
if (props.disableValidation) return
if (validationState.value === ValidationState.LOADING) return
const url = cleanInput(value)

View File

@@ -1,10 +1,8 @@
<template>
<IconButton
type="secondary"
size="fit-content"
class="group w-full justify-between gap-3 rounded-lg p-1 text-left font-normal hover:cursor-pointer focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
<button
type="button"
class="group flex w-full items-center justify-between gap-3 rounded-lg border-0 bg-secondary-background p-1 text-left transition-colors duration-200 ease-in-out hover:cursor-pointer hover:bg-secondary-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
:aria-label="props.ariaLabel"
@click="emit('click', $event)"
>
<span class="inline-flex items-center gap-2">
<span v-if="props.mode === 'allFailed'" class="inline-flex items-center">
@@ -78,11 +76,10 @@
>
<i class="icon-[lucide--chevron-down] block size-4 leading-none" />
</span>
</IconButton>
</button>
</template>
<script setup lang="ts">
import IconButton from '@/components/button/IconButton.vue'
import type {
CompletionSummary,
CompletionSummaryMode
@@ -99,8 +96,4 @@ type Props = {
const props = withDefaults(defineProps<Props>(), {
thumbnailUrls: () => []
})
const emit = defineEmits<{
(e: 'click', event: MouseEvent): void
}>()
</script>

View File

@@ -42,19 +42,17 @@
t('sideToolbar.queueProgressOverlay.running')
}}</span>
</span>
<IconButton
<button
v-if="runningCount > 0"
v-tooltip.top="cancelJobTooltip"
type="secondary"
size="sm"
class="size-6 bg-secondary-background hover:bg-destructive-background"
class="inline-flex size-6 cursor-pointer items-center justify-center rounded border-0 bg-secondary-background p-0 transition-colors hover:bg-destructive-background"
:aria-label="t('sideToolbar.queueProgressOverlay.interruptAll')"
@click="$emit('interruptAll')"
>
<i
class="icon-[lucide--x] block size-4 leading-none text-text-primary"
/>
</IconButton>
</button>
</div>
<div class="flex items-center gap-2">
@@ -64,28 +62,26 @@
t('sideToolbar.queueProgressOverlay.queuedSuffix')
}}</span>
</span>
<IconButton
<button
v-if="queuedCount > 0"
v-tooltip.top="clearQueueTooltip"
type="secondary"
size="sm"
class="size-6 bg-secondary-background hover:bg-destructive-background"
class="inline-flex size-6 cursor-pointer items-center justify-center rounded border-0 bg-secondary-background p-0 transition-colors hover:bg-destructive-background"
:aria-label="t('sideToolbar.queueProgressOverlay.clearQueued')"
@click="$emit('clearQueued')"
>
<i
class="icon-[lucide--list-x] block size-4 leading-none text-text-primary"
/>
</IconButton>
</button>
</div>
</div>
<TextButton
class="h-6 min-w-[120px] flex-1 px-2 py-0 text-[12px]"
type="secondary"
:label="t('sideToolbar.queueProgressOverlay.viewAllJobs')"
<button
class="inline-flex h-6 min-w-[120px] flex-1 cursor-pointer items-center justify-center rounded border-0 bg-secondary-background px-2 py-0 text-[12px] text-text-primary hover:bg-secondary-background-hover hover:opacity-90"
@click="$emit('viewAllJobs')"
/>
>
{{ t('sideToolbar.queueProgressOverlay.viewAllJobs') }}
</button>
</div>
</div>
</template>
@@ -94,8 +90,6 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import IconButton from '@/components/button/IconButton.vue'
import TextButton from '@/components/button/TextButton.vue'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
defineProps<{

View File

@@ -8,20 +8,17 @@
/>
<div class="flex items-center justify-between px-3">
<IconTextButton
class="grow gap-1 p-2 text-center font-inter text-[12px] leading-none hover:opacity-90 justify-center"
type="secondary"
:label="t('sideToolbar.queueProgressOverlay.showAssets')"
<button
class="inline-flex grow cursor-pointer items-center justify-center gap-1 rounded border-0 bg-secondary-background p-2 text-center font-inter text-[12px] leading-none text-text-primary hover:bg-secondary-background-hover hover:opacity-90"
:aria-label="t('sideToolbar.queueProgressOverlay.showAssets')"
@click="$emit('showAssets')"
>
<template #icon>
<div
class="pointer-events-none block size-4 shrink-0 leading-none icon-[comfy--image-ai-edit]"
aria-hidden="true"
/>
</template>
</IconTextButton>
<div
class="pointer-events-none block size-4 shrink-0 leading-none icon-[comfy--image-ai-edit]"
aria-hidden="true"
/>
<span>{{ t('sideToolbar.queueProgressOverlay.showAssets') }}</span>
</button>
<div class="ml-4 inline-flex items-center">
<div
class="inline-flex h-6 items-center text-[12px] leading-none text-text-primary opacity-90"
@@ -31,18 +28,16 @@
t('sideToolbar.queueProgressOverlay.queuedSuffix')
}}</span>
</div>
<IconButton
<button
v-if="queuedCount > 0"
class="group ml-2 size-6 bg-secondary-background hover:bg-destructive-background"
type="secondary"
size="sm"
class="group ml-2 inline-flex size-6 cursor-pointer items-center justify-center rounded border-0 bg-secondary-background p-0 transition-colors hover:bg-destructive-background"
:aria-label="t('sideToolbar.queueProgressOverlay.clearQueued')"
@click="$emit('clearQueued')"
>
<i
class="pointer-events-none icon-[lucide--list-x] block size-4 leading-none text-text-primary transition-colors group-hover:text-base-background"
/>
</IconButton>
</button>
</div>
</div>
@@ -80,8 +75,6 @@
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import IconButton from '@/components/button/IconButton.vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import type {
JobGroup,
JobListItem,

View File

@@ -18,18 +18,16 @@
</span>
</div>
<div class="flex items-center gap-1">
<IconButton
<button
v-tooltip.top="moreTooltipConfig"
type="transparent"
size="sm"
class="size-6 bg-transparent hover:bg-secondary-background hover:opacity-100"
class="inline-flex size-6 cursor-pointer items-center justify-center rounded border-0 bg-transparent p-0 hover:bg-secondary-background hover:opacity-100"
:aria-label="t('sideToolbar.queueProgressOverlay.moreOptions')"
@click="onMoreClick"
>
<i
class="icon-[lucide--more-horizontal] block size-4 leading-none text-text-secondary"
/>
</IconButton>
</button>
<Popover
ref="morePopoverRef"
:dismissable="true"
@@ -47,19 +45,18 @@
<div
class="flex flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3 font-inter"
>
<IconTextButton
class="w-full justify-start gap-2 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90"
type="transparent"
:label="t('sideToolbar.queueProgressOverlay.clearHistory')"
<button
class="inline-flex w-full cursor-pointer items-center justify-start gap-2 rounded-lg border-0 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90"
:aria-label="t('sideToolbar.queueProgressOverlay.clearHistory')"
@click="onClearHistoryFromMenu"
>
<template #icon>
<i
class="icon-[lucide--file-x-2] block size-4 leading-none text-text-secondary"
/>
</template>
</IconTextButton>
<i
class="icon-[lucide--file-x-2] block size-4 leading-none text-text-secondary"
/>
<span>{{
t('sideToolbar.queueProgressOverlay.clearHistory')
}}</span>
</button>
</div>
</Popover>
</div>
@@ -72,8 +69,6 @@ import type { PopoverMethods } from 'primevue/popover'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import IconButton from '@/components/button/IconButton.vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
defineProps<{

View File

@@ -8,15 +8,13 @@
<p class="m-0 text-[14px] font-normal leading-none">
{{ t('sideToolbar.queueProgressOverlay.clearHistoryDialogTitle') }}
</p>
<IconButton
type="transparent"
size="sm"
class="size-6 bg-transparent text-text-secondary hover:bg-secondary-background hover:opacity-100"
<button
class="inline-flex size-6 cursor-pointer items-center justify-center rounded border-0 bg-transparent p-0 text-text-secondary transition hover:bg-secondary-background hover:opacity-100"
:aria-label="t('g.close')"
@click="onCancel"
>
<i class="icon-[lucide--x] block size-4 leading-none" />
</IconButton>
</button>
</header>
<div class="flex flex-col gap-4 px-4 py-4 text-[14px] text-text-secondary">
@@ -32,19 +30,21 @@
<footer class="flex items-center justify-end px-4 py-4">
<div class="flex items-center gap-4 text-[14px] leading-none">
<TextButton
class="min-h-[24px] px-1 py-1 text-[14px] leading-[1] text-text-secondary hover:text-text-primary"
type="transparent"
:label="t('g.cancel')"
<button
class="inline-flex min-h-[24px] cursor-pointer items-center rounded-md border-0 bg-transparent px-1 py-1 text-[14px] leading-[1] text-text-secondary transition hover:text-text-primary"
:aria-label="t('g.cancel')"
@click="onCancel"
/>
<TextButton
class="min-h-[32px] px-4 py-2 text-[12px] font-normal leading-[1]"
type="secondary"
:label="t('g.clear')"
>
{{ t('g.cancel') }}
</button>
<button
class="inline-flex min-h-[32px] items-center rounded-lg border-0 bg-secondary-background px-4 py-2 text-[12px] font-normal leading-[1] text-text-primary transition hover:bg-secondary-background-hover hover:text-text-primary disabled:cursor-not-allowed disabled:opacity-60"
:aria-label="t('g.clear')"
:disabled="isClearing"
@click="onConfirm"
/>
>
{{ t('g.clear') }}
</button>
</div>
</footer>
</section>
@@ -54,8 +54,6 @@
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import IconButton from '@/components/button/IconButton.vue'
import TextButton from '@/components/button/TextButton.vue'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useDialogStore } from '@/stores/dialogStore'
import { useQueueStore } from '@/stores/queueStore'

View File

@@ -20,24 +20,21 @@
<div v-if="entry.kind === 'divider'" class="px-2 py-1">
<div class="h-px bg-interface-stroke" />
</div>
<IconTextButton
<button
v-else
class="w-full justify-start gap-2 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-interface-panel-hover-surface"
type="transparent"
:label="entry.label"
class="inline-flex w-full cursor-pointer items-center justify-start gap-2 rounded-lg border-0 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary transition-colors duration-150 hover:bg-interface-panel-hover-surface"
:aria-label="entry.label"
@click="onEntry(entry)"
>
<template #icon>
<i
v-if="entry.icon"
:class="[
entry.icon,
'block size-4 shrink-0 leading-none text-text-secondary'
]"
/>
</template>
</IconTextButton>
<i
v-if="entry.icon"
:class="[
entry.icon,
'block size-4 shrink-0 leading-none text-text-secondary'
]"
/>
<span>{{ entry.label }}</span>
</button>
</template>
</div>
</Popover>
@@ -47,7 +44,6 @@
import Popover from 'primevue/popover'
import { ref } from 'vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import type { MenuEntry } from '@/composables/queue/useJobMenu'
defineProps<{ entries: MenuEntry[] }>()

View File

@@ -20,18 +20,17 @@
class="flex min-w-0 items-center text-[0.75rem] leading-normal font-normal text-text-secondary"
>
<span class="block min-w-0 truncate">{{ row.value }}</span>
<IconButton
<button
v-if="row.canCopy"
type="transparent"
size="sm"
class="ml-2 size-6 bg-transparent hover:opacity-90"
type="button"
class="ml-2 inline-flex size-6 items-center justify-center rounded border-0 bg-transparent p-0 hover:opacity-90"
:aria-label="copyAriaLabel"
@click.stop="copyJobId"
>
<i
class="icon-[lucide--copy] block size-4 leading-none text-text-secondary"
/>
</IconButton>
</button>
</div>
</template>
</div>
@@ -61,31 +60,25 @@
{{ t('queue.jobDetails.errorMessage') }}
</div>
<div class="flex items-center justify-between gap-4">
<IconTextButton
class="h-6 justify-start gap-2 bg-transparent px-0 text-[0.75rem] leading-none text-text-secondary hover:opacity-90"
type="transparent"
:label="copyAriaLabel"
<button
type="button"
class="inline-flex h-6 items-center justify-center gap-2 rounded border-none bg-transparent px-0 text-[0.75rem] leading-none text-text-secondary hover:opacity-90"
:aria-label="copyAriaLabel"
icon-position="right"
@click.stop="copyErrorMessage"
>
<template #icon>
<i class="icon-[lucide--copy] block size-3.5 leading-none" />
</template>
</IconTextButton>
<IconTextButton
class="h-6 justify-start gap-2 bg-transparent px-0 text-[0.75rem] leading-none text-text-secondary hover:opacity-90"
type="transparent"
:label="t('queue.jobDetails.report')"
icon-position="right"
<span>{{ copyAriaLabel }}</span>
<i class="icon-[lucide--copy] block size-3.5 leading-none" />
</button>
<button
type="button"
class="inline-flex h-6 items-center justify-center gap-2 rounded border-none bg-transparent px-0 text-[0.75rem] leading-none text-text-secondary hover:opacity-90"
@click.stop="reportJobError"
>
<template #icon>
<i
class="icon-[lucide--message-circle-warning] block size-3.5 leading-none"
/>
</template>
</IconTextButton>
<span>{{ t('queue.jobDetails.report') }}</span>
<i
class="icon-[lucide--message-circle-warning] block size-3.5 leading-none"
/>
</button>
</div>
<div
class="col-span-2 mt-2 rounded bg-interface-panel-hover-surface px-4 py-2 text-[0.75rem] leading-normal text-text-secondary"
@@ -101,8 +94,6 @@
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import IconButton from '@/components/button/IconButton.vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'

View File

@@ -2,26 +2,26 @@
<div class="flex items-center justify-between gap-2 px-3">
<div class="min-w-0 flex-1 overflow-x-auto">
<div class="inline-flex items-center gap-1 whitespace-nowrap">
<TextButton
<button
v-for="tab in visibleJobTabs"
:key="tab"
class="h-6 px-3 py-1 text-[12px] leading-none hover:opacity-90"
:type="selectedJobTab === tab ? 'secondary' : 'transparent'"
class="h-6 cursor-pointer rounded border-0 px-3 py-1 text-[12px] leading-none hover:opacity-90"
:class="[
selectedJobTab === tab ? 'text-text-primary' : 'text-text-secondary'
selectedJobTab === tab
? 'bg-secondary-background text-text-primary'
: 'bg-transparent text-text-secondary'
]"
:label="tabLabel(tab)"
@click="$emit('update:selectedJobTab', tab)"
/>
>
{{ tabLabel(tab) }}
</button>
</div>
</div>
<div class="ml-2 flex shrink-0 items-center gap-2">
<IconButton
<button
v-if="showWorkflowFilter"
v-tooltip.top="filterTooltipConfig"
type="secondary"
size="sm"
class="relative size-6 bg-secondary-background hover:bg-secondary-background-hover hover:opacity-90"
class="relative inline-flex size-6 cursor-pointer items-center justify-center rounded border-0 bg-secondary-background p-0 hover:bg-secondary-background-hover hover:opacity-90"
:aria-label="t('sideToolbar.queueProgressOverlay.filterJobs')"
@click="onFilterClick"
>
@@ -32,7 +32,7 @@
v-if="selectedWorkflowFilter !== 'all'"
class="pointer-events-none absolute -top-1 -right-1 inline-block size-2 rounded-full bg-base-foreground"
/>
</IconButton>
</button>
<Popover
v-if="showWorkflowFilter"
ref="filterPopoverRef"
@@ -51,48 +51,46 @@
<div
class="flex min-w-[12rem] flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3"
>
<IconTextButton
class="w-full justify-between gap-1 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90"
type="transparent"
icon-position="right"
:label="t('sideToolbar.queueProgressOverlay.filterAllWorkflows')"
<button
class="inline-flex w-full cursor-pointer items-center justify-start gap-1 rounded-lg border-0 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90"
:aria-label="
t('sideToolbar.queueProgressOverlay.filterAllWorkflows')
"
@click="selectWorkflowFilter('all')"
>
<template #icon>
<span>{{
t('sideToolbar.queueProgressOverlay.filterAllWorkflows')
}}</span>
<span class="ml-auto inline-flex items-center">
<i
v-if="selectedWorkflowFilter === 'all'"
class="icon-[lucide--check] block size-4 leading-none text-text-secondary"
/>
</template>
</IconTextButton>
</span>
</button>
<div class="mx-2 mt-1 h-px" />
<IconTextButton
class="w-full justify-between gap-1 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90"
type="transparent"
icon-position="right"
:label="t('sideToolbar.queueProgressOverlay.filterCurrentWorkflow')"
<button
class="inline-flex w-full cursor-pointer items-center justify-start gap-1 rounded-lg border-0 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90"
:aria-label="
t('sideToolbar.queueProgressOverlay.filterCurrentWorkflow')
"
@click="selectWorkflowFilter('current')"
>
<template #icon>
<span>{{
t('sideToolbar.queueProgressOverlay.filterCurrentWorkflow')
}}</span>
<span class="ml-auto inline-flex items-center">
<i
v-if="selectedWorkflowFilter === 'current'"
class="icon-[lucide--check] block size-4 leading-none text-text-secondary"
/>
</template>
</IconTextButton>
</span>
</button>
</div>
</Popover>
<IconButton
<button
v-tooltip.top="sortTooltipConfig"
type="secondary"
size="sm"
class="relative size-6 bg-secondary-background hover:bg-secondary-background-hover hover:opacity-90"
class="relative inline-flex size-6 cursor-pointer items-center justify-center rounded border-0 bg-secondary-background p-0 hover:bg-secondary-background-hover hover:opacity-90"
:aria-label="t('sideToolbar.queueProgressOverlay.sortJobs')"
@click="onSortClick"
>
@@ -103,7 +101,7 @@
v-if="selectedSortMode !== 'mostRecent'"
class="pointer-events-none absolute -top-1 -right-1 inline-block size-2 rounded-full bg-base-foreground"
/>
</IconButton>
</button>
<Popover
ref="sortPopoverRef"
:dismissable="true"
@@ -122,21 +120,19 @@
class="flex min-w-[12rem] flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3"
>
<template v-for="(mode, index) in jobSortModes" :key="mode">
<IconTextButton
class="w-full justify-between gap-1 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90"
type="transparent"
icon-position="right"
:label="sortLabel(mode)"
<button
class="inline-flex w-full cursor-pointer items-center justify-start gap-1 rounded-lg border-0 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90"
:aria-label="sortLabel(mode)"
@click="selectSortMode(mode)"
>
<template #icon>
<span>{{ sortLabel(mode) }}</span>
<span class="ml-auto inline-flex items-center">
<i
v-if="selectedSortMode === mode"
class="icon-[lucide--check] block size-4 leading-none text-text-secondary"
/>
</template>
</IconTextButton>
</span>
</button>
<div
v-if="index < jobSortModes.length - 1"
class="mx-2 mt-1 h-px"
@@ -153,9 +149,6 @@ import Popover from 'primevue/popover'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import IconButton from '@/components/button/IconButton.vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import TextButton from '@/components/button/TextButton.vue'
import { jobSortModes, jobTabs } from '@/composables/queue/useJobList'
import type { JobSortMode, JobTab } from '@/composables/queue/useJobList'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'

View File

@@ -108,47 +108,45 @@
key="actions"
class="inline-flex items-center gap-2 pr-1"
>
<IconButton
<button
v-if="props.state === 'failed' && computedShowClear"
v-tooltip.top="deleteTooltipConfig"
type="transparent"
size="sm"
class="h-6 transform gap-1 rounded bg-modal-card-button-surface px-1 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:bg-destructive-background hover:opacity-95"
type="button"
class="inline-flex h-6 transform cursor-pointer items-center gap-1 rounded border-0 bg-modal-card-button-surface px-1 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:bg-destructive-background hover:opacity-95"
:aria-label="t('g.delete')"
@click.stop="emit('delete')"
>
<i class="icon-[lucide--trash-2] size-4" />
</IconButton>
<IconButton
</button>
<button
v-else-if="props.state !== 'completed' && computedShowClear"
v-tooltip.top="cancelTooltipConfig"
type="transparent"
size="sm"
class="h-6 transform gap-1 rounded bg-modal-card-button-surface px-1 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:bg-destructive-background hover:opacity-95"
type="button"
class="inline-flex h-6 transform cursor-pointer items-center gap-1 rounded border-0 bg-modal-card-button-surface px-1 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:bg-destructive-background hover:opacity-95"
:aria-label="t('g.cancel')"
@click.stop="emit('cancel')"
>
<i class="icon-[lucide--x] size-4" />
</IconButton>
<TextButton
</button>
<button
v-else-if="props.state === 'completed'"
class="h-6 transform gap-1 rounded bg-modal-card-button-surface px-2 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:opacity-95"
type="transparent"
:label="t('menuLabels.View')"
type="button"
class="inline-flex h-6 transform cursor-pointer items-center gap-1 rounded border-0 bg-modal-card-button-surface px-2 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:opacity-95"
:aria-label="t('menuLabels.View')"
@click.stop="emit('view')"
/>
<IconButton
>
<span>{{ t('menuLabels.View') }}</span>
</button>
<button
v-if="props.showMenu !== undefined ? props.showMenu : true"
v-tooltip.top="moreTooltipConfig"
type="transparent"
size="sm"
class="h-6 transform gap-1 rounded bg-modal-card-button-surface px-1 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:opacity-95"
type="button"
class="inline-flex h-6 transform cursor-pointer items-center gap-1 rounded border-0 bg-modal-card-button-surface px-1 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:opacity-95"
:aria-label="t('g.more')"
@click.stop="emit('menu', $event)"
>
<i class="icon-[lucide--more-horizontal] size-4" />
</IconButton>
</button>
</div>
<div v-else key="secondary" class="pr-2">
<slot name="secondary">{{ props.rightText }}</slot>
@@ -163,8 +161,6 @@
import { computed, nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import IconButton from '@/components/button/IconButton.vue'
import TextButton from '@/components/button/TextButton.vue'
import JobDetailsPopover from '@/components/queue/job/JobDetailsPopover.vue'
import QueueAssetPreview from '@/components/queue/job/QueueAssetPreview.vue'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'

View File

@@ -85,13 +85,10 @@
:show-output-count="shouldShowOutputCount(item)"
:output-count="getOutputCount(item)"
:show-delete-button="shouldShowDeleteButton"
:open-popover-id="openPopoverId"
@click="handleAssetSelect(item)"
@zoom="handleZoomClick(item)"
@output-count-click="enterFolderView(item)"
@asset-deleted="refreshAssets"
@popover-opened="openPopoverId = item.id"
@popover-closed="openPopoverId = null"
/>
</template>
</VirtualGrid>
@@ -202,9 +199,6 @@ const folderPromptId = ref<string | null>(null)
const folderExecutionTime = ref<number | undefined>(undefined)
const isInFolderView = computed(() => folderPromptId.value !== null)
// Track which asset's popover is open (for single-instance popover management)
const openPopoverId = ref<string | null>(null)
// Determine if delete button should be shown
// Hide delete button when in input tab and not in cloud (OSS mode - files are from local folders)
const shouldShowDeleteButton = computed(() => {

View File

@@ -0,0 +1,344 @@
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import type {
IBaseWidget,
TWidgetValue
} from '@/lib/litegraph/src/types/widgets'
interface PropagationOptions {
/**
* Find output by name instead of index
*/
outputName?: string
/**
* Explicitly specify output index (default: 0)
*/
outputIndex?: number
/**
* Whether to call node.setOutputData (default: false)
*/
setOutputData?: boolean
/**
* Whether to update target widget values (default: true)
*/
updateWidget?: boolean
/**
* Whether to call widget.callback after updating (default: false)
*/
callWidgetCallback?: boolean
/**
* Whether to call targetNode.onExecuted (default: false)
*/
callOnExecuted?: boolean
/**
* Custom function to build the message for onExecuted
*/
messageBuilder?: (
targetNode: LGraphNode,
value: TWidgetValue,
link: any
) => any
/**
* Custom handlers for specific node types
* Return true if handled, false to continue with default behavior
*/
customHandlers?: Map<
string,
(node: LGraphNode, value: TWidgetValue, link: any) => boolean
>
/**
* Enable reentry protection (default: true)
*/
preventReentry?: boolean
}
/**
* Calculator function type for live preview nodes
* Takes input values and returns the computed output value
*/
type LivePreviewCalculator = (inputValues: any[]) => TWidgetValue
/**
* Configuration for setting up a live preview node
*/
interface LivePreviewNodeConfig {
/**
* The calculator function that computes output from inputs
*/
calculator: LivePreviewCalculator
/**
* Optional output index (default: 0)
*/
outputIndex?: number
/**
* Optional propagation options to use when propagating the result
*/
propagationOptions?: Omit<PropagationOptions, 'outputIndex' | 'setOutputData'>
}
/**
* Composable for managing live preview functionality in ComfyUI nodes
*
* @example
* ```typescript
* // In a node extension:
* const { setupLivePreviewNode, propagateLivePreview } = useLivePreview()
*
* // For computation nodes:
* setupLivePreviewNode(node, {
* calculator: (inputs) => {
* const [a, b] = inputs
* return a + b
* }
* })
*
* // For simple propagation:
* propagateLivePreview(node, value, {
* updateWidget: true,
* callOnExecuted: true
* })
* ```
*/
const propagationFlags = new WeakMap<LGraphNode, Set<string>>()
const nodeCalculators = new WeakMap<LGraphNode, LivePreviewNodeConfig>()
export function useLivePreview() {
function getPropagationKey(outputIndex: number): string {
return `propagating_${outputIndex}`
}
function isNodePropagating(node: LGraphNode, outputIndex: number): boolean {
const flags = propagationFlags.get(node)
return flags?.has(getPropagationKey(outputIndex)) ?? false
}
function setNodePropagating(
node: LGraphNode,
outputIndex: number,
value: boolean
): void {
if (!propagationFlags.has(node)) {
propagationFlags.set(node, new Set())
}
const flags = propagationFlags.get(node)!
const key = getPropagationKey(outputIndex)
if (value) {
flags.add(key)
} else {
flags.delete(key)
}
}
function collectNodeInputValues(node: LGraphNode): any[] {
const inputValues: any[] = []
const graph = node.graph as LGraph
if (!graph || !node.inputs) {
return inputValues
}
for (const input of node.inputs) {
if (input.link != null) {
const link = graph.links[input.link]
if (link) {
const sourceNode = graph.getNodeById(link.origin_id)
if (sourceNode && sourceNode.getOutputData) {
const outputData = sourceNode.getOutputData(link.origin_slot)
inputValues.push(outputData)
} else {
inputValues.push(undefined)
}
} else {
inputValues.push(undefined)
}
} else if (input.widget) {
const widget = node.widgets?.find((w) => w.name === input.widget?.name)
inputValues.push(widget?.value)
} else {
inputValues.push(undefined)
}
}
return inputValues
}
function triggerNodeRecalculation(node: LGraphNode): void {
const config = nodeCalculators.get(node)
if (!config) {
return
}
const inputValues = collectNodeInputValues(node)
const hasValidInputs = inputValues.some((v) => v !== undefined)
if (!hasValidInputs) {
return
}
try {
const result = config.calculator(inputValues)
if (result !== undefined) {
propagateLivePreview(node, result, {
outputIndex: config.outputIndex ?? 0,
setOutputData: true,
...config.propagationOptions
})
}
} catch (error) {
console.error(
`Error calculating live preview for node ${node.type}:`,
error
)
}
}
function propagateLivePreview(
sourceNode: LGraphNode,
value: TWidgetValue,
options: PropagationOptions = {}
): void {
const {
outputName,
outputIndex: explicitOutputIndex,
setOutputData = false,
updateWidget = true,
callWidgetCallback = false,
callOnExecuted = false,
messageBuilder,
customHandlers,
preventReentry = true
} = options
let outputIndex = explicitOutputIndex ?? 0
if (outputName && sourceNode.outputs) {
const foundIndex = sourceNode.outputs.findIndex(
(output) => output.name === outputName
)
if (foundIndex >= 0) {
outputIndex = foundIndex
}
}
if (preventReentry && isNodePropagating(sourceNode, outputIndex)) {
return
}
if (preventReentry) {
setNodePropagating(sourceNode, outputIndex, true)
}
try {
if (setOutputData && sourceNode.setOutputData && value !== undefined) {
sourceNode.setOutputData(outputIndex, value as any)
}
const output = sourceNode.outputs?.[outputIndex]
if (!output || !output.links || output.links.length === 0) {
return
}
const graph = sourceNode.graph as LGraph
if (!graph) {
return
}
for (const linkId of output.links) {
const link = graph.links[linkId]
if (!link) {
continue
}
const targetNode = graph.getNodeById(link.target_id)
if (!targetNode) {
continue
}
if (customHandlers?.has(targetNode.type)) {
const handler = customHandlers.get(targetNode.type)!
const handled = handler(targetNode, value, link)
if (handled) {
continue
}
}
if (updateWidget) {
const targetInput = targetNode.inputs?.[link.target_slot]
if (targetInput?.widget) {
const targetWidget = targetNode.widgets?.find(
(w: IBaseWidget) => w.name === targetInput.widget?.name
)
if (targetWidget) {
targetWidget.value = value
if (callWidgetCallback && targetWidget.callback) {
targetWidget.callback(value)
}
}
}
}
const hasCalculator = nodeCalculators.has(targetNode)
if (hasCalculator) {
triggerNodeRecalculation(targetNode)
continue
}
if (callOnExecuted && targetNode.onExecuted) {
const message = messageBuilder
? messageBuilder(targetNode, value, link)
: { text: [value] }
targetNode.onExecuted(message)
}
}
} finally {
if (preventReentry) {
setNodePropagating(sourceNode, outputIndex, false)
}
}
}
function setupLivePreviewNode(
node: LGraphNode,
config: LivePreviewNodeConfig
): void {
nodeCalculators.set(node, config)
const originalOnExecuted = node.onExecuted
node.onExecuted = function (message: any) {
if (originalOnExecuted) {
originalOnExecuted.call(this, message)
}
if (message.text && Array.isArray(message.text)) {
const result = config.calculator(message.text)
if (result !== undefined) {
propagateLivePreview(this, result, {
outputIndex: config.outputIndex ?? 0,
setOutputData: true,
...config.propagationOptions
})
}
}
}
}
return {
propagateLivePreview,
setupLivePreviewNode
}
}

View File

@@ -14,6 +14,7 @@ import './matchType'
import './nodeTemplates'
import './noteNode'
import './previewAny'
import './stringOperations'
import './rerouteNode'
import './saveImageExtraOutput'
import './saveMesh'

View File

@@ -17,10 +17,10 @@ useExtensionService().registerExtension({
nodeType.prototype.onNodeCreated = function () {
onNodeCreated ? onNodeCreated.apply(this, []) : undefined
const showValueWidget = ComfyWidgets['MARKDOWN'](
const showValueWidget = ComfyWidgets['STRING'](
this,
'preview',
['MARKDOWN', {}],
['STRING', { multiline: true }],
app
).widget as DOMWidget<HTMLTextAreaElement, string>

View File

@@ -0,0 +1,58 @@
import { useExtensionService } from '@/services/extensionService'
import { useLivePreview } from '@/composables/useLivePreview'
const { setupLivePreviewNode } = useLivePreview()
useExtensionService().registerExtension({
name: 'Comfy.StringLength',
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name === 'StringLength') {
const onNodeCreated = nodeType.prototype.onNodeCreated
nodeType.prototype.onNodeCreated = function () {
if (onNodeCreated) {
onNodeCreated.call(this)
}
// Set up live preview with calculator
setupLivePreviewNode(this, {
calculator: (inputs) => {
const inputString = inputs[0]
if (inputString == null) return undefined
return String(inputString).length
},
propagationOptions: {
updateWidget: true,
callOnExecuted: true
}
})
}
}
}
})
useExtensionService().registerExtension({
name: 'Comfy.StringConcatenate',
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name === 'StringConcatenate') {
const onNodeCreated = nodeType.prototype.onNodeCreated
nodeType.prototype.onNodeCreated = function () {
if (onNodeCreated) {
onNodeCreated.call(this)
}
// Set up live preview with calculator
setupLivePreviewNode(this, {
calculator: (inputs) => {
const [string_a, string_b, delimiter] = inputs
if (string_a == null && string_b == null) return undefined
return [string_a ?? '', string_b ?? ''].join(delimiter || '')
},
propagationOptions: {
updateWidget: true,
callOnExecuted: true
}
})
}
}
}
})

View File

@@ -1436,7 +1436,6 @@
"INT": "INT",
"LATENT": "LATENT",
"LATENT_OPERATION": "LATENT_OPERATION",
"LATENT_UPSCALE_MODEL": "LATENT_UPSCALE_MODEL",
"LOAD_3D": "LOAD_3D",
"LOAD_3D_ANIMATION": "LOAD_3D_ANIMATION",
"LOAD3D_CAMERA": "LOAD3D_CAMERA",
@@ -2076,8 +2075,8 @@
"failedToCreateNode": "Failed to create node. Please try again or check console for details.",
"noModelsInFolder": "No {type} available in this folder",
"searchAssetsPlaceholder": "Type to search...",
"uploadModel": "Import model",
"uploadModelFromCivitai": "Import a model from Civitai",
"uploadModel": "Upload model",
"uploadModelFromCivitai": "Upload a model from Civitai",
"uploadModelFailedToRetrieveMetadata": "Failed to retrieve metadata. Please check the link and try again.",
"onlyCivitaiUrlsSupported": "Only Civitai URLs are supported",
"uploadModelDescription1": "Paste a Civitai model download link to add it to your library.",
@@ -2094,16 +2093,16 @@
"tags": "Tags",
"tagsPlaceholder": "e.g., models, checkpoint",
"tagsHelp": "Separate tags with commas",
"upload": "Import",
"uploadingModel": "Importing model...",
"uploadSuccess": "Model imported successfully!",
"uploadFailed": "Import failed",
"upload": "Upload",
"uploadingModel": "Uploading model...",
"uploadSuccess": "Model uploaded successfully!",
"uploadFailed": "Upload failed",
"modelAssociatedWithLink": "The model associated with the link you provided:",
"modelTypeSelectorLabel": "What type of model is this?",
"modelTypeSelectorPlaceholder": "Select model type",
"selectModelType": "Select model type",
"notSureLeaveAsIs": "Not sure? Just leave this as is",
"modelUploaded": "Model imported! 🎉",
"modelUploaded": "Model uploaded! 🎉",
"findInLibrary": "Find it in the {type} section of the models library.",
"finish": "Finish",
"allModels": "All Models",
@@ -2123,7 +2122,7 @@
"errorUnsafeVirusScan": "CivitAI detected malware or suspicious content in this file",
"errorModelTypeNotSupported": "This model type is not supported",
"errorUnknown": "An unexpected error occurred",
"errorUploadFailed": "Failed to import asset. Please try again.",
"errorUploadFailed": "Failed to upload asset. Please try again.",
"ariaLabel": {
"assetCard": "{name} - {type} asset",
"loadingAsset": "Loading asset"

View File

@@ -2022,28 +2022,6 @@
}
}
},
"EmptyHunyuanVideo15Latent": {
"display_name": "EmptyHunyuanVideo15Latent",
"inputs": {
"width": {
"name": "width"
},
"height": {
"name": "height"
},
"length": {
"name": "length"
},
"batch_size": {
"name": "batch_size"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"EmptyImage": {
"display_name": "EmptyImage",
"inputs": {
@@ -2596,54 +2574,6 @@
}
}
},
"GeminiImage2Node": {
"display_name": "Nano Banana Pro (Google Gemini Image)",
"description": "Generate or edit images synchronously via Google Vertex API.",
"inputs": {
"prompt": {
"name": "prompt",
"tooltip": "Text prompt describing the image to generate or the edits to apply. Include any constraints, styles, or details the model should follow."
},
"model": {
"name": "model"
},
"seed": {
"name": "seed",
"tooltip": "When the seed is fixed to a specific value, the model makes a best effort to provide the same response for repeated requests. Deterministic output isn't guaranteed. Also, changing the model or parameter settings, such as the temperature, can cause variations in the response even when you use the same seed value. By default, a random seed value is used."
},
"aspect_ratio": {
"name": "aspect_ratio",
"tooltip": "If set to 'auto', matches your input image's aspect ratio; if no image is provided, generates a 1:1 square."
},
"resolution": {
"name": "resolution",
"tooltip": "Target output resolution. For 2K/4K the native Gemini upscaler is used."
},
"response_modalities": {
"name": "response_modalities",
"tooltip": "Choose 'IMAGE' for image-only output, or 'IMAGE+TEXT' to return both the generated image and a text response."
},
"images": {
"name": "images",
"tooltip": "Optional reference image(s). To include multiple images, use the Batch Images node (up to 14)."
},
"files": {
"name": "files",
"tooltip": "Optional file(s) to use as context for the model. Accepts inputs from the Gemini Generate Content Input Files node."
},
"control_after_generate": {
"name": "control after generate"
}
},
"outputs": {
"0": {
"tooltip": null
},
"1": {
"tooltip": null
}
}
},
"GeminiImageNode": {
"display_name": "Nano Banana (Google Gemini Image)",
"description": "Edit images synchronously via Google API.",
@@ -2672,10 +2602,6 @@
"name": "aspect_ratio",
"tooltip": "Defaults to matching the output image size to that of your input image, or otherwise generates 1:1 squares."
},
"response_modalities": {
"name": "response_modalities",
"tooltip": "Choose 'IMAGE' for image-only output, or 'IMAGE+TEXT' to return both the generated image and a text response."
},
"control_after_generate": {
"name": "control after generate"
}
@@ -2979,120 +2905,6 @@
}
}
},
"HunyuanVideo15ImageToVideo": {
"display_name": "HunyuanVideo15ImageToVideo",
"inputs": {
"positive": {
"name": "positive"
},
"negative": {
"name": "negative"
},
"vae": {
"name": "vae"
},
"width": {
"name": "width"
},
"height": {
"name": "height"
},
"length": {
"name": "length"
},
"batch_size": {
"name": "batch_size"
},
"start_image": {
"name": "start_image"
},
"clip_vision_output": {
"name": "clip_vision_output"
}
},
"outputs": {
"0": {
"name": "positive",
"tooltip": null
},
"1": {
"name": "negative",
"tooltip": null
},
"2": {
"name": "latent",
"tooltip": null
}
}
},
"HunyuanVideo15LatentUpscaleWithModel": {
"display_name": "Hunyuan Video 15 Latent Upscale With Model",
"inputs": {
"model": {
"name": "model"
},
"samples": {
"name": "samples"
},
"upscale_method": {
"name": "upscale_method"
},
"width": {
"name": "width"
},
"height": {
"name": "height"
},
"crop": {
"name": "crop"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"HunyuanVideo15SuperResolution": {
"display_name": "HunyuanVideo15SuperResolution",
"inputs": {
"positive": {
"name": "positive"
},
"negative": {
"name": "negative"
},
"latent": {
"name": "latent"
},
"noise_augmentation": {
"name": "noise_augmentation"
},
"vae": {
"name": "vae"
},
"start_image": {
"name": "start_image"
},
"clip_vision_output": {
"name": "clip_vision_output"
}
},
"outputs": {
"0": {
"name": "positive",
"tooltip": null
},
"1": {
"name": "negative",
"tooltip": null
},
"2": {
"name": "latent",
"tooltip": null
}
}
},
"HypernetworkLoader": {
"display_name": "HypernetworkLoader",
"inputs": {
@@ -4754,19 +4566,6 @@
}
}
},
"LatentUpscaleModelLoader": {
"display_name": "Load Latent Upscale Model",
"inputs": {
"model_name": {
"name": "model_name"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"LazyCache": {
"display_name": "LazyCache",
"description": "A homebrew version of EasyCache - even 'easier' version of EasyCache to implement. Overall works worse than EasyCache, but better in some rare cases AND universal compatibility with everything in ComfyUI.",

View File

@@ -38,7 +38,7 @@
:on-click="handleUploadClick"
>
<template #icon>
<i class="icon-[lucide--package-plus]" />
<i class="icon-[lucide--upload]" />
</template>
</IconTextButton>
</div>

View File

@@ -71,10 +71,9 @@
</div>
<div v-tooltip.top="$t('mediaAsset.actions.more')">
<MoreButton
ref="moreButtonRef"
size="sm"
@menu-opened="handleMenuOpened"
@menu-closed="handleMenuClosed"
@menu-opened="isMenuOpen = true"
@menu-closed="isMenuOpen = false"
@mouseenter="handleOverlayMouseEnter"
@mouseleave="handleOverlayMouseLeave"
>
@@ -140,7 +139,7 @@
</template>
<script setup lang="ts">
import { useElementHover, whenever } from '@vueuse/core'
import { useElementHover } from '@vueuse/core'
import { computed, defineAsyncComponent, provide, ref, toRef } from 'vue'
import IconButton from '@/components/button/IconButton.vue'
@@ -190,8 +189,7 @@ const {
selected,
showOutputCount,
outputCount,
showDeleteButton,
openPopoverId
showDeleteButton
} = defineProps<{
asset?: AssetItem
loading?: boolean
@@ -199,19 +197,15 @@ const {
showOutputCount?: boolean
outputCount?: number
showDeleteButton?: boolean
openPopoverId?: string | null
}>()
const emit = defineEmits<{
zoom: [asset: AssetItem]
'output-count-click': []
'asset-deleted': []
'popover-opened': []
'popover-closed': []
}>()
const cardContainerRef = ref<HTMLElement>()
const moreButtonRef = ref<InstanceType<typeof MoreButton>>()
const isVideoPlaying = ref(false)
const isMenuOpen = ref(false)
@@ -345,22 +339,4 @@ const handleOutputCountClick = () => {
const handleAssetDelete = () => {
emit('asset-deleted')
}
const handleMenuOpened = () => {
isMenuOpen.value = true
emit('popover-opened')
}
const handleMenuClosed = () => {
isMenuOpen.value = false
emit('popover-closed')
}
// Close this popover when another opens
whenever(
() => openPopoverId && openPopoverId !== asset?.id && isMenuOpen.value,
() => {
moreButtonRef.value?.hide()
}
)
</script>

View File

@@ -14,10 +14,10 @@
<label class="text-sm text-muted mb-0">
{{ $t('assetBrowser.civitaiLinkLabel') }}
</label>
<InputText
<UrlInput
v-model="url"
:placeholder="$t('assetBrowser.civitaiLinkPlaceholder')"
class="w-full"
:disable-validation="true"
/>
<p v-if="error" class="text-xs text-error">
{{ error }}
@@ -30,9 +30,10 @@
</template>
<script setup lang="ts">
import InputText from 'primevue/inputtext'
import { computed } from 'vue'
import UrlInput from '@/components/common/UrlInput.vue'
const props = defineProps<{
modelValue: string
error?: string

View File

@@ -62,15 +62,6 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
async function fetchMetadata() {
if (!canFetchMetadata.value) return
// Clean and normalize URL
let cleanedUrl = wizardData.value.url.trim()
try {
cleanedUrl = new URL(encodeURI(cleanedUrl)).toString()
} catch {
// If URL parsing fails, just use the trimmed input
}
wizardData.value.url = cleanedUrl
if (!isCivitaiUrl(wizardData.value.url)) {
uploadError.value = st(
'assetBrowser.onlyCivitaiUrlsSupported',

View File

@@ -67,7 +67,9 @@ import type {
VueNodeData,
WidgetSlotMetadata
} from '@/composables/graph/useGraphNodeManager'
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useLivePreview } from '@/composables/useLivePreview'
import { st } from '@/i18n'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
@@ -83,12 +85,16 @@ import { cn } from '@/utils/tailwindUtil'
import InputSlot from './InputSlot.vue'
const { propagateLivePreview } = useLivePreview()
interface NodeWidgetsProps {
nodeData?: VueNodeData
}
const { nodeData } = defineProps<NodeWidgetsProps>()
const { nodeManager } = useVueNodeLifecycle()
const { shouldHandleNodePointerEvents, forwardEventToCanvas } =
useCanvasInteractions()
function handleWidgetPointerEvent(event: PointerEvent) {
@@ -113,6 +119,24 @@ const { getWidgetTooltip, createTooltipConfig } = useNodeTooltips(
nodeType.value
)
function propagateToDownstreamVue(
sourceNodeId: string,
widgetName: string,
value: WidgetValue
): void {
const lgNode = nodeManager.value?.getNode(sourceNodeId)
if (!lgNode || !value) {
return
}
propagateLivePreview(lgNode, value, {
outputName: widgetName,
updateWidget: true,
callWidgetCallback: false,
callOnExecuted: false
})
}
interface ProcessedWidget {
name: string
type: string
@@ -170,6 +194,10 @@ const processedWidgets = computed((): ProcessedWidget[] => {
if (widget.type !== 'asset') {
widget.callback?.(value)
}
if (nodeData?.id && nodeManager.value) {
propagateToDownstreamVue(nodeData.id, widget.name, value)
}
}
const tooltipText = getWidgetTooltip(widget)

View File

@@ -57,7 +57,7 @@ const renderedHtml = computed(() => {
// Methods
const startEditing = async () => {
if (isEditing.value || widget.options?.read_only) return
if (isEditing.value) return
isEditing.value = true
await nextTick()

View File

@@ -12,7 +12,6 @@
:disabled="widget.options?.read_only"
fluid
data-capture-wheel="true"
@pointerdown.capture.stop
/>
<LODFallback />
</div>

View File

@@ -6,7 +6,6 @@ import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import type { ComponentWidgetStandardProps } from '@/scripts/domWidget'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
type TextPreviewCustomProps = Omit<
InstanceType<typeof TextPreviewWidget>['$props'],
@@ -15,15 +14,15 @@ type TextPreviewCustomProps = Omit<
const PADDING = 16
export function useTextPreviewWidget(
export const useTextPreviewWidget = (
options: {
minHeight?: number
} = {}
): ComfyWidgetConstructorV2 {
function widgetConstructor(
) => {
const widgetConstructor: ComfyWidgetConstructorV2 = (
node: LGraphNode,
inputSpec: InputSpec
): IBaseWidget {
) => {
const widgetValue = ref<string>('')
const widget = new ComponentWidgetImpl<
string | object,
@@ -42,10 +41,8 @@ export function useTextPreviewWidget(
widgetValue.value = typeof value === 'string' ? value : String(value)
},
getMinHeight: () => options.minHeight ?? 42 + PADDING,
serialize: false,
read_only: true
},
type: inputSpec.type
serialize: false
}
})
addWidget(node, widget)
return widget

View File

@@ -4,6 +4,10 @@ import { isStringInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { app } from '@/scripts/app'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import type { WidgetValue } from '@/types/simplifiedWidget'
import { useLivePreview } from '@/composables/useLivePreview'
const { propagateLivePreview } = useLivePreview()
const TRACKPAD_DETECTION_THRESHOLD = 50
@@ -119,6 +123,22 @@ export const useStringWidget = () => {
const defaultVal = inputSpec.default ?? ''
const multiline = inputSpec.multiline
const propagateCallback = (value: WidgetValue) => {
if (!value) {
return
}
// Simple propagation: just send the value downstream
// - Nodes with calculators will automatically recalculate
// - Passive nodes (like PreviewAny) will receive onExecuted
propagateLivePreview(node, value, {
outputName: inputSpec.name,
setOutputData: true,
updateWidget: true,
callOnExecuted: true
})
}
const widget = multiline
? addMultilineWidget(node, inputSpec.name, {
defaultVal,
@@ -130,6 +150,23 @@ export const useStringWidget = () => {
widget.dynamicPrompts = inputSpec.dynamicPrompts
}
const originalCallback = widget.callback
widget.callback = function (value: WidgetValue) {
if (originalCallback) {
;(originalCallback as any).call(this, value)
}
const input = node.inputs?.find(
(input) => input.widget?.name === inputSpec.name
)
if (input?.link) {
return
}
propagateCallback(value)
}
return widget
}

View File

@@ -134,11 +134,7 @@ const coreWidgetDefinitions: Array<[string, WidgetDefinition]> = [
],
[
'markdown',
{
component: WidgetMarkdown,
aliases: ['MARKDOWN', 'progressText'],
essential: false
}
{ component: WidgetMarkdown, aliases: ['MARKDOWN'], essential: false }
],
['legacy', { component: WidgetLegacy, aliases: [], essential: true }],
[

View File

@@ -303,11 +303,10 @@ export class ComponentWidgetImpl<
inputSpec: InputSpec
props?: P
options: DOMWidgetOptions<V>
type?: string
}) {
super({
type: 'custom',
...obj
...obj,
type: 'custom'
})
this.component = obj.component
this.inputSpec = obj.inputSpec

View File

@@ -24,12 +24,7 @@ export const useApiKeyAuthStore = defineStore('apiKeyAuth', () => {
const isAuthenticated = computed(() => !!currentUser.value)
const initializeUserFromApiKey = async () => {
const createCustomerResponse = await firebaseAuthStore
.createCustomer()
.catch((err) => {
console.error(err)
return
})
const createCustomerResponse = await firebaseAuthStore.createCustomer()
if (!createCustomerResponse) {
apiKey.value = null
throw new Error(t('auth.login.noAssociatedUser'))

View File

@@ -349,49 +349,3 @@ describe('useTransformState', () => {
})
})
})
describe('useTransformState - Non-Shared Behavior', () => {
it('should create independent instances for multiple calls', () => {
const state1 = useTransformState()
const state2 = useTransformState()
// Modify state1
const mockCanvas1 = {
ds: { offset: [100, 200], scale: 1.5 }
} as any
state1.syncWithCanvas(mockCanvas1)
// Modify state2 differently
const mockCanvas2 = {
ds: { offset: [300, 400], scale: 0.5 }
} as any
state2.syncWithCanvas(mockCanvas2)
// Each instance should have its own camera state
expect(state1.camera.x).toBe(100)
expect(state1.camera.z).toBe(1.5)
expect(state2.camera.x).toBe(300)
expect(state2.camera.z).toBe(0.5)
})
it('should handle ArrayLike<number> parameters correctly', () => {
const { isNodeInViewport } = useTransformState()
const viewport = { width: 800, height: 600 }
// Test with regular arrays
const arrayPos: number[] = [100, 200]
const arraySize: number[] = [150, 100]
expect(() => isNodeInViewport(arrayPos, arraySize, viewport)).not.toThrow()
// Test with TypedArrays
const typedPos = new Float32Array([100, 200])
const typedSize = new Float64Array([150, 100])
expect(() => isNodeInViewport(typedPos, typedSize, viewport)).not.toThrow()
// Test with tuples
const tuplePos: [number, number] = [100, 200]
const tupleSize: [number, number] = [150, 100]
expect(() => isNodeInViewport(tuplePos, tupleSize, viewport)).not.toThrow()
})
})

View File

@@ -1,59 +0,0 @@
import { describe, expect, it } from 'vitest'
import { inject, provide } from 'vue'
import {
TransformStateKey,
type TransformState
} from '@/renderer/core/layout/injectionKeys'
describe('injectionKeys', () => {
describe('TransformStateKey', () => {
it('should be a valid Symbol injection key', () => {
expect(TransformStateKey).toBeDefined()
expect(typeof TransformStateKey).toBe('symbol')
expect(TransformStateKey.toString()).toContain('transformState')
})
it('should work with Vue provide/inject', () => {
const mockTransformState: TransformState = {
camera: { x: 0, y: 0, z: 1 },
screenToCanvas: () => ({ x: 0, y: 0 }),
canvasToScreen: () => ({ x: 0, y: 0 }),
isNodeInViewport: () => true
}
// Simulate provide
provide(TransformStateKey, mockTransformState)
// Simulate inject
const injected = inject(TransformStateKey)
expect(injected).toBe(mockTransformState)
})
it('should enforce correct TransformState interface structure', () => {
const validState: TransformState = {
camera: { x: 10, y: 20, z: 1.5 },
screenToCanvas: (point: { x: number; y: number }) => ({
x: point.x / 1.5 - 10,
y: point.y / 1.5 - 20
}),
canvasToScreen: (point: { x: number; y: number }) => ({
x: (point.x + 10) * 1.5,
y: (point.y + 20) * 1.5
}),
isNodeInViewport: (
nodePos: ArrayLike<number>,
nodeSize: ArrayLike<number>,
viewport: { width: number; height: number },
margin?: number
) => {
return true
}
}
// Type check - should compile without errors
const _check: TransformState = validState
expect(_check).toBeDefined()
})
})
})

View File

@@ -1,95 +0,0 @@
import { describe, expect, it } from 'vitest'
import { calculateNodeBounds } from '@/renderer/core/spatial/boundsCalculator'
describe('boundsCalculator', () => {
describe('calculateNodeBounds', () => {
it('should calculate bounds for single node', () => {
const nodes = [{ pos: [100, 200], size: [150, 100] }]
const bounds = calculateNodeBounds(nodes)
expect(bounds).toEqual({
minX: 100,
minY: 200,
maxX: 250,
maxY: 300,
width: 150,
height: 100
})
})
it('should calculate bounds for multiple nodes', () => {
const nodes = [
{ pos: [100, 200], size: [150, 100] },
{ pos: [300, 400], size: [200, 150] },
{ pos: [50, 100], size: [100, 80] }
]
const bounds = calculateNodeBounds(nodes)
expect(bounds).toEqual({
minX: 50,
minY: 100,
maxX: 500,
maxY: 550,
width: 450,
height: 450
})
})
it('should return null for empty array', () => {
const nodes: Array<{ pos: ArrayLike<number>; size: ArrayLike<number> }> =
[]
const bounds = calculateNodeBounds(nodes)
expect(bounds).toBeNull()
})
it('should handle nodes with negative coordinates', () => {
const nodes = [
{ pos: [-100, -200], size: [150, 100] },
{ pos: [50, 100], size: [100, 80] }
]
const bounds = calculateNodeBounds(nodes)
expect(bounds).toEqual({
minX: -100,
minY: -200,
maxX: 150,
maxY: 180,
width: 250,
height: 380
})
})
it('should accept TypedArray for position and size', () => {
const nodes = [
{ pos: new Float32Array([100, 200]), size: [150, 100] },
{ pos: new Float64Array([300, 400]), size: new Float32Array([200, 150]) }
]
const bounds = calculateNodeBounds(nodes)
expect(bounds).not.toBeNull()
expect(bounds!.minX).toBe(100)
expect(bounds!.minY).toBe(200)
})
it('should handle large number of nodes efficiently', () => {
const nodes = Array.from({ length: 10000 }, (_, i) => ({
pos: [i * 10, i * 10],
size: [100, 100]
}))
const start = performance.now()
const bounds = calculateNodeBounds(nodes)
const end = performance.now()
expect(bounds).not.toBeNull()
expect(end - start).toBeLessThan(100)
})
})
})

View File

@@ -399,83 +399,3 @@ describe('useNodeEventHandlers', () => {
})
})
})
describe('useNodeEventHandlers - New Batch Operations', () => {
describe('selectNodes', () => {
it('should select multiple nodes at once', () => {
const { selectNodes } = useNodeEventHandlers()
const mockNodes = [
{ id: 'node1', selected: false },
{ id: 'node2', selected: false },
{ id: 'node3', selected: false }
]
mockNodeManager.value!.getNode = vi.fn((id: string) =>
mockNodes.find((n) => n.id === id)
) as any
const canvasStore = useCanvasStore()
canvasStore.canvas!.select = vi.fn()
canvasStore.canvas!.deselectAll = vi.fn()
selectNodes(['node1', 'node2', 'node3'], false)
expect(canvasStore.canvas!.deselectAll).toHaveBeenCalled()
expect(canvasStore.canvas!.select).toHaveBeenCalledTimes(3)
})
it('should skip non-existent nodes', () => {
const { selectNodes } = useNodeEventHandlers()
mockNodeManager.value!.getNode = vi.fn(() => null)
const canvasStore = useCanvasStore()
canvasStore.canvas!.select = vi.fn()
selectNodes(['missing-node'], false)
expect(canvasStore.canvas!.select).not.toHaveBeenCalled()
})
})
describe('deselectNodes', () => {
it('should deselect multiple nodes', () => {
const { deselectNodes } = useNodeEventHandlers()
const mockNodes = [
{ id: 'node1', selected: true },
{ id: 'node2', selected: true }
]
mockNodeManager.value!.getNode = vi.fn((id: string) =>
mockNodes.find((n) => n.id === id)
) as any
const canvasStore = useCanvasStore()
canvasStore.canvas!.deselect = vi.fn()
deselectNodes(['node1', 'node2'])
expect(canvasStore.canvas!.deselect).toHaveBeenCalledTimes(2)
})
})
describe('deselectNode', () => {
it('should deselect a single node', () => {
const { deselectNode } = useNodeEventHandlers()
const mockNodeToDeselect = { id: 'test-node', selected: true }
mockNodeManager.value!.getNode = vi.fn(() => mockNodeToDeselect as any)
const canvasStore = useCanvasStore()
canvasStore.canvas!.deselect = vi.fn()
deselectNode('test-node')
expect(canvasStore.canvas!.deselect).toHaveBeenCalledWith(
mockNodeToDeselect
)
})
})
})