mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-11 01:28:03 +00:00
Compare commits
2 Commits
coderabbit
...
live-previ
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee4599368c | ||
|
|
c781421cad |
3
.github/workflows/pr-backport.yaml
vendored
3
.github/workflows/pr-backport.yaml
vendored
@@ -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() {
|
||||
|
||||
@@ -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']
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
severity="primary"
|
||||
size="small"
|
||||
:model="queueModeMenuItems"
|
||||
:disabled="hasMissingNodes"
|
||||
data-testid="queue-button"
|
||||
@click="queuePrompt"
|
||||
>
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
interface IconButtonProps extends BaseButtonProps {
|
||||
onClick?: (event: MouseEvent) => void
|
||||
onClick: (event: Event) => void
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -68,8 +68,4 @@ const toggle = (event: Event) => {
|
||||
const hide = () => {
|
||||
popover.value?.hide()
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
hide
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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[] }>()
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
344
src/composables/useLivePreview.ts
Normal file
344
src/composables/useLivePreview.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import './matchType'
|
||||
import './nodeTemplates'
|
||||
import './noteNode'
|
||||
import './previewAny'
|
||||
import './stringOperations'
|
||||
import './rerouteNode'
|
||||
import './saveImageExtraOutput'
|
||||
import './saveMesh'
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
58
src/extensions/core/stringOperations.ts
Normal file
58
src/extensions/core/stringOperations.ts
Normal 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
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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"
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
:on-click="handleUploadClick"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--package-plus]" />
|
||||
<i class="icon-[lucide--upload]" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
:disabled="widget.options?.read_only"
|
||||
fluid
|
||||
data-capture-wheel="true"
|
||||
@pointerdown.capture.stop
|
||||
/>
|
||||
<LODFallback />
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }],
|
||||
[
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user