Compare commits
17 Commits
v1.34.0
...
backport-6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
338da7f72e | ||
|
|
6c3408592e | ||
|
|
b6632443dc | ||
|
|
c8a1df3a05 | ||
|
|
39204135ba | ||
|
|
ffa55cb92b | ||
|
|
3856e0deea | ||
|
|
7b589b5502 | ||
|
|
72a2581068 | ||
|
|
22aea29a0d | ||
|
|
637c1995b4 | ||
|
|
550ca0c911 | ||
|
|
896867b03c | ||
|
|
0cd0218946 | ||
|
|
334404aa3b | ||
|
|
31d842217b | ||
|
|
3dd3e26003 |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 112 KiB |
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../../../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Vue Node Resizing', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test('should resize node without position drift after selecting', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Get a Vue node fixture
|
||||
const node = await comfyPage.vueNodes.getFixtureByTitle('Load Checkpoint')
|
||||
const initialBox = await node.boundingBox()
|
||||
if (!initialBox) throw new Error('Node bounding box not found')
|
||||
|
||||
// Select the node first (this was causing the bug)
|
||||
await node.header.click()
|
||||
await comfyPage.page.waitForTimeout(100) // Brief pause after selection
|
||||
|
||||
// Get position after selection
|
||||
const selectedBox = await node.boundingBox()
|
||||
if (!selectedBox)
|
||||
throw new Error('Node bounding box not found after select')
|
||||
|
||||
// Verify position unchanged after selection
|
||||
expect(selectedBox.x).toBeCloseTo(initialBox.x, 1)
|
||||
expect(selectedBox.y).toBeCloseTo(initialBox.y, 1)
|
||||
|
||||
// Now resize from bottom-right corner
|
||||
const resizeStartX = selectedBox.x + selectedBox.width - 5
|
||||
const resizeStartY = selectedBox.y + selectedBox.height - 5
|
||||
|
||||
await comfyPage.page.mouse.move(resizeStartX, resizeStartY)
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.page.mouse.move(resizeStartX + 50, resizeStartY + 30)
|
||||
await comfyPage.page.mouse.up()
|
||||
|
||||
// Get final position and size
|
||||
const finalBox = await node.boundingBox()
|
||||
if (!finalBox) throw new Error('Node bounding box not found after resize')
|
||||
|
||||
// Position should NOT have changed (the bug was position drift)
|
||||
expect(finalBox.x).toBeCloseTo(initialBox.x, 1)
|
||||
expect(finalBox.y).toBeCloseTo(initialBox.y, 1)
|
||||
|
||||
// Size should have increased
|
||||
expect(finalBox.width).toBeGreaterThan(initialBox.width)
|
||||
expect(finalBox.height).toBeGreaterThan(initialBox.height)
|
||||
})
|
||||
})
|
||||
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 143 KiB After Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 122 KiB |
@@ -88,12 +88,14 @@ export function comfyAPIPlugin(isDev: boolean): Plugin {
|
||||
|
||||
if (result.exports.length > 0) {
|
||||
const projectRoot = process.cwd()
|
||||
const relativePath = path.relative(path.join(projectRoot, 'src'), id)
|
||||
const relativePath = path
|
||||
.relative(path.join(projectRoot, 'src'), id)
|
||||
.replace(/\\/g, '/')
|
||||
const shimFileName = relativePath.replace(/\.ts$/, '.js')
|
||||
|
||||
let shimContent = `// Shim for ${relativePath}\n`
|
||||
|
||||
const fileKey = relativePath.replace(/\.ts$/, '').replace(/\\/g, '/')
|
||||
const fileKey = relativePath.replace(/\.ts$/, '')
|
||||
const warningMessage = getWarningMessage(fileKey, shimFileName)
|
||||
|
||||
if (warningMessage) {
|
||||
|
||||
24
cloud-loader-dropdown.md
Normal file
@@ -0,0 +1,24 @@
|
||||
Fixes loader dropdown placeholder
|
||||
===============================
|
||||
|
||||
Cloud loader dropdowns hydrate via `useAssetWidgetData(nodeType)`, so `dropdownItems` stays empty until the Asset API returns friendly filenames. Meanwhile `modelValue` already holds the saved asset and the watcher at [WidgetSelectDropdown.vue#L215-L227](https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue#L215-L227) only tracks `modelValue`. It runs before assets load, fails to find a match, clears `selectedSet`, and the placeholder persists.
|
||||
|
||||
```ts
|
||||
watch(
|
||||
modelValue,
|
||||
(currentValue) => {
|
||||
if (currentValue === undefined) {
|
||||
selectedSet.value.clear()
|
||||
return
|
||||
}
|
||||
const item = dropdownItems.value.find((item) => item.name === currentValue)
|
||||
if (item) {
|
||||
selectedSet.value.clear()
|
||||
selectedSet.value.add(item.id)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
```
|
||||
|
||||
Once the API resolves, `dropdownItems` recomputes but nothing resyncs because the watcher never sees that change. Desktop doesn’t hit this because it still reads from `widget.options.values` immediately.
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.34.0",
|
||||
"version": "1.33.9",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -44,17 +44,22 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useQueueSettingsStore } from '@/stores/queueStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
||||
import { graphHasMissingNodes } from '@/workbench/extensions/manager/utils/graphHasMissingNodes'
|
||||
|
||||
import BatchCountEdit from '../BatchCountEdit.vue'
|
||||
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const { mode: queueMode, batchCount } = storeToRefs(useQueueSettingsStore())
|
||||
|
||||
const { hasMissingNodes } = useMissingNodes()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const hasMissingNodes = computed(() =>
|
||||
graphHasMissingNodes(app.graph, nodeDefStore.nodeDefsByName)
|
||||
)
|
||||
|
||||
const { t } = useI18n()
|
||||
const queueModeMenuItemLookup = computed(() => {
|
||||
|
||||
@@ -64,11 +64,13 @@ import {
|
||||
ComfyWorkflow,
|
||||
useWorkflowStore
|
||||
} from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
||||
import { appendJsonExt } from '@/utils/formatUtil'
|
||||
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
||||
import { graphHasMissingNodes } from '@/workbench/extensions/manager/utils/graphHasMissingNodes'
|
||||
|
||||
interface Props {
|
||||
item: MenuItem
|
||||
@@ -79,7 +81,10 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
isActive: false
|
||||
})
|
||||
|
||||
const { hasMissingNodes } = useMissingNodes()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const hasMissingNodes = computed(() =>
|
||||
graphHasMissingNodes(app.graph, nodeDefStore.nodeDefsByName)
|
||||
)
|
||||
|
||||
const { t } = useI18n()
|
||||
const menu = ref<InstanceType<typeof Menu> & MenuState>()
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
<template>
|
||||
<div :class="iconGroupClasses">
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex justify-center items-center shrink-0 outline-hidden border-none p-0 rounded-lg bg-secondary-background shadow-sm transition-all duration-200 cursor-pointer'
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const iconGroupClasses = cn(
|
||||
'flex justify-center items-center shrink-0',
|
||||
'outline-hidden border-none p-0 rounded-lg',
|
||||
'bg-secondary-background shadow-sm',
|
||||
'transition-all duration-200',
|
||||
'cursor-pointer'
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="relative inline-flex items-center">
|
||||
<IconButton :size="size" :type="type" @click="toggle">
|
||||
<IconButton :size="size" :type="type" @click="popover?.toggle">
|
||||
<i v-if="!isVertical" class="icon-[lucide--ellipsis] text-sm" />
|
||||
<i v-else class="icon-[lucide--more-vertical] text-sm" />
|
||||
</IconButton>
|
||||
@@ -25,8 +25,18 @@
|
||||
)
|
||||
}
|
||||
}"
|
||||
@show="$emit('menuOpened')"
|
||||
@hide="$emit('menuClosed')"
|
||||
@show="
|
||||
() => {
|
||||
isOpen = true
|
||||
$emit('menuOpened')
|
||||
}
|
||||
"
|
||||
@hide="
|
||||
() => {
|
||||
isOpen = false
|
||||
$emit('menuClosed')
|
||||
}
|
||||
"
|
||||
>
|
||||
<div class="flex min-w-40 flex-col gap-2 p-2">
|
||||
<slot :close="hide" />
|
||||
@@ -48,8 +58,6 @@ interface MoreButtonProps extends BaseButtonProps {
|
||||
isVertical?: boolean
|
||||
}
|
||||
|
||||
const popover = ref<InstanceType<typeof Popover>>()
|
||||
|
||||
const {
|
||||
size = 'md',
|
||||
type = 'secondary',
|
||||
@@ -61,15 +69,15 @@ defineEmits<{
|
||||
menuClosed: []
|
||||
}>()
|
||||
|
||||
const toggle = (event: Event) => {
|
||||
popover.value?.toggle(event)
|
||||
}
|
||||
const isOpen = ref(false)
|
||||
const popover = ref<InstanceType<typeof Popover>>()
|
||||
|
||||
const hide = () => {
|
||||
function hide() {
|
||||
popover.value?.hide()
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
hide
|
||||
hide,
|
||||
isOpen
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
...inputAttrs
|
||||
}
|
||||
}"
|
||||
@keyup.enter="blurInputElement"
|
||||
@keyup.escape="cancelEditing"
|
||||
@keyup.enter.capture.stop="blurInputElement"
|
||||
@keyup.escape.stop="cancelEditing"
|
||||
@click.stop
|
||||
@pointerdown.stop.capture
|
||||
@pointermove.stop.capture
|
||||
@@ -38,7 +38,7 @@ const {
|
||||
} = defineProps<{
|
||||
modelValue: string
|
||||
isEditing?: boolean
|
||||
inputAttrs?: Record<string, any>
|
||||
inputAttrs?: Record<string, string>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'edit', 'cancel'])
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<component
|
||||
:is="item.headerComponent"
|
||||
v-if="item.headerComponent"
|
||||
v-bind="item.headerProps"
|
||||
:id="item.key"
|
||||
/>
|
||||
<h3 v-else :id="item.key">
|
||||
|
||||
19
src/components/dialog/confirm/ConfirmBody.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col px-4 py-2 text-sm text-muted-foreground border-t border-border-default"
|
||||
>
|
||||
<p v-if="promptTextReal">
|
||||
{{ promptTextReal }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed, toValue } from 'vue'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
|
||||
const { promptText } = defineProps<{
|
||||
promptText?: MaybeRefOrGetter<string>
|
||||
}>()
|
||||
|
||||
const promptTextReal = computed(() => toValue(promptText))
|
||||
</script>
|
||||
43
src/components/dialog/confirm/ConfirmFooter.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<section class="w-full flex gap-2 justify-end px-2 pb-2">
|
||||
<TextButton
|
||||
:label="cancelTextX"
|
||||
:disabled
|
||||
type="transparent"
|
||||
autofocus
|
||||
@click="$emit('cancel')"
|
||||
/>
|
||||
<TextButton
|
||||
:label="confirmTextX"
|
||||
:disabled
|
||||
type="transparent"
|
||||
:class="confirmClass"
|
||||
@click="$emit('confirm')"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed, toValue } from 'vue'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import TextButton from '@/components/button/TextButton.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { cancelText, confirmText, confirmClass, optionsDisabled } = defineProps<{
|
||||
cancelText?: string
|
||||
confirmText?: string
|
||||
confirmClass?: string
|
||||
optionsDisabled?: MaybeRefOrGetter<boolean>
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
cancel: []
|
||||
confirm: []
|
||||
}>()
|
||||
|
||||
const confirmTextX = computed(() => confirmText || t('g.confirm'))
|
||||
const cancelTextX = computed(() => cancelText || t('g.cancel'))
|
||||
const disabled = computed(() => toValue(optionsDisabled))
|
||||
</script>
|
||||
12
src/components/dialog/confirm/ConfirmHeader.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center gap-2 p-4 font-bold text-sm text-base-foreground font-inter"
|
||||
>
|
||||
<span v-if="title" class="flex-auto">{{ title }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
title?: string
|
||||
}>()
|
||||
</script>
|
||||
31
src/components/dialog/confirm/confirmDialog.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import ConfirmBody from '@/components/dialog/confirm/ConfirmBody.vue'
|
||||
import ConfirmFooter from '@/components/dialog/confirm/ConfirmFooter.vue'
|
||||
import ConfirmHeader from '@/components/dialog/confirm/ConfirmHeader.vue'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import type { ComponentAttrs } from 'vue-component-type-helpers'
|
||||
|
||||
interface ConfirmDialogOptions {
|
||||
headerProps?: ComponentAttrs<typeof ConfirmHeader>
|
||||
props?: ComponentAttrs<typeof ConfirmBody>
|
||||
footerProps?: ComponentAttrs<typeof ConfirmFooter>
|
||||
}
|
||||
|
||||
export function showConfirmDialog(options: ConfirmDialogOptions = {}) {
|
||||
const dialogStore = useDialogStore()
|
||||
const { headerProps, props, footerProps } = options
|
||||
return dialogStore.showDialog({
|
||||
headerComponent: ConfirmHeader,
|
||||
component: ConfirmBody,
|
||||
footerComponent: ConfirmFooter,
|
||||
headerProps,
|
||||
props,
|
||||
footerProps,
|
||||
dialogComponentProps: {
|
||||
pt: {
|
||||
header: 'py-0! px-0!',
|
||||
content: 'p-0!',
|
||||
footer: 'p-0!'
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -73,6 +73,7 @@
|
||||
@click.stop="handleNodes2ToggleClick"
|
||||
>
|
||||
<span class="p-menubar-item-label text-nowrap">{{ item.label }}</span>
|
||||
<Tag severity="info" class="ml-2 text-xs">{{ $t('g.beta') }}</Tag>
|
||||
<ToggleSwitch
|
||||
v-model="nodes2Enabled"
|
||||
class="ml-4"
|
||||
@@ -101,6 +102,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import Tag from 'primevue/tag'
|
||||
import TieredMenu from 'primevue/tieredmenu'
|
||||
import type { TieredMenuMethods, TieredMenuState } from 'primevue/tieredmenu'
|
||||
import ToggleSwitch from 'primevue/toggleswitch'
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { useLayoutSync } from '@/renderer/core/layout/sync/useLayoutSync'
|
||||
import { removeNodeTitleHeight } from '@/renderer/core/layout/utils/nodeSizeUtil'
|
||||
import { ensureCorrectLayoutScale } from '@/renderer/extensions/vueNodes/layout/ensureCorrectLayoutScale'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
@@ -38,7 +39,10 @@ function useVueNodeLifecycleIndividual() {
|
||||
const nodes = activeGraph._nodes.map((node: LGraphNode) => ({
|
||||
id: node.id.toString(),
|
||||
pos: [node.pos[0], node.pos[1]] as [number, number],
|
||||
size: [node.size[0], node.size[1]] as [number, number]
|
||||
size: [node.size[0], removeNodeTitleHeight(node.size[1])] as [
|
||||
number,
|
||||
number
|
||||
]
|
||||
}))
|
||||
layoutStore.initializeFromLiteGraph(nodes)
|
||||
|
||||
|
||||
@@ -49,6 +49,21 @@ const calculateRunwayDurationPrice = (node: LGraphNode): string => {
|
||||
return `$${cost}/Run`
|
||||
}
|
||||
|
||||
const makeOmniProDurationCalculator =
|
||||
(pricePerSecond: number): PricingFunction =>
|
||||
(node: LGraphNode): string => {
|
||||
const durationWidget = node.widgets?.find(
|
||||
(w) => w.name === 'duration'
|
||||
) as IComboWidget
|
||||
if (!durationWidget) return `$${pricePerSecond.toFixed(3)}/second`
|
||||
|
||||
const seconds = parseFloat(String(durationWidget.value))
|
||||
if (!Number.isFinite(seconds)) return `$${pricePerSecond.toFixed(3)}/second`
|
||||
|
||||
const cost = pricePerSecond * seconds
|
||||
return `$${cost.toFixed(2)}/Run`
|
||||
}
|
||||
|
||||
const pixversePricingCalculator = (node: LGraphNode): string => {
|
||||
const durationWidget = node.widgets?.find(
|
||||
(w) => w.name === 'duration_seconds'
|
||||
@@ -131,6 +146,11 @@ const byteDanceVideoPricingCalculator = (node: LGraphNode): string => {
|
||||
'720p': [0.51, 0.56],
|
||||
'1080p': [1.18, 1.22]
|
||||
},
|
||||
'seedance-1-0-pro-fast': {
|
||||
'480p': [0.09, 0.1],
|
||||
'720p': [0.21, 0.23],
|
||||
'1080p': [0.47, 0.49]
|
||||
},
|
||||
'seedance-1-0-lite': {
|
||||
'480p': [0.17, 0.18],
|
||||
'720p': [0.37, 0.41],
|
||||
@@ -138,11 +158,13 @@ const byteDanceVideoPricingCalculator = (node: LGraphNode): string => {
|
||||
}
|
||||
}
|
||||
|
||||
const modelKey = model.includes('seedance-1-0-pro')
|
||||
? 'seedance-1-0-pro'
|
||||
: model.includes('seedance-1-0-lite')
|
||||
? 'seedance-1-0-lite'
|
||||
: ''
|
||||
const modelKey = model.includes('seedance-1-0-pro-fast')
|
||||
? 'seedance-1-0-pro-fast'
|
||||
: model.includes('seedance-1-0-pro')
|
||||
? 'seedance-1-0-pro'
|
||||
: model.includes('seedance-1-0-lite')
|
||||
? 'seedance-1-0-lite'
|
||||
: ''
|
||||
|
||||
const resKey = resolution.includes('1080')
|
||||
? '1080p'
|
||||
@@ -699,6 +721,21 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
KlingVirtualTryOnNode: {
|
||||
displayPrice: '$0.07/Run'
|
||||
},
|
||||
KlingOmniProTextToVideoNode: {
|
||||
displayPrice: makeOmniProDurationCalculator(0.112)
|
||||
},
|
||||
KlingOmniProFirstLastFrameNode: {
|
||||
displayPrice: makeOmniProDurationCalculator(0.112)
|
||||
},
|
||||
KlingOmniProImageToVideoNode: {
|
||||
displayPrice: makeOmniProDurationCalculator(0.112)
|
||||
},
|
||||
KlingOmniProVideoToVideoNode: {
|
||||
displayPrice: makeOmniProDurationCalculator(0.168)
|
||||
},
|
||||
KlingOmniProEditVideoNode: {
|
||||
displayPrice: '$0.168/second'
|
||||
},
|
||||
LumaImageToVideoNode: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
// Same pricing as LumaVideoNode per CSV
|
||||
@@ -1873,6 +1910,10 @@ export const useNodePricing = () => {
|
||||
KlingDualCharacterVideoEffectNode: ['mode', 'model_name', 'duration'],
|
||||
KlingSingleImageVideoEffectNode: ['effect_scene'],
|
||||
KlingStartEndFrameNode: ['mode', 'model_name', 'duration'],
|
||||
KlingOmniProTextToVideoNode: ['duration'],
|
||||
KlingOmniProFirstLastFrameNode: ['duration'],
|
||||
KlingOmniProImageToVideoNode: ['duration'],
|
||||
KlingOmniProVideoToVideoNode: ['duration'],
|
||||
MinimaxHailuoVideoNode: ['resolution', 'duration'],
|
||||
OpenAIDalle3: ['size', 'quality'],
|
||||
OpenAIDalle2: ['size', 'n'],
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
|
||||
import { st } from '@/i18n'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
@@ -263,7 +264,8 @@ export function useJobList() {
|
||||
totalPercent: isActive ? totalPercent.value : undefined,
|
||||
currentNodePercent: isActive ? currentNodePercent.value : undefined,
|
||||
currentNodeName: isActive ? currentNodeName.value : undefined,
|
||||
showAddedHint
|
||||
showAddedHint,
|
||||
isCloud
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
@@ -9,7 +9,8 @@ export enum ServerFeatureFlag {
|
||||
SUPPORTS_PREVIEW_METADATA = 'supports_preview_metadata',
|
||||
MAX_UPLOAD_SIZE = 'max_upload_size',
|
||||
MANAGER_SUPPORTS_V4 = 'extension.manager.supports_v4',
|
||||
MODEL_UPLOAD_BUTTON_ENABLED = 'model_upload_button_enabled'
|
||||
MODEL_UPLOAD_BUTTON_ENABLED = 'model_upload_button_enabled',
|
||||
ASSET_UPDATE_OPTIONS_ENABLED = 'asset_update_options_enabled'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -31,6 +32,12 @@ export function useFeatureFlags() {
|
||||
ServerFeatureFlag.MODEL_UPLOAD_BUTTON_ENABLED,
|
||||
false
|
||||
)
|
||||
},
|
||||
get assetUpdateOptionsEnabled() {
|
||||
return api.getServerFeature(
|
||||
ServerFeatureFlag.ASSET_UPDATE_OPTIONS_ENABLED,
|
||||
false
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ useExtensionService().registerExtension({
|
||||
name: 'Comfy.Cloud.RemoteConfig',
|
||||
|
||||
setup: async () => {
|
||||
// Poll for config updates every 30 seconds
|
||||
setInterval(() => void loadRemoteConfig(), 30000)
|
||||
// Poll for config updates every 10 minutes
|
||||
setInterval(() => void loadRemoteConfig(), 600_000)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -7,9 +7,9 @@ import type {
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot,
|
||||
ISlotType,
|
||||
LLink,
|
||||
Point
|
||||
LLink
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { NodeSlot } from '@/lib/litegraph/src/node/NodeSlot'
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { InputSpec } from '@/schemas/nodeDefSchema'
|
||||
@@ -37,15 +37,15 @@ export class PrimitiveNode extends LGraphNode {
|
||||
}
|
||||
|
||||
override applyToGraph(extraLinks: LLink[] = []) {
|
||||
if (!this.outputs[0].links?.length) return
|
||||
if (!this.outputs[0].links?.length || !this.graph) return
|
||||
|
||||
const links = [
|
||||
...this.outputs[0].links.map((l) => app.graph.links[l]),
|
||||
...this.outputs[0].links.map((l) => this.graph!.links[l]),
|
||||
...extraLinks
|
||||
]
|
||||
let v = this.widgets?.[0].value
|
||||
if (v && this.properties[replacePropertyName]) {
|
||||
v = applyTextReplacements(app.graph, v as string)
|
||||
v = applyTextReplacements(this.graph, v as string)
|
||||
}
|
||||
|
||||
// For each output link copy our value over the original widget value
|
||||
@@ -331,13 +331,13 @@ export class PrimitiveNode extends LGraphNode {
|
||||
const config1 = (output.widget?.[GET_CONFIG] as () => InputSpec)?.()
|
||||
if (!config1) return
|
||||
const isNumber = config1[0] === 'INT' || config1[0] === 'FLOAT'
|
||||
if (!isNumber) return
|
||||
if (!isNumber || !this.graph) return
|
||||
|
||||
for (const linkId of links) {
|
||||
const link = app.graph.links[linkId]
|
||||
const link = this.graph.links[linkId]
|
||||
if (!link) continue // Can be null when removing a node
|
||||
|
||||
const theirNode = app.graph.getNodeById(link.target_id)
|
||||
const theirNode = this.graph.getNodeById(link.target_id)
|
||||
if (!theirNode) continue
|
||||
const theirInput = theirNode.inputs[link.target_slot]
|
||||
|
||||
@@ -441,10 +441,7 @@ function getWidgetType(config: InputSpec) {
|
||||
return { type }
|
||||
}
|
||||
|
||||
export function setWidgetConfig(
|
||||
slot: INodeInputSlot | INodeOutputSlot,
|
||||
config?: InputSpec
|
||||
) {
|
||||
export function setWidgetConfig(slot: INodeInputSlot, config?: InputSpec) {
|
||||
if (!slot.widget) return
|
||||
if (config) {
|
||||
slot.widget[GET_CONFIG] = () => config
|
||||
@@ -452,19 +449,18 @@ export function setWidgetConfig(
|
||||
delete slot.widget
|
||||
}
|
||||
|
||||
if ('link' in slot) {
|
||||
const link = app.graph.links[slot.link ?? -1]
|
||||
if (link) {
|
||||
const originNode = app.graph.getNodeById(link.origin_id)
|
||||
if (originNode && isPrimitiveNode(originNode)) {
|
||||
if (config) {
|
||||
originNode.recreateWidget()
|
||||
} else if (!app.configuringGraph) {
|
||||
originNode.disconnectOutput(0)
|
||||
originNode.onLastDisconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!(slot instanceof NodeSlot)) return
|
||||
const graph = slot.node.graph
|
||||
if (!graph) return
|
||||
const link = graph.links[slot.link ?? -1]
|
||||
if (!link) return
|
||||
const originNode = graph.getNodeById(link.origin_id)
|
||||
if (!originNode || !isPrimitiveNode(originNode)) return
|
||||
if (config) {
|
||||
originNode.recreateWidget()
|
||||
} else if (!app.configuringGraph) {
|
||||
originNode.disconnectOutput(0)
|
||||
originNode.onLastDisconnect()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -555,15 +551,6 @@ app.registerExtension({
|
||||
}
|
||||
)
|
||||
|
||||
function isNodeAtPos(pos: Point) {
|
||||
for (const n of app.graph.nodes) {
|
||||
if (n.pos[0] === pos[0] && n.pos[1] === pos[1]) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Double click a widget input to automatically attach a primitive
|
||||
const origOnInputDblClick = nodeType.prototype.onInputDblClick
|
||||
nodeType.prototype.onInputDblClick = function (
|
||||
@@ -589,18 +576,18 @@ app.registerExtension({
|
||||
|
||||
// Create a primitive node
|
||||
const node = LiteGraph.createNode('PrimitiveNode')
|
||||
if (!node) return r
|
||||
const graph = app.canvas.graph
|
||||
if (!node || !graph) return r
|
||||
|
||||
this.graph?.add(node)
|
||||
graph?.add(node)
|
||||
|
||||
// Calculate a position that won't directly overlap another node
|
||||
const pos: [number, number] = [
|
||||
this.pos[0] - node.size[0] - 30,
|
||||
this.pos[1]
|
||||
]
|
||||
while (isNodeAtPos(pos)) {
|
||||
while (graph.getNodeOnPos(pos[0], pos[1], graph.nodes))
|
||||
pos[1] += LiteGraph.NODE_TITLE_HEIGHT
|
||||
}
|
||||
|
||||
node.pos = pos
|
||||
node.connect(0, this, slot)
|
||||
|
||||
@@ -159,6 +159,7 @@ export const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: navigator.language.split('-')[0] || 'en',
|
||||
fallbackLocale: 'en',
|
||||
escapeParameter: true,
|
||||
messages,
|
||||
// Ignore warnings for locale options as each option is in its own language.
|
||||
// e.g. "English", "中文", "Русский", "日本語", "한국어", "Français", "Español"
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getSlotPosition } from '@/renderer/core/canvas/litegraph/slotCalculatio
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import { removeNodeTitleHeight } from '@/renderer/core/layout/utils/nodeSizeUtil'
|
||||
|
||||
import { CanvasPointer } from './CanvasPointer'
|
||||
import type { ContextMenu } from './ContextMenu'
|
||||
@@ -4043,16 +4044,25 @@ export class LGraphCanvas
|
||||
|
||||
// TODO: Report failures, i.e. `failedNodes`
|
||||
|
||||
const newPositions = created.map((node) => ({
|
||||
nodeId: String(node.id),
|
||||
bounds: {
|
||||
x: node.pos[0],
|
||||
y: node.pos[1],
|
||||
width: node.size?.[0] ?? 100,
|
||||
height: node.size?.[1] ?? 200
|
||||
}
|
||||
}))
|
||||
const newPositions = created
|
||||
.filter((item): item is LGraphNode => item instanceof LGraphNode)
|
||||
.map((node) => {
|
||||
const fullHeight = node.size?.[1] ?? 200
|
||||
const layoutHeight = LiteGraph.vueNodesMode
|
||||
? removeNodeTitleHeight(fullHeight)
|
||||
: fullHeight
|
||||
return {
|
||||
nodeId: String(node.id),
|
||||
bounds: {
|
||||
x: node.pos[0],
|
||||
y: node.pos[1],
|
||||
width: node.size?.[0] ?? 100,
|
||||
height: layoutHeight
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (newPositions.length) layoutStore.setSource(LayoutSource.Canvas)
|
||||
layoutStore.batchUpdateNodeBounds(newPositions)
|
||||
|
||||
this.selectItems(created)
|
||||
|
||||
@@ -124,6 +124,7 @@
|
||||
"searchKeybindings": "Search Keybindings",
|
||||
"searchExtensions": "Search Extensions",
|
||||
"search": "Search",
|
||||
"searchPlaceholder": "Search...",
|
||||
"noResultsFound": "No Results Found",
|
||||
"searchFailedMessage": "We couldn't find any settings matching your search. Try adjusting your search terms.",
|
||||
"noTasksFound": "No Tasks Found",
|
||||
@@ -984,6 +985,7 @@
|
||||
"initializingAlmostReady": "Initializing - Almost ready",
|
||||
"inQueue": "In queue...",
|
||||
"jobAddedToQueue": "Job added to queue",
|
||||
"completedIn": "Finished in {duration}",
|
||||
"jobMenu": {
|
||||
"openAsWorkflowNewTab": "Open as workflow in new tab",
|
||||
"openWorkflowNewTab": "Open workflow in new tab",
|
||||
@@ -2086,7 +2088,6 @@
|
||||
"connectionError": "Please check your connection and try again",
|
||||
"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",
|
||||
"uploadModelFailedToRetrieveMetadata": "Failed to retrieve metadata. Please check the link and try again.",
|
||||
@@ -2145,6 +2146,16 @@
|
||||
"media": {
|
||||
"threeDModelPlaceholder": "3D Model",
|
||||
"audioPlaceholder": "Audio"
|
||||
},
|
||||
"deletion": {
|
||||
"header": "Delete this model?",
|
||||
"body": "This model will be permanently removed from your library.",
|
||||
"inProgress": "Deleting {assetName}...",
|
||||
"complete": "{assetName} has been deleted.",
|
||||
"failed": "{assetName} could not be deleted."
|
||||
},
|
||||
"rename": {
|
||||
"failed": "Could not rename asset."
|
||||
}
|
||||
},
|
||||
"mediaAsset": {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="absolute right-2 bottom-2 flex flex-wrap justify-end gap-1">
|
||||
<div class="absolute left-2 bottom-2 flex flex-wrap justify-start gap-1">
|
||||
<span
|
||||
v-for="badge in badges"
|
||||
:key="badge.label"
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
v-model="searchQuery"
|
||||
:autofocus="true"
|
||||
size="lg"
|
||||
:placeholder="$t('assetBrowser.searchAssetsPlaceholder')"
|
||||
:placeholder="$t('g.searchPlaceholder')"
|
||||
class="max-w-96"
|
||||
/>
|
||||
<IconTextButton
|
||||
|
||||
@@ -1,53 +1,106 @@
|
||||
<template>
|
||||
<component
|
||||
:is="interactive ? 'button' : 'div'"
|
||||
<div
|
||||
v-if="!deletedLocal"
|
||||
data-component-id="AssetCard"
|
||||
:data-asset-id="asset.id"
|
||||
v-bind="elementProps"
|
||||
:class="cardClasses"
|
||||
@click="interactive && $emit('select', asset)"
|
||||
@keydown.enter="interactive && $emit('select', asset)"
|
||||
:aria-labelledby="titleId"
|
||||
:aria-describedby="descId"
|
||||
:tabindex="interactive ? 0 : -1"
|
||||
:class="
|
||||
cn(
|
||||
'rounded-2xl overflow-hidden transition-all duration-200 bg-modal-card-background p-2 gap-2 flex flex-col h-full',
|
||||
interactive &&
|
||||
'group appearance-none bg-transparent m-0 outline-none text-left hover:bg-secondary-background focus:bg-secondary-background border-none focus:outline-solid outline-base-foreground outline-4'
|
||||
)
|
||||
"
|
||||
@keydown.enter.self="interactive && $emit('select', asset)"
|
||||
>
|
||||
<div class="relative aspect-square w-full overflow-hidden rounded-xl">
|
||||
<img
|
||||
v-if="shouldShowImage"
|
||||
:src="asset.preview_url"
|
||||
class="h-full w-full object-contain"
|
||||
/>
|
||||
<div
|
||||
v-if="isLoading || error"
|
||||
class="flex size-full cursor-pointer items-center justify-center bg-gradient-to-br from-smoke-400 via-smoke-800 to-charcoal-400"
|
||||
role="button"
|
||||
@click.self="interactive && $emit('select', asset)"
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
class="flex h-full w-full items-center justify-center bg-gradient-to-br from-smoke-400 via-smoke-800 to-charcoal-400"
|
||||
></div>
|
||||
:src="asset.preview_url"
|
||||
:alt="displayName"
|
||||
class="size-full object-contain cursor-pointer"
|
||||
role="button"
|
||||
@click.self="interactive && $emit('select', asset)"
|
||||
/>
|
||||
|
||||
<AssetBadgeGroup :badges="asset.badges" />
|
||||
<IconGroup
|
||||
v-if="flags.assetUpdateOptionsEnabled"
|
||||
:class="
|
||||
cn(
|
||||
'absolute top-2 right-2 invisible group-hover:visible',
|
||||
dropdownMenuButton?.isOpen && 'visible'
|
||||
)
|
||||
"
|
||||
>
|
||||
<IconButton v-if="false" size="sm">
|
||||
<i class="icon-[lucide--file-text]" />
|
||||
</IconButton>
|
||||
<MoreButton ref="dropdown-menu-button" size="sm">
|
||||
<template #default>
|
||||
<IconTextButton
|
||||
:label="$t('g.rename')"
|
||||
type="secondary"
|
||||
size="md"
|
||||
@click="startAssetRename"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--pencil]" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
<IconTextButton
|
||||
:label="$t('g.delete')"
|
||||
type="secondary"
|
||||
size="md"
|
||||
@click="confirmDeletion"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--trash-2]" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
</template>
|
||||
</MoreButton>
|
||||
</IconGroup>
|
||||
</div>
|
||||
<div :class="cn('p-4 h-32 flex flex-col justify-between')">
|
||||
<div>
|
||||
<h3
|
||||
:id="titleId"
|
||||
v-tooltip.top="{ value: asset.name, showDelay: tooltipDelay }"
|
||||
:class="
|
||||
cn(
|
||||
'mb-2 m-0 text-base font-semibold line-clamp-2 wrap-anywhere',
|
||||
'text-base-foreground'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ asset.name }}
|
||||
</h3>
|
||||
<p
|
||||
:id="descId"
|
||||
v-tooltip.top="{ value: asset.description, showDelay: tooltipDelay }"
|
||||
:class="
|
||||
cn(
|
||||
'm-0 text-sm leading-6 overflow-hidden [-webkit-box-orient:vertical] [-webkit-line-clamp:2] [display:-webkit-box]',
|
||||
'text-muted-foreground'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ asset.description }}
|
||||
</p>
|
||||
</div>
|
||||
<div :class="cn('flex gap-4 text-xs text-muted-foreground')">
|
||||
<div class="max-h-32 flex flex-col gap-2 justify-between flex-auto">
|
||||
<h3
|
||||
:id="titleId"
|
||||
v-tooltip.top="{ value: displayName, showDelay: tooltipDelay }"
|
||||
:class="
|
||||
cn(
|
||||
'mb-2 m-0 text-base font-semibold line-clamp-2 wrap-anywhere',
|
||||
'text-base-foreground'
|
||||
)
|
||||
"
|
||||
>
|
||||
<EditableText
|
||||
:model-value="displayName"
|
||||
:is-editing="isEditing"
|
||||
:input-attrs="{ 'data-testid': 'asset-name-input' }"
|
||||
@edit="assetRename"
|
||||
@cancel="assetRename()"
|
||||
/>
|
||||
</h3>
|
||||
<p
|
||||
:id="descId"
|
||||
v-tooltip.top="{ value: asset.description, showDelay: tooltipDelay }"
|
||||
:class="
|
||||
cn(
|
||||
'm-0 text-sm leading-6 overflow-hidden [-webkit-box-orient:vertical] [-webkit-line-clamp:2] [display:-webkit-box] text-muted-foreground'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ asset.description }}
|
||||
</p>
|
||||
<div class="flex gap-4 text-xs text-muted-foreground mt-auto">
|
||||
<span v-if="asset.stats.stars" class="flex items-center gap-1">
|
||||
<i class="icon-[lucide--star] size-3" />
|
||||
{{ asset.stats.stars }}
|
||||
@@ -62,73 +115,141 @@
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</component>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useImage } from '@vueuse/core'
|
||||
import { computed, useId } from 'vue'
|
||||
import { computed, ref, toValue, useId, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import IconButton from '@/components/button/IconButton.vue'
|
||||
import IconGroup from '@/components/button/IconGroup.vue'
|
||||
import IconTextButton from '@/components/button/IconTextButton.vue'
|
||||
import MoreButton from '@/components/button/MoreButton.vue'
|
||||
import EditableText from '@/components/common/EditableText.vue'
|
||||
import { showConfirmDialog } from '@/components/dialog/confirm/confirmDialog'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import AssetBadgeGroup from '@/platform/assets/components/AssetBadgeGroup.vue'
|
||||
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const props = defineProps<{
|
||||
const { asset, interactive } = defineProps<{
|
||||
asset: AssetDisplayItem
|
||||
interactive?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
select: [asset: AssetDisplayItem]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const settingStore = useSettingStore()
|
||||
const { closeDialog } = useDialogStore()
|
||||
const { flags } = useFeatureFlags()
|
||||
const toastStore = useToastStore()
|
||||
|
||||
const dropdownMenuButton = useTemplateRef<InstanceType<typeof MoreButton>>(
|
||||
'dropdown-menu-button'
|
||||
)
|
||||
|
||||
const titleId = useId()
|
||||
const descId = useId()
|
||||
|
||||
const isEditing = ref(false)
|
||||
const newNameRef = ref<string>()
|
||||
const deletedLocal = ref(false)
|
||||
|
||||
const displayName = computed(() => newNameRef.value ?? asset.name)
|
||||
|
||||
const tooltipDelay = computed<number>(() =>
|
||||
settingStore.get('LiteGraph.Node.TooltipDelay')
|
||||
)
|
||||
|
||||
const { error } = useImage({
|
||||
src: props.asset.preview_url ?? '',
|
||||
alt: props.asset.name
|
||||
const { isLoading, error } = useImage({
|
||||
src: asset.preview_url ?? '',
|
||||
alt: asset.name
|
||||
})
|
||||
|
||||
const shouldShowImage = computed(() => props.asset.preview_url && !error.value)
|
||||
function confirmDeletion() {
|
||||
dropdownMenuButton.value?.hide()
|
||||
const assetName = toValue(displayName)
|
||||
const promptText = ref<string>(t('assetBrowser.deletion.body'))
|
||||
const optionsDisabled = ref(false)
|
||||
const confirmDialog = showConfirmDialog({
|
||||
headerProps: {
|
||||
title: t('assetBrowser.deletion.header')
|
||||
},
|
||||
props: {
|
||||
promptText
|
||||
},
|
||||
footerProps: {
|
||||
confirmText: t('g.delete'),
|
||||
// TODO: These need to be put into the new Button Variants once we have them.
|
||||
confirmClass: cn(
|
||||
'bg-danger-200 text-base-foreground hover:bg-danger-200/80 focus:bg-danger-200/80 focus:ring ring-base-foreground'
|
||||
),
|
||||
optionsDisabled,
|
||||
onCancel: () => {
|
||||
closeDialog(confirmDialog)
|
||||
},
|
||||
onConfirm: async () => {
|
||||
optionsDisabled.value = true
|
||||
try {
|
||||
promptText.value = t('assetBrowser.deletion.inProgress', {
|
||||
assetName
|
||||
})
|
||||
await assetService.deleteAsset(asset.id)
|
||||
promptText.value = t('assetBrowser.deletion.complete', {
|
||||
assetName
|
||||
})
|
||||
// Give a second for the completion message
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_000))
|
||||
deletedLocal.value = true
|
||||
} catch (err: unknown) {
|
||||
console.error(err)
|
||||
promptText.value = t('assetBrowser.deletion.failed', {
|
||||
assetName
|
||||
})
|
||||
// Give a second for the completion message
|
||||
await new Promise((resolve) => setTimeout(resolve, 3_000))
|
||||
} finally {
|
||||
closeDialog(confirmDialog)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const cardClasses = computed(() => {
|
||||
const base = cn(
|
||||
'rounded-xl overflow-hidden transition-all duration-200 bg-modal-card-background'
|
||||
)
|
||||
function startAssetRename() {
|
||||
dropdownMenuButton.value?.hide()
|
||||
isEditing.value = true
|
||||
}
|
||||
|
||||
if (!props.interactive) {
|
||||
return base
|
||||
async function assetRename(newName?: string) {
|
||||
isEditing.value = false
|
||||
if (newName) {
|
||||
// Optimistic update
|
||||
newNameRef.value = newName
|
||||
try {
|
||||
const result = await assetService.updateAsset(asset.id, {
|
||||
name: newName
|
||||
})
|
||||
// Update with the actual name once the server responds
|
||||
newNameRef.value = result.name
|
||||
} catch (err: unknown) {
|
||||
console.error(err)
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('assetBrowser.rename.failed'),
|
||||
life: 10_000
|
||||
})
|
||||
newNameRef.value = undefined
|
||||
}
|
||||
}
|
||||
|
||||
return cn(
|
||||
base,
|
||||
'group',
|
||||
'appearance-none bg-transparent p-0 m-0',
|
||||
'font-inherit text-inherit outline-none cursor-pointer text-left',
|
||||
'hover:bg-secondary-background',
|
||||
'border-none',
|
||||
'focus:outline-solid outline-azure-600 outline-4'
|
||||
)
|
||||
})
|
||||
|
||||
const elementProps = computed(() =>
|
||||
props.interactive
|
||||
? {
|
||||
type: 'button',
|
||||
'aria-labelledby': titleId,
|
||||
'aria-describedby': descId
|
||||
}
|
||||
: {
|
||||
'aria-labelledby': titleId,
|
||||
'aria-describedby': descId
|
||||
}
|
||||
)
|
||||
|
||||
defineEmits<{
|
||||
select: [asset: AssetDisplayItem]
|
||||
}>()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<template>
|
||||
<div
|
||||
data-component-id="AssetGrid"
|
||||
:style="gridStyle"
|
||||
:class="
|
||||
cn('grid grid-cols-[repeat(auto-fill,minmax(15rem,1fr))] gap-4 p-2')
|
||||
"
|
||||
role="grid"
|
||||
:aria-label="$t('assetBrowser.assetCollection')"
|
||||
:aria-rowcount="-1"
|
||||
@@ -34,7 +36,6 @@
|
||||
:key="asset.id"
|
||||
:asset="asset"
|
||||
:interactive="true"
|
||||
role="gridcell"
|
||||
@select="$emit('assetSelect', $event)"
|
||||
/>
|
||||
</template>
|
||||
@@ -42,11 +43,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import AssetCard from '@/platform/assets/components/AssetCard.vue'
|
||||
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
|
||||
import { createGridStyle } from '@/utils/gridUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
defineProps<{
|
||||
assets: AssetDisplayItem[]
|
||||
@@ -56,7 +55,4 @@ defineProps<{
|
||||
defineEmits<{
|
||||
assetSelect: [asset: AssetDisplayItem]
|
||||
}>()
|
||||
|
||||
// Use same grid style as BaseModalLayout
|
||||
const gridStyle = computed(() => createGridStyle())
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
class="upload-model-dialog flex flex-col justify-between gap-6 p-4 pt-6 border-t-[1px] border-border-default"
|
||||
class="upload-model-dialog flex flex-col justify-between gap-6 p-4 pt-6 border-t border-border-default"
|
||||
>
|
||||
<!-- Step 1: Enter URL -->
|
||||
<UploadModelUrlInput
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { fromZodError } from 'zod-validation-error'
|
||||
|
||||
import { st } from '@/i18n'
|
||||
import { assetResponseSchema } from '@/platform/assets/schemas/assetSchema'
|
||||
import {
|
||||
assetItemSchema,
|
||||
assetResponseSchema
|
||||
} from '@/platform/assets/schemas/assetSchema'
|
||||
import type {
|
||||
AssetItem,
|
||||
AssetMetadata,
|
||||
@@ -281,6 +284,43 @@ function createAssetService() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update metadata of an asset by ID
|
||||
* Only available in cloud environment
|
||||
*
|
||||
* @param id - The asset ID (UUID)
|
||||
* @param newData - The data to update
|
||||
* @returns Promise<AssetItem>
|
||||
* @throws Error if update fails
|
||||
*/
|
||||
async function updateAsset(
|
||||
id: string,
|
||||
newData: Partial<AssetMetadata>
|
||||
): Promise<AssetItem> {
|
||||
const res = await api.fetchApi(`${ASSETS_ENDPOINT}/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(newData)
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`Unable to update asset ${id}: Server returned ${res.status}`
|
||||
)
|
||||
}
|
||||
|
||||
const newAsset = assetItemSchema.safeParse(await res.json())
|
||||
if (newAsset.success) {
|
||||
return newAsset.data
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Unable to update asset ${id}: Invalid response - ${newAsset.error}`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves metadata from a download URL without downloading the file
|
||||
*
|
||||
@@ -360,6 +400,7 @@ function createAssetService() {
|
||||
getAssetDetails,
|
||||
getAssetsByTag,
|
||||
deleteAsset,
|
||||
updateAsset,
|
||||
getAssetMetadata,
|
||||
uploadAssetFromUrl
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { clearPreservedQuery } from '@/platform/navigation/preservedQueryManager'
|
||||
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
|
||||
import { useTemplateWorkflows } from './useTemplateWorkflows'
|
||||
|
||||
@@ -13,9 +14,10 @@ import { useTemplateWorkflows } from './useTemplateWorkflows'
|
||||
* Supports URLs like:
|
||||
* - /?template=flux_simple (loads with default source)
|
||||
* - /?template=flux_simple&source=custom (loads from custom source)
|
||||
* - /?template=flux_simple&mode=linear (loads template in linear mode)
|
||||
*
|
||||
* Input validation:
|
||||
* - Template and source parameters must match: ^[a-zA-Z0-9_-]+$
|
||||
* - Template, source, and mode parameters must match: ^[a-zA-Z0-9_-]+$
|
||||
* - Invalid formats are rejected with console warnings
|
||||
*/
|
||||
export function useTemplateUrlLoader() {
|
||||
@@ -24,7 +26,10 @@ export function useTemplateUrlLoader() {
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const templateWorkflows = useTemplateWorkflows()
|
||||
const canvasStore = useCanvasStore()
|
||||
const TEMPLATE_NAMESPACE = PRESERVED_QUERY_NAMESPACES.TEMPLATE
|
||||
const SUPPORTED_MODES = ['linear'] as const
|
||||
type SupportedMode = (typeof SUPPORTED_MODES)[number]
|
||||
|
||||
/**
|
||||
* Validates parameter format to prevent path traversal and injection attacks
|
||||
@@ -34,12 +39,20 @@ export function useTemplateUrlLoader() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes template and source parameters from URL
|
||||
* Type guard to check if a value is a supported mode
|
||||
*/
|
||||
const isSupportedMode = (mode: string): mode is SupportedMode => {
|
||||
return SUPPORTED_MODES.includes(mode as SupportedMode)
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes template, source, and mode parameters from URL
|
||||
*/
|
||||
const cleanupUrlParams = () => {
|
||||
const newQuery = { ...route.query }
|
||||
delete newQuery.template
|
||||
delete newQuery.source
|
||||
delete newQuery.mode
|
||||
void router.replace({ query: newQuery })
|
||||
}
|
||||
|
||||
@@ -70,6 +83,24 @@ export function useTemplateUrlLoader() {
|
||||
return
|
||||
}
|
||||
|
||||
const modeParam = route.query.mode as string | undefined
|
||||
|
||||
if (
|
||||
modeParam &&
|
||||
(typeof modeParam !== 'string' || !isValidParameter(modeParam))
|
||||
) {
|
||||
console.warn(
|
||||
`[useTemplateUrlLoader] Invalid mode parameter format: ${modeParam}`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (modeParam && !isSupportedMode(modeParam)) {
|
||||
console.warn(
|
||||
`[useTemplateUrlLoader] Unsupported mode parameter: ${modeParam}. Supported modes: ${SUPPORTED_MODES.join(', ')}`
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
await templateWorkflows.loadTemplates()
|
||||
|
||||
@@ -87,6 +118,9 @@ export function useTemplateUrlLoader() {
|
||||
}),
|
||||
life: 3000
|
||||
})
|
||||
} else if (modeParam === 'linear') {
|
||||
// Set linear mode after successful template load
|
||||
canvasStore.linearMode = true
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
|
||||
@@ -9,6 +9,8 @@ import { computed, customRef, ref } from 'vue'
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
import * as Y from 'yjs'
|
||||
|
||||
import { removeNodeTitleHeight } from '@/renderer/core/layout/utils/nodeSizeUtil'
|
||||
|
||||
import { ACTOR_CONFIG } from '@/renderer/core/layout/constants'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import type {
|
||||
@@ -136,6 +138,8 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
|
||||
// Vue dragging state for selection toolbox (public ref for direct mutation)
|
||||
public isDraggingVueNodes = ref(false)
|
||||
// Vue resizing state to prevent drag from activating during resize
|
||||
public isResizingVueNodes = ref(false)
|
||||
|
||||
constructor() {
|
||||
// Initialize Yjs data structures
|
||||
@@ -1414,8 +1418,8 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
batchUpdateNodeBounds(updates: NodeBoundsUpdate[]): void {
|
||||
if (updates.length === 0) return
|
||||
|
||||
// Set source to Vue for these DOM-driven updates
|
||||
const originalSource = this.currentSource
|
||||
const shouldNormalizeHeights = originalSource === LayoutSource.DOM
|
||||
this.currentSource = LayoutSource.Vue
|
||||
|
||||
const nodeIds: NodeId[] = []
|
||||
@@ -1426,8 +1430,15 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
if (!ynode) continue
|
||||
const currentLayout = yNodeToLayout(ynode)
|
||||
|
||||
const normalizedBounds = shouldNormalizeHeights
|
||||
? {
|
||||
...bounds,
|
||||
height: removeNodeTitleHeight(bounds.height)
|
||||
}
|
||||
: bounds
|
||||
|
||||
boundsRecord[nodeId] = {
|
||||
bounds,
|
||||
bounds: normalizedBounds,
|
||||
previousBounds: currentLayout.bounds
|
||||
}
|
||||
nodeIds.push(nodeId)
|
||||
|
||||
@@ -8,6 +8,7 @@ import { onUnmounted, ref } from 'vue'
|
||||
|
||||
import type { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { addNodeTitleHeight } from '@/renderer/core/layout/utils/nodeSizeUtil'
|
||||
|
||||
/**
|
||||
* Composable for syncing LiteGraph with the Layout system
|
||||
@@ -43,12 +44,13 @@ export function useLayoutSync() {
|
||||
liteNode.pos[1] = layout.position.y
|
||||
}
|
||||
|
||||
const targetHeight = addNodeTitleHeight(layout.size.height)
|
||||
if (
|
||||
liteNode.size[0] !== layout.size.width ||
|
||||
liteNode.size[1] !== layout.size.height
|
||||
liteNode.size[1] !== targetHeight
|
||||
) {
|
||||
// Use setSize() to trigger onResize callback
|
||||
liteNode.setSize([layout.size.width, layout.size.height])
|
||||
liteNode.setSize([layout.size.width, targetHeight])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { ComputedRef, Ref } from 'vue'
|
||||
export enum LayoutSource {
|
||||
Canvas = 'canvas',
|
||||
Vue = 'vue',
|
||||
DOM = 'dom',
|
||||
External = 'external'
|
||||
}
|
||||
|
||||
|
||||
7
src/renderer/core/layout/utils/nodeSizeUtil.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
export const removeNodeTitleHeight = (height: number) =>
|
||||
Math.max(0, height - (LiteGraph.NODE_TITLE_HEIGHT || 0))
|
||||
|
||||
export const addNodeTitleHeight = (height: number) =>
|
||||
height + LiteGraph.NODE_TITLE_HEIGHT
|
||||
@@ -117,17 +117,14 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Resize handles -->
|
||||
<template v-if="!isCollapsed">
|
||||
<div
|
||||
v-for="handle in cornerResizeHandles"
|
||||
:key="handle.id"
|
||||
role="button"
|
||||
:aria-label="handle.ariaLabel"
|
||||
:class="cn(baseResizeHandleClasses, handle.classes)"
|
||||
@pointerdown.stop="handleResizePointerDown(handle.direction)($event)"
|
||||
/>
|
||||
</template>
|
||||
<!-- Resize handle (bottom-right only) -->
|
||||
<div
|
||||
v-if="!isCollapsed"
|
||||
role="button"
|
||||
:aria-label="t('g.resizeFromBottomRight')"
|
||||
:class="cn(baseResizeHandleClasses, 'right-0 bottom-0 cursor-se-resize')"
|
||||
@pointerdown.stop="handleResizePointerDown"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -171,7 +168,6 @@ import {
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import type { ResizeHandleDirection } from '../interactions/resize/resizeMath'
|
||||
import { useNodeResize } from '../interactions/resize/useNodeResize'
|
||||
import LivePreview from './LivePreview.vue'
|
||||
import NodeContent from './NodeContent.vue'
|
||||
@@ -263,7 +259,7 @@ onErrorCaptured((error) => {
|
||||
return false // Prevent error propagation
|
||||
})
|
||||
|
||||
const { position, size, zIndex, moveNodeTo } = useNodeLayout(() => nodeData.id)
|
||||
const { position, size, zIndex } = useNodeLayout(() => nodeData.id)
|
||||
const { pointerHandlers } = useNodePointerInteractions(() => nodeData.id)
|
||||
const { onPointerdown, ...remainingPointerHandlers } = pointerHandlers
|
||||
const { startDrag } = useNodeDrag()
|
||||
@@ -314,41 +310,6 @@ onMounted(() => {
|
||||
|
||||
const baseResizeHandleClasses =
|
||||
'absolute h-3 w-3 opacity-0 pointer-events-auto focus-visible:outline focus-visible:outline-2 focus-visible:outline-white/40'
|
||||
const POSITION_EPSILON = 0.01
|
||||
|
||||
type CornerResizeHandle = {
|
||||
id: string
|
||||
direction: ResizeHandleDirection
|
||||
classes: string
|
||||
ariaLabel: string
|
||||
}
|
||||
|
||||
const cornerResizeHandles: CornerResizeHandle[] = [
|
||||
{
|
||||
id: 'se',
|
||||
direction: { horizontal: 'right', vertical: 'bottom' },
|
||||
classes: 'right-0 bottom-0 cursor-se-resize',
|
||||
ariaLabel: t('g.resizeFromBottomRight')
|
||||
},
|
||||
{
|
||||
id: 'ne',
|
||||
direction: { horizontal: 'right', vertical: 'top' },
|
||||
classes: 'right-0 top-0 cursor-ne-resize',
|
||||
ariaLabel: t('g.resizeFromTopRight')
|
||||
},
|
||||
{
|
||||
id: 'sw',
|
||||
direction: { horizontal: 'left', vertical: 'bottom' },
|
||||
classes: 'left-0 bottom-0 cursor-sw-resize',
|
||||
ariaLabel: t('g.resizeFromBottomLeft')
|
||||
},
|
||||
{
|
||||
id: 'nw',
|
||||
direction: { horizontal: 'left', vertical: 'top' },
|
||||
classes: 'left-0 top-0 cursor-nw-resize',
|
||||
ariaLabel: t('g.resizeFromTopLeft')
|
||||
}
|
||||
]
|
||||
|
||||
const MIN_NODE_WIDTH = 225
|
||||
|
||||
@@ -361,22 +322,11 @@ const { startResize } = useNodeResize((result, element) => {
|
||||
// Apply size directly to DOM element - ResizeObserver will pick this up
|
||||
element.style.setProperty('--node-width', `${clampedWidth}px`)
|
||||
element.style.setProperty('--node-height', `${result.size.height}px`)
|
||||
|
||||
const currentPosition = position.value
|
||||
const deltaX = Math.abs(result.position.x - currentPosition.x)
|
||||
const deltaY = Math.abs(result.position.y - currentPosition.y)
|
||||
|
||||
if (deltaX > POSITION_EPSILON || deltaY > POSITION_EPSILON) {
|
||||
moveNodeTo(result.position)
|
||||
}
|
||||
})
|
||||
|
||||
const handleResizePointerDown = (direction: ResizeHandleDirection) => {
|
||||
return (event: PointerEvent) => {
|
||||
if (nodeData.flags?.pinned) return
|
||||
|
||||
startResize(event, direction, { ...position.value })
|
||||
}
|
||||
const handleResizePointerDown = (event: PointerEvent) => {
|
||||
if (nodeData.flags?.pinned) return
|
||||
startResize(event)
|
||||
}
|
||||
|
||||
watch(isCollapsed, (collapsed) => {
|
||||
|
||||
@@ -92,12 +92,14 @@ const mockData = vi.hoisted(() => {
|
||||
|
||||
vi.mock('@/renderer/core/layout/store/layoutStore', () => {
|
||||
const isDraggingVueNodes = ref(false)
|
||||
const isResizingVueNodes = ref(false)
|
||||
const fakeNodeLayoutRef = ref(mockData.fakeNodeLayout)
|
||||
const getNodeLayoutRef = vi.fn(() => fakeNodeLayoutRef)
|
||||
const setSource = vi.fn()
|
||||
return {
|
||||
layoutStore: {
|
||||
isDraggingVueNodes,
|
||||
isResizingVueNodes,
|
||||
getNodeLayoutRef,
|
||||
setSource
|
||||
}
|
||||
|
||||
@@ -63,6 +63,9 @@ export function useNodePointerInteractions(
|
||||
function onPointermove(event: PointerEvent) {
|
||||
if (forwardMiddlePointerIfNeeded(event)) return
|
||||
|
||||
// Don't activate drag while resizing
|
||||
if (layoutStore.isResizingVueNodes.value) return
|
||||
|
||||
const nodeId = toValue(nodeIdRef)
|
||||
|
||||
if (nodeManager.value?.getNode(nodeId)?.flags?.pinned) {
|
||||
|
||||
@@ -107,7 +107,7 @@ const resizeObserver = new ResizeObserver((entries) => {
|
||||
x: topLeftCanvas.x,
|
||||
y: topLeftCanvas.y + LiteGraph.NODE_TITLE_HEIGHT,
|
||||
width: Math.max(0, width),
|
||||
height: Math.max(0, height - LiteGraph.NODE_TITLE_HEIGHT)
|
||||
height: Math.max(0, height)
|
||||
}
|
||||
|
||||
let updates = updatesByType.get(elementType)
|
||||
@@ -123,8 +123,7 @@ const resizeObserver = new ResizeObserver((entries) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Set source to Vue before processing DOM-driven updates
|
||||
layoutStore.setSource(LayoutSource.Vue)
|
||||
layoutStore.setSource(LayoutSource.DOM)
|
||||
|
||||
// Flush per-type
|
||||
for (const [type, updates] of updatesByType) {
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
import type { Point, Size } from '@/renderer/core/layout/types'
|
||||
|
||||
export type ResizeHandleDirection = {
|
||||
horizontal: 'left' | 'right'
|
||||
vertical: 'top' | 'bottom'
|
||||
}
|
||||
|
||||
function applyHandleDelta(
|
||||
startSize: Size,
|
||||
delta: Point,
|
||||
handle: ResizeHandleDirection
|
||||
): Size {
|
||||
const horizontalMultiplier = handle.horizontal === 'right' ? 1 : -1
|
||||
const verticalMultiplier = handle.vertical === 'bottom' ? 1 : -1
|
||||
|
||||
return {
|
||||
width: startSize.width + delta.x * horizontalMultiplier,
|
||||
height: startSize.height + delta.y * verticalMultiplier
|
||||
}
|
||||
}
|
||||
|
||||
function computeAdjustedPosition(
|
||||
startPosition: Point,
|
||||
startSize: Size,
|
||||
nextSize: Size,
|
||||
handle: ResizeHandleDirection
|
||||
): Point {
|
||||
const widthDelta = startSize.width - nextSize.width
|
||||
const heightDelta = startSize.height - nextSize.height
|
||||
|
||||
return {
|
||||
x:
|
||||
handle.horizontal === 'left'
|
||||
? startPosition.x + widthDelta
|
||||
: startPosition.x,
|
||||
y:
|
||||
handle.vertical === 'top'
|
||||
? startPosition.y + heightDelta
|
||||
: startPosition.y
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the resulting size and position of a node given pointer movement
|
||||
* and handle orientation.
|
||||
*/
|
||||
export function computeResizeOutcome({
|
||||
startSize,
|
||||
startPosition,
|
||||
delta,
|
||||
handle,
|
||||
snapFn
|
||||
}: {
|
||||
startSize: Size
|
||||
startPosition: Point
|
||||
delta: Point
|
||||
handle: ResizeHandleDirection
|
||||
snapFn?: (size: Size) => Size
|
||||
}): { size: Size; position: Point } {
|
||||
const resized = applyHandleDelta(startSize, delta, handle)
|
||||
const snapped = snapFn?.(resized) ?? resized
|
||||
const position = computeAdjustedPosition(
|
||||
startPosition,
|
||||
startSize,
|
||||
snapped,
|
||||
handle
|
||||
)
|
||||
|
||||
return {
|
||||
size: snapped,
|
||||
position
|
||||
}
|
||||
}
|
||||
|
||||
export function createResizeSession(config: {
|
||||
startSize: Size
|
||||
startPosition: Point
|
||||
handle: ResizeHandleDirection
|
||||
}) {
|
||||
const startSize = { ...config.startSize }
|
||||
const startPosition = { ...config.startPosition }
|
||||
const handle = config.handle
|
||||
|
||||
return (delta: Point, snapFn?: (size: Size) => Size) =>
|
||||
computeResizeOutcome({
|
||||
startSize,
|
||||
startPosition,
|
||||
handle,
|
||||
delta,
|
||||
snapFn
|
||||
})
|
||||
}
|
||||
|
||||
export function toCanvasDelta(
|
||||
startPointer: Point,
|
||||
currentPointer: Point,
|
||||
scale: number
|
||||
): Point {
|
||||
const safeScale = scale === 0 ? 1 : scale
|
||||
return {
|
||||
x: (currentPointer.x - startPointer.x) / safeScale,
|
||||
y: (currentPointer.y - startPointer.y) / safeScale
|
||||
}
|
||||
}
|
||||
@@ -2,20 +2,17 @@ import { useEventListener } from '@vueuse/core'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { Point, Size } from '@/renderer/core/layout/types'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { useNodeSnap } from '@/renderer/extensions/vueNodes/composables/useNodeSnap'
|
||||
import { useShiftKeySync } from '@/renderer/extensions/vueNodes/composables/useShiftKeySync'
|
||||
|
||||
import type { ResizeHandleDirection } from './resizeMath'
|
||||
import { createResizeSession, toCanvasDelta } from './resizeMath'
|
||||
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
|
||||
|
||||
interface ResizeCallbackPayload {
|
||||
size: Size
|
||||
position: Point
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for node resizing functionality
|
||||
* Composable for node resizing functionality (bottom-right corner only)
|
||||
*
|
||||
* Provides resize handle interaction that integrates with the layout system.
|
||||
* Handles pointer capture, coordinate calculations, and size constraints.
|
||||
@@ -27,16 +24,7 @@ export function useNodeResize(
|
||||
|
||||
const isResizing = ref(false)
|
||||
const resizeStartPointer = ref<Point | null>(null)
|
||||
const resizeSession = ref<
|
||||
| ((
|
||||
delta: Point,
|
||||
snapFn?: (size: Size) => Size
|
||||
) => {
|
||||
size: Size
|
||||
position: Point
|
||||
})
|
||||
| null
|
||||
>(null)
|
||||
const resizeStartSize = ref<Size | null>(null)
|
||||
|
||||
// Snap-to-grid functionality
|
||||
const { shouldSnap, applySnapToSize } = useNodeSnap()
|
||||
@@ -44,11 +32,7 @@ export function useNodeResize(
|
||||
// Shift key sync for LiteGraph canvas preview
|
||||
const { trackShiftKey } = useShiftKeySync()
|
||||
|
||||
const startResize = (
|
||||
event: PointerEvent,
|
||||
handle: ResizeHandleDirection,
|
||||
startPosition: Point
|
||||
) => {
|
||||
const startResize = (event: PointerEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
@@ -72,47 +56,49 @@ export function useNodeResize(
|
||||
// Capture pointer to ensure we get all move/up events
|
||||
target.setPointerCapture(event.pointerId)
|
||||
|
||||
// Mark as resizing to prevent drag from activating
|
||||
layoutStore.isResizingVueNodes.value = true
|
||||
isResizing.value = true
|
||||
resizeStartPointer.value = { x: event.clientX, y: event.clientY }
|
||||
resizeSession.value = createResizeSession({
|
||||
startSize,
|
||||
startPosition: { ...startPosition },
|
||||
handle
|
||||
})
|
||||
resizeStartSize.value = startSize
|
||||
|
||||
const handlePointerMove = (moveEvent: PointerEvent) => {
|
||||
if (
|
||||
!isResizing.value ||
|
||||
!resizeStartPointer.value ||
|
||||
!resizeSession.value
|
||||
)
|
||||
!resizeStartSize.value
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const startPointer = resizeStartPointer.value
|
||||
const session = resizeSession.value
|
||||
const scale = transformState.camera.z
|
||||
const deltaX =
|
||||
(moveEvent.clientX - resizeStartPointer.value.x) / (scale || 1)
|
||||
const deltaY =
|
||||
(moveEvent.clientY - resizeStartPointer.value.y) / (scale || 1)
|
||||
|
||||
const delta = toCanvasDelta(
|
||||
startPointer,
|
||||
{ x: moveEvent.clientX, y: moveEvent.clientY },
|
||||
transformState.camera.z
|
||||
)
|
||||
let newSize: Size = {
|
||||
width: resizeStartSize.value.width + deltaX,
|
||||
height: resizeStartSize.value.height + deltaY
|
||||
}
|
||||
|
||||
// Apply snap if shift is held
|
||||
if (shouldSnap(moveEvent)) {
|
||||
newSize = applySnapToSize(newSize)
|
||||
}
|
||||
|
||||
const nodeElement = target.closest('[data-node-id]')
|
||||
if (nodeElement instanceof HTMLElement) {
|
||||
const outcome = session(
|
||||
delta,
|
||||
shouldSnap(moveEvent) ? applySnapToSize : undefined
|
||||
)
|
||||
|
||||
resizeCallback(outcome, nodeElement)
|
||||
resizeCallback({ size: newSize }, nodeElement)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePointerUp = (upEvent: PointerEvent) => {
|
||||
if (isResizing.value) {
|
||||
isResizing.value = false
|
||||
layoutStore.isResizingVueNodes.value = false
|
||||
resizeStartPointer.value = null
|
||||
resizeSession.value = null
|
||||
resizeStartSize.value = null
|
||||
|
||||
// Stop tracking shift key state
|
||||
stopShiftSync()
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<Select
|
||||
v-model="modelValue"
|
||||
:invalid
|
||||
:filter="selectOptions.length > 4"
|
||||
:options="selectOptions"
|
||||
v-bind="combinedProps"
|
||||
:class="cn(WidgetInputBaseClass, 'w-full text-xs')"
|
||||
|
||||
@@ -213,12 +213,13 @@ const acceptTypes = computed(() => {
|
||||
const layoutMode = ref<LayoutMode>(props.defaultLayoutMode ?? 'grid')
|
||||
|
||||
watch(
|
||||
modelValue,
|
||||
(currentValue) => {
|
||||
[modelValue, dropdownItems],
|
||||
([currentValue, _dropdownItems]) => {
|
||||
if (currentValue === undefined) {
|
||||
selectedSet.value.clear()
|
||||
return
|
||||
}
|
||||
|
||||
const item = dropdownItems.value.find((item) => item.name === currentValue)
|
||||
if (item) {
|
||||
selectedSet.value.clear()
|
||||
|
||||
@@ -80,7 +80,7 @@ const router = createRouter({
|
||||
installPreservedQueryTracker(router, [
|
||||
{
|
||||
namespace: PRESERVED_QUERY_NAMESPACES.TEMPLATE,
|
||||
keys: ['template', 'source']
|
||||
keys: ['template', 'source', 'mode']
|
||||
}
|
||||
])
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ import NodeConflictDialogContent from '@/workbench/extensions/manager/components
|
||||
import NodeConflictFooter from '@/workbench/extensions/manager/components/manager/NodeConflictFooter.vue'
|
||||
import NodeConflictHeader from '@/workbench/extensions/manager/components/manager/NodeConflictHeader.vue'
|
||||
import type { ConflictDetectionResult } from '@/workbench/extensions/manager/types/conflictDetectionTypes'
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
import type { ComponentAttrs } from 'vue-component-type-helpers'
|
||||
|
||||
export type ConfirmationDialogType =
|
||||
| 'default'
|
||||
@@ -48,7 +48,7 @@ export const useDialogService = () => {
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
function showLoadWorkflowWarning(
|
||||
props: ComponentProps<typeof MissingNodesContent>
|
||||
props: ComponentAttrs<typeof MissingNodesContent>
|
||||
) {
|
||||
dialogStore.showDialog({
|
||||
key: 'global-missing-nodes',
|
||||
@@ -74,7 +74,7 @@ export const useDialogService = () => {
|
||||
}
|
||||
|
||||
function showMissingModelsWarning(
|
||||
props: InstanceType<typeof MissingModelsWarning>['$props']
|
||||
props: ComponentAttrs<typeof MissingModelsWarning>
|
||||
) {
|
||||
dialogStore.showDialog({
|
||||
key: 'global-missing-models-warning',
|
||||
@@ -115,7 +115,7 @@ export const useDialogService = () => {
|
||||
}
|
||||
|
||||
function showExecutionErrorDialog(executionError: ExecutionErrorWsMessage) {
|
||||
const props: InstanceType<typeof ErrorDialogContent>['$props'] = {
|
||||
const props: ComponentAttrs<typeof ErrorDialogContent> = {
|
||||
error: {
|
||||
exceptionType: executionError.exception_type,
|
||||
exceptionMessage: executionError.exception_message,
|
||||
@@ -141,7 +141,7 @@ export const useDialogService = () => {
|
||||
}
|
||||
|
||||
function showManagerDialog(
|
||||
props: InstanceType<typeof ManagerDialogContent>['$props'] = {}
|
||||
props: ComponentAttrs<typeof ManagerDialogContent> = {}
|
||||
) {
|
||||
dialogStore.showDialog({
|
||||
key: 'global-manager',
|
||||
@@ -206,7 +206,7 @@ export const useDialogService = () => {
|
||||
errorMessage: String(error)
|
||||
}
|
||||
|
||||
const props: InstanceType<typeof ErrorDialogContent>['$props'] = {
|
||||
const props: ComponentAttrs<typeof ErrorDialogContent> = {
|
||||
error: {
|
||||
exceptionType: options.title ?? 'Unknown Error',
|
||||
exceptionMessage: errorProps.errorMessage,
|
||||
@@ -430,7 +430,7 @@ export const useDialogService = () => {
|
||||
}
|
||||
|
||||
function toggleManagerDialog(
|
||||
props?: InstanceType<typeof ManagerDialogContent>['$props']
|
||||
props?: ComponentAttrs<typeof ManagerDialogContent>
|
||||
) {
|
||||
if (dialogStore.isDialogOpen('global-manager')) {
|
||||
dialogStore.closeDialog({ key: 'global-manager' })
|
||||
@@ -440,7 +440,7 @@ export const useDialogService = () => {
|
||||
}
|
||||
|
||||
function toggleManagerProgressDialog(
|
||||
props?: InstanceType<typeof ManagerProgressDialogContent>['$props']
|
||||
props?: ComponentAttrs<typeof ManagerProgressDialogContent>
|
||||
) {
|
||||
if (dialogStore.isDialogOpen('global-manager-progress-dialog')) {
|
||||
dialogStore.closeDialog({ key: 'global-manager-progress-dialog' })
|
||||
|
||||
@@ -7,6 +7,7 @@ import { markRaw, ref } from 'vue'
|
||||
import type { Component } from 'vue'
|
||||
|
||||
import type GlobalDialog from '@/components/dialog/GlobalDialog.vue'
|
||||
import type { ComponentAttrs } from 'vue-component-type-helpers'
|
||||
|
||||
type DialogPosition =
|
||||
| 'center'
|
||||
@@ -33,30 +34,40 @@ interface CustomDialogComponentProps {
|
||||
headless?: boolean
|
||||
}
|
||||
|
||||
export type DialogComponentProps = InstanceType<typeof GlobalDialog>['$props'] &
|
||||
export type DialogComponentProps = ComponentAttrs<typeof GlobalDialog> &
|
||||
CustomDialogComponentProps
|
||||
|
||||
interface DialogInstance {
|
||||
interface DialogInstance<
|
||||
H extends Component = Component,
|
||||
B extends Component = Component,
|
||||
F extends Component = Component
|
||||
> {
|
||||
key: string
|
||||
visible: boolean
|
||||
title?: string
|
||||
headerComponent?: Component
|
||||
component: Component
|
||||
contentProps: Record<string, any>
|
||||
footerComponent?: Component
|
||||
footerProps?: Record<string, any>
|
||||
headerComponent?: H
|
||||
headerProps?: ComponentAttrs<H>
|
||||
component: B
|
||||
contentProps: ComponentAttrs<B>
|
||||
footerComponent?: F
|
||||
footerProps?: ComponentAttrs<F>
|
||||
dialogComponentProps: DialogComponentProps
|
||||
priority: number
|
||||
}
|
||||
|
||||
export interface ShowDialogOptions {
|
||||
export interface ShowDialogOptions<
|
||||
H extends Component = Component,
|
||||
B extends Component = Component,
|
||||
F extends Component = Component
|
||||
> {
|
||||
key?: string
|
||||
title?: string
|
||||
headerComponent?: Component
|
||||
footerComponent?: Component
|
||||
component: Component
|
||||
props?: Record<string, any>
|
||||
footerProps?: Record<string, any>
|
||||
headerComponent?: H
|
||||
footerComponent?: F
|
||||
component: B
|
||||
props?: ComponentAttrs<B>
|
||||
headerProps?: ComponentAttrs<H>
|
||||
footerProps?: ComponentAttrs<F>
|
||||
dialogComponentProps?: DialogComponentProps
|
||||
/**
|
||||
* Optional priority for dialog stacking.
|
||||
@@ -123,17 +134,11 @@ export const useDialogStore = defineStore('dialog', () => {
|
||||
updateCloseOnEscapeStates()
|
||||
}
|
||||
|
||||
function createDialog(options: {
|
||||
key: string
|
||||
title?: string
|
||||
headerComponent?: Component
|
||||
footerComponent?: Component
|
||||
component: Component
|
||||
props?: Record<string, any>
|
||||
footerProps?: Record<string, any>
|
||||
dialogComponentProps?: DialogComponentProps
|
||||
priority?: number
|
||||
}) {
|
||||
function createDialog<
|
||||
H extends Component = Component,
|
||||
B extends Component = Component,
|
||||
F extends Component = Component
|
||||
>(options: ShowDialogOptions<H, B, F> & { key: string }) {
|
||||
if (dialogStack.value.length >= 10) {
|
||||
dialogStack.value.shift()
|
||||
}
|
||||
@@ -149,6 +154,7 @@ export const useDialogStore = defineStore('dialog', () => {
|
||||
? markRaw(options.footerComponent)
|
||||
: undefined,
|
||||
component: markRaw(options.component),
|
||||
headerProps: { ...options.headerProps },
|
||||
contentProps: { ...options.props },
|
||||
footerProps: { ...options.footerProps },
|
||||
priority: options.priority ?? 1,
|
||||
@@ -203,7 +209,11 @@ export const useDialogStore = defineStore('dialog', () => {
|
||||
})
|
||||
}
|
||||
|
||||
function showDialog(options: ShowDialogOptions) {
|
||||
function showDialog<
|
||||
H extends Component = Component,
|
||||
B extends Component = Component,
|
||||
F extends Component = Component
|
||||
>(options: ShowDialogOptions<H, B, F>) {
|
||||
const dialogKey = options.key || genDialogKey()
|
||||
|
||||
let dialog = dialogStack.value.find((d) => d.key === dialogKey)
|
||||
|
||||
@@ -24,8 +24,7 @@ export const getButtonSizeClasses = (size: ButtonSize = 'md') => {
|
||||
|
||||
export const getButtonTypeClasses = (type: ButtonType = 'primary') => {
|
||||
const baseByType = {
|
||||
primary:
|
||||
'bg-neutral-900 border-none text-white dark-theme:bg-white dark-theme:text-neutral-900',
|
||||
primary: 'bg-base-foreground border-none text-base-background',
|
||||
secondary: cn(
|
||||
'bg-secondary-background border-none text-base-foreground hover:bg-secondary-background-hover'
|
||||
),
|
||||
@@ -41,10 +40,8 @@ export const getButtonTypeClasses = (type: ButtonType = 'primary') => {
|
||||
|
||||
export const getBorderButtonTypeClasses = (type: ButtonType = 'primary') => {
|
||||
const baseByType = {
|
||||
primary:
|
||||
'bg-neutral-900 text-white dark-theme:bg-white dark-theme:text-neutral-900',
|
||||
secondary:
|
||||
'bg-white text-neutral-950 dark-theme:bg-zinc-700 dark-theme:text-white',
|
||||
primary: 'bg-base-background text-base-foreground',
|
||||
secondary: 'bg-secondary-background text-base-foreground',
|
||||
transparent: cn(
|
||||
'bg-transparent text-base-foreground hover:bg-secondary-background-hover'
|
||||
),
|
||||
@@ -53,10 +50,9 @@ export const getBorderButtonTypeClasses = (type: ButtonType = 'primary') => {
|
||||
} as const
|
||||
|
||||
const borderByType = {
|
||||
primary: 'border border-solid border-white dark-theme:border-neutral-900',
|
||||
secondary: 'border border-solid border-neutral-950 dark-theme:border-white',
|
||||
transparent:
|
||||
'border border-solid border-neutral-950 dark-theme:border-white',
|
||||
primary: 'border border-solid border-base-background',
|
||||
secondary: 'border border-solid border-base-foreground',
|
||||
transparent: 'border border-solid border-base-foreground',
|
||||
accent: 'border border-solid border-primary-background'
|
||||
} as const
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ interface GridOptions {
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Just use tailwind utilities directly.
|
||||
* TODO: Create a common grid layout component if needed.
|
||||
* Creates CSS grid styles for responsive grid layouts
|
||||
* @param options Grid configuration options
|
||||
* @returns CSS properties object for grid styling
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { TaskItemImpl } from '@/stores/queueStore'
|
||||
import type { JobState } from '@/types/queue'
|
||||
import { formatDuration } from '@/utils/formatUtil'
|
||||
import { clampPercentInt, formatPercent0 } from '@/utils/numberUtil'
|
||||
|
||||
type BuildJobDisplayCtx = {
|
||||
@@ -11,6 +12,8 @@ type BuildJobDisplayCtx = {
|
||||
currentNodePercent?: number
|
||||
currentNodeName?: string
|
||||
showAddedHint?: boolean
|
||||
/** Whether the app is running in cloud distribution */
|
||||
isCloud?: boolean
|
||||
}
|
||||
|
||||
type JobDisplay = {
|
||||
@@ -122,13 +125,20 @@ export const buildJobDisplay = (
|
||||
const time = task.executionTimeInSeconds
|
||||
const preview = task.previewOutput
|
||||
const iconImageUrl = preview && preview.isImage ? preview.url : undefined
|
||||
|
||||
// Cloud shows "Completed in Xh Ym Zs", non-cloud shows filename
|
||||
const primary = ctx.isCloud
|
||||
? ctx.t('queue.completedIn', {
|
||||
duration: formatDuration(task.executionTime ?? 0)
|
||||
})
|
||||
: preview?.filename && preview.filename.length
|
||||
? preview.filename
|
||||
: buildTitle(task, ctx.t)
|
||||
|
||||
return {
|
||||
iconName: iconForJobState(state),
|
||||
iconImageUrl,
|
||||
primary:
|
||||
preview?.filename && preview.filename.length
|
||||
? preview.filename
|
||||
: buildTitle(task, ctx.t),
|
||||
primary,
|
||||
secondary: time !== undefined ? `${time.toFixed(2)}s` : '',
|
||||
showClear: false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { unref } from 'vue'
|
||||
import type { MaybeRef } from 'vue'
|
||||
|
||||
import type {
|
||||
LGraph,
|
||||
LGraphNode,
|
||||
Subgraph
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { collectAllNodes } from '@/utils/graphTraversalUtil'
|
||||
|
||||
export type NodeDefLookup = Record<string, ComfyNodeDefImpl | undefined>
|
||||
|
||||
const isNodeMissingDefinition = (
|
||||
node: LGraphNode,
|
||||
nodeDefsByName: NodeDefLookup
|
||||
) => {
|
||||
const nodeName = node?.type
|
||||
if (!nodeName) return false
|
||||
return !nodeDefsByName[nodeName]
|
||||
}
|
||||
|
||||
export const collectMissingNodes = (
|
||||
graph: LGraph | Subgraph | null | undefined,
|
||||
nodeDefsByName: MaybeRef<NodeDefLookup>
|
||||
): LGraphNode[] => {
|
||||
if (!graph) return []
|
||||
const lookup = unref(nodeDefsByName)
|
||||
return collectAllNodes(graph, (node) => isNodeMissingDefinition(node, lookup))
|
||||
}
|
||||
|
||||
export const graphHasMissingNodes = (
|
||||
graph: LGraph | Subgraph | null | undefined,
|
||||
nodeDefsByName: MaybeRef<NodeDefLookup>
|
||||
) => {
|
||||
return collectMissingNodes(graph, nodeDefsByName).length > 0
|
||||
}
|
||||
@@ -8,8 +8,9 @@ import { useTemplateUrlLoader } from '@/platform/workflow/templates/composables/
|
||||
* Tests the behavior of loading templates via URL query parameters:
|
||||
* - ?template=flux_simple loads the template
|
||||
* - ?template=flux_simple&source=custom loads from custom source
|
||||
* - ?template=flux_simple&mode=linear loads template in linear mode
|
||||
* - Invalid template shows error toast
|
||||
* - Input validation for template and source parameters
|
||||
* - Input validation for template, source, and mode parameters
|
||||
*/
|
||||
|
||||
const preservedQueryMocks = vi.hoisted(() => ({
|
||||
@@ -70,10 +71,20 @@ vi.mock('vue-i18n', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
// Mock canvas store
|
||||
const mockCanvasStore = {
|
||||
linearMode: false
|
||||
}
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => mockCanvasStore
|
||||
}))
|
||||
|
||||
describe('useTemplateUrlLoader', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockQueryParams = {}
|
||||
mockCanvasStore.linearMode = false
|
||||
})
|
||||
|
||||
it('does not load template when no query param present', () => {
|
||||
@@ -236,6 +247,7 @@ describe('useTemplateUrlLoader', () => {
|
||||
mockQueryParams = {
|
||||
template: 'flux_simple',
|
||||
source: 'custom',
|
||||
mode: 'linear',
|
||||
other: 'param'
|
||||
}
|
||||
|
||||
@@ -270,4 +282,121 @@ describe('useTemplateUrlLoader', () => {
|
||||
query: { other: 'param' }
|
||||
})
|
||||
})
|
||||
|
||||
it('sets linear mode when mode=linear and template loads successfully', async () => {
|
||||
mockQueryParams = { template: 'flux_simple', mode: 'linear' }
|
||||
|
||||
const { loadTemplateFromUrl } = useTemplateUrlLoader()
|
||||
await loadTemplateFromUrl()
|
||||
|
||||
expect(mockLoadWorkflowTemplate).toHaveBeenCalledWith(
|
||||
'flux_simple',
|
||||
'default'
|
||||
)
|
||||
expect(mockCanvasStore.linearMode).toBe(true)
|
||||
})
|
||||
|
||||
it('does not set linear mode when template loading fails', async () => {
|
||||
mockQueryParams = { template: 'invalid-template', mode: 'linear' }
|
||||
mockLoadWorkflowTemplate.mockResolvedValueOnce(false)
|
||||
|
||||
const { loadTemplateFromUrl } = useTemplateUrlLoader()
|
||||
await loadTemplateFromUrl()
|
||||
|
||||
expect(mockCanvasStore.linearMode).toBe(false)
|
||||
})
|
||||
|
||||
it('does not set linear mode when mode parameter is not linear', async () => {
|
||||
mockQueryParams = { template: 'flux_simple', mode: 'graph' }
|
||||
|
||||
const { loadTemplateFromUrl } = useTemplateUrlLoader()
|
||||
await loadTemplateFromUrl()
|
||||
|
||||
expect(mockLoadWorkflowTemplate).toHaveBeenCalledWith(
|
||||
'flux_simple',
|
||||
'default'
|
||||
)
|
||||
expect(mockCanvasStore.linearMode).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects invalid mode parameter with special characters', () => {
|
||||
mockQueryParams = { template: 'flux_simple', mode: '../malicious' }
|
||||
|
||||
const { loadTemplateFromUrl } = useTemplateUrlLoader()
|
||||
void loadTemplateFromUrl()
|
||||
|
||||
expect(mockLoadTemplates).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles array mode params correctly', () => {
|
||||
// Vue Router can return string[] for duplicate params
|
||||
mockQueryParams = {
|
||||
template: 'flux_simple',
|
||||
mode: ['linear', 'graph'] as any
|
||||
}
|
||||
|
||||
const { loadTemplateFromUrl } = useTemplateUrlLoader()
|
||||
void loadTemplateFromUrl()
|
||||
|
||||
// Should not load when mode param is an array
|
||||
expect(mockLoadTemplates).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('warns about unsupported mode values but continues loading', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
mockQueryParams = { template: 'flux_simple', mode: 'unsupported' }
|
||||
|
||||
const { loadTemplateFromUrl } = useTemplateUrlLoader()
|
||||
await loadTemplateFromUrl()
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'[useTemplateUrlLoader] Unsupported mode parameter: unsupported. Supported modes: linear'
|
||||
)
|
||||
expect(mockLoadWorkflowTemplate).toHaveBeenCalledWith(
|
||||
'flux_simple',
|
||||
'default'
|
||||
)
|
||||
expect(mockCanvasStore.linearMode).toBe(false)
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('accepts supported mode parameter: linear', async () => {
|
||||
mockQueryParams = { template: 'flux_simple', mode: 'linear' }
|
||||
|
||||
const { loadTemplateFromUrl } = useTemplateUrlLoader()
|
||||
await loadTemplateFromUrl()
|
||||
|
||||
expect(mockLoadWorkflowTemplate).toHaveBeenCalledWith(
|
||||
'flux_simple',
|
||||
'default'
|
||||
)
|
||||
expect(mockCanvasStore.linearMode).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts valid format but warns about unsupported modes', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const unsupportedModes = ['graph', 'mode123', 'my_mode-2']
|
||||
|
||||
for (const mode of unsupportedModes) {
|
||||
vi.clearAllMocks()
|
||||
consoleSpy.mockClear()
|
||||
mockCanvasStore.linearMode = false
|
||||
mockQueryParams = { template: 'flux_simple', mode }
|
||||
|
||||
const { loadTemplateFromUrl } = useTemplateUrlLoader()
|
||||
await loadTemplateFromUrl()
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
`[useTemplateUrlLoader] Unsupported mode parameter: ${mode}. Supported modes: linear`
|
||||
)
|
||||
expect(mockLoadWorkflowTemplate).toHaveBeenCalledWith(
|
||||
'flux_simple',
|
||||
'default'
|
||||
)
|
||||
expect(mockCanvasStore.linearMode).toBe(false)
|
||||
}
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import {
|
||||
type LayoutChange,
|
||||
LayoutSource,
|
||||
type NodeLayout
|
||||
} from '@/renderer/core/layout/types'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import type { LayoutChange, NodeLayout } from '@/renderer/core/layout/types'
|
||||
|
||||
describe('layoutStore CRDT operations', () => {
|
||||
beforeEach(() => {
|
||||
@@ -304,4 +302,108 @@ describe('layoutStore CRDT operations', () => {
|
||||
expect(recentOps.length).toBeGreaterThanOrEqual(1)
|
||||
expect(recentOps[0].type).toBe('moveNode')
|
||||
})
|
||||
|
||||
it('normalizes DOM-sourced heights before storing', () => {
|
||||
const nodeId = 'dom-node'
|
||||
const layout = createTestNode(nodeId)
|
||||
|
||||
layoutStore.applyOperation({
|
||||
type: 'createNode',
|
||||
entity: 'node',
|
||||
nodeId,
|
||||
layout,
|
||||
timestamp: Date.now(),
|
||||
source: LayoutSource.External,
|
||||
actor: 'test'
|
||||
})
|
||||
|
||||
layoutStore.setSource(LayoutSource.DOM)
|
||||
layoutStore.batchUpdateNodeBounds([
|
||||
{
|
||||
nodeId,
|
||||
bounds: {
|
||||
x: layout.bounds.x,
|
||||
y: layout.bounds.y,
|
||||
width: layout.size.width,
|
||||
height: layout.size.height + LiteGraph.NODE_TITLE_HEIGHT
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
const nodeRef = layoutStore.getNodeLayoutRef(nodeId)
|
||||
expect(nodeRef.value?.size.height).toBe(layout.size.height)
|
||||
expect(nodeRef.value?.size.width).toBe(layout.size.width)
|
||||
expect(nodeRef.value?.position).toEqual(layout.position)
|
||||
})
|
||||
|
||||
it('normalizes very small DOM-sourced heights safely', () => {
|
||||
const nodeId = 'small-dom-node'
|
||||
const layout = createTestNode(nodeId)
|
||||
layout.size.height = 10
|
||||
|
||||
layoutStore.applyOperation({
|
||||
type: 'createNode',
|
||||
entity: 'node',
|
||||
nodeId,
|
||||
layout,
|
||||
timestamp: Date.now(),
|
||||
source: LayoutSource.External,
|
||||
actor: 'test'
|
||||
})
|
||||
|
||||
layoutStore.setSource(LayoutSource.DOM)
|
||||
layoutStore.batchUpdateNodeBounds([
|
||||
{
|
||||
nodeId,
|
||||
bounds: {
|
||||
x: layout.bounds.x,
|
||||
y: layout.bounds.y,
|
||||
width: layout.size.width,
|
||||
height: layout.size.height + LiteGraph.NODE_TITLE_HEIGHT
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
const nodeRef = layoutStore.getNodeLayoutRef(nodeId)
|
||||
expect(nodeRef.value?.size.height).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
it('handles undefined NODE_TITLE_HEIGHT without NaN results', () => {
|
||||
const nodeId = 'undefined-title-height'
|
||||
const layout = createTestNode(nodeId)
|
||||
|
||||
layoutStore.applyOperation({
|
||||
type: 'createNode',
|
||||
entity: 'node',
|
||||
nodeId,
|
||||
layout,
|
||||
timestamp: Date.now(),
|
||||
source: LayoutSource.External,
|
||||
actor: 'test'
|
||||
})
|
||||
|
||||
const originalTitleHeight = LiteGraph.NODE_TITLE_HEIGHT
|
||||
// @ts-expect-error – intentionally simulate undefined runtime value
|
||||
LiteGraph.NODE_TITLE_HEIGHT = undefined
|
||||
|
||||
try {
|
||||
layoutStore.setSource(LayoutSource.DOM)
|
||||
layoutStore.batchUpdateNodeBounds([
|
||||
{
|
||||
nodeId,
|
||||
bounds: {
|
||||
x: layout.bounds.x,
|
||||
y: layout.bounds.y,
|
||||
width: layout.size.width,
|
||||
height: layout.size.height
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
const nodeRef = layoutStore.getNodeLayoutRef(nodeId)
|
||||
expect(nodeRef.value?.size.height).toBe(layout.size.height)
|
||||
} finally {
|
||||
LiteGraph.NODE_TITLE_HEIGHT = originalTitleHeight
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,7 +5,9 @@ import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNodeZIndex'
|
||||
|
||||
// Mock the layout mutations module
|
||||
vi.mock('@/renderer/core/layout/operations/layoutMutations')
|
||||
vi.mock('@/renderer/core/layout/operations/layoutMutations', () => ({
|
||||
useLayoutMutations: vi.fn()
|
||||
}))
|
||||
|
||||
const mockedUseLayoutMutations = vi.mocked(useLayoutMutations)
|
||||
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
computeResizeOutcome,
|
||||
createResizeSession,
|
||||
toCanvasDelta
|
||||
} from '@/renderer/extensions/vueNodes/interactions/resize/resizeMath'
|
||||
|
||||
describe('nodeResizeMath', () => {
|
||||
const startSize = { width: 200, height: 120 }
|
||||
const startPosition = { x: 80, y: 160 }
|
||||
|
||||
it('computes resize from bottom-right corner without moving position', () => {
|
||||
const outcome = computeResizeOutcome({
|
||||
startSize,
|
||||
startPosition,
|
||||
delta: { x: 40, y: 20 },
|
||||
handle: { horizontal: 'right', vertical: 'bottom' }
|
||||
})
|
||||
|
||||
expect(outcome.size).toEqual({ width: 240, height: 140 })
|
||||
expect(outcome.position).toEqual(startPosition)
|
||||
})
|
||||
|
||||
it('computes resize from top-left corner adjusting position', () => {
|
||||
const outcome = computeResizeOutcome({
|
||||
startSize,
|
||||
startPosition,
|
||||
delta: { x: -30, y: -20 },
|
||||
handle: { horizontal: 'left', vertical: 'top' }
|
||||
})
|
||||
|
||||
expect(outcome.size).toEqual({ width: 230, height: 140 })
|
||||
expect(outcome.position).toEqual({ x: 50, y: 140 })
|
||||
})
|
||||
|
||||
it('supports reusable resize sessions with snapping', () => {
|
||||
const session = createResizeSession({
|
||||
startSize,
|
||||
startPosition,
|
||||
handle: { horizontal: 'right', vertical: 'bottom' }
|
||||
})
|
||||
|
||||
const snapFn = vi.fn((size: typeof startSize) => ({
|
||||
width: Math.round(size.width / 25) * 25,
|
||||
height: Math.round(size.height / 25) * 25
|
||||
}))
|
||||
|
||||
const applied = session({ x: 13, y: 17 }, snapFn)
|
||||
|
||||
expect(applied.size).toEqual({ width: 225, height: 125 })
|
||||
expect(applied.position).toEqual(startPosition)
|
||||
expect(snapFn).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('converts screen delta to canvas delta using scale', () => {
|
||||
const delta = toCanvasDelta({ x: 50, y: 75 }, { x: 150, y: 135 }, 2)
|
||||
|
||||
expect(delta).toEqual({ x: 50, y: 30 })
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles zero scale by using fallback scale of 1', () => {
|
||||
const delta = toCanvasDelta({ x: 50, y: 75 }, { x: 150, y: 135 }, 0)
|
||||
|
||||
expect(delta).toEqual({ x: 100, y: 60 })
|
||||
})
|
||||
|
||||
it('handles negative deltas when resizing from right/bottom', () => {
|
||||
const outcome = computeResizeOutcome({
|
||||
startSize,
|
||||
startPosition,
|
||||
delta: { x: -50, y: -30 },
|
||||
handle: { horizontal: 'right', vertical: 'bottom' }
|
||||
})
|
||||
|
||||
expect(outcome.size).toEqual({ width: 150, height: 90 })
|
||||
expect(outcome.position).toEqual(startPosition)
|
||||
})
|
||||
|
||||
it('handles very large deltas without overflow', () => {
|
||||
const outcome = computeResizeOutcome({
|
||||
startSize,
|
||||
startPosition,
|
||||
delta: { x: 10000, y: 10000 },
|
||||
handle: { horizontal: 'right', vertical: 'bottom' }
|
||||
})
|
||||
|
||||
expect(outcome.size.width).toBe(10200)
|
||||
expect(outcome.size.height).toBe(10120)
|
||||
expect(outcome.position).toEqual(startPosition)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type {
|
||||
LGraph,
|
||||
LGraphNode,
|
||||
Subgraph
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
collectMissingNodes,
|
||||
graphHasMissingNodes
|
||||
} from '@/workbench/extensions/manager/utils/graphHasMissingNodes'
|
||||
import type { NodeDefLookup } from '@/workbench/extensions/manager/utils/graphHasMissingNodes'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
|
||||
type NodeDefs = NodeDefLookup
|
||||
|
||||
let nodeIdCounter = 0
|
||||
const mockNodeDef = {} as ComfyNodeDefImpl
|
||||
|
||||
const createGraph = (nodes: LGraphNode[] = []): LGraph => {
|
||||
return { nodes } as Partial<LGraph> as LGraph
|
||||
}
|
||||
|
||||
const createSubgraph = (nodes: LGraphNode[]): Subgraph => {
|
||||
return { nodes } as Partial<Subgraph> as Subgraph
|
||||
}
|
||||
|
||||
const createNode = (
|
||||
type?: string,
|
||||
subgraphNodes?: LGraphNode[]
|
||||
): LGraphNode => {
|
||||
return {
|
||||
id: nodeIdCounter++,
|
||||
type,
|
||||
isSubgraphNode: subgraphNodes ? () => true : undefined,
|
||||
subgraph: subgraphNodes ? createSubgraph(subgraphNodes) : undefined
|
||||
} as unknown as LGraphNode
|
||||
}
|
||||
|
||||
describe('graphHasMissingNodes', () => {
|
||||
it('returns false when graph is null', () => {
|
||||
expect(graphHasMissingNodes(null, {})).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when graph is undefined', () => {
|
||||
expect(graphHasMissingNodes(undefined, {})).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when graph has no nodes', () => {
|
||||
expect(graphHasMissingNodes(createGraph(), {})).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when every node has a definition', () => {
|
||||
const graph = createGraph([createNode('FooNode'), createNode('BarNode')])
|
||||
const nodeDefs: NodeDefs = {
|
||||
FooNode: mockNodeDef,
|
||||
BarNode: mockNodeDef
|
||||
}
|
||||
|
||||
expect(graphHasMissingNodes(graph, nodeDefs)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when at least one node is missing', () => {
|
||||
const graph = createGraph([
|
||||
createNode('FooNode'),
|
||||
createNode('MissingNode')
|
||||
])
|
||||
const nodeDefs: NodeDefs = {
|
||||
FooNode: mockNodeDef
|
||||
}
|
||||
|
||||
expect(graphHasMissingNodes(graph, nodeDefs)).toBe(true)
|
||||
})
|
||||
|
||||
it('checks nodes nested in subgraphs', () => {
|
||||
const graph = createGraph([
|
||||
createNode('ContainerNode', [createNode('InnerMissing')])
|
||||
])
|
||||
const nodeDefs: NodeDefs = {
|
||||
ContainerNode: mockNodeDef
|
||||
}
|
||||
|
||||
const missingNodes = collectMissingNodes(graph, nodeDefs)
|
||||
expect(missingNodes).toHaveLength(1)
|
||||
expect(missingNodes[0]?.type).toBe('InnerMissing')
|
||||
})
|
||||
|
||||
it('ignores nodes without a type', () => {
|
||||
const graph = createGraph([
|
||||
createNode(undefined),
|
||||
createNode(null as unknown as string)
|
||||
])
|
||||
|
||||
expect(graphHasMissingNodes(graph, {})).toBe(false)
|
||||
})
|
||||
|
||||
it('traverses deeply nested subgraphs', () => {
|
||||
const deepGraph = createGraph([
|
||||
createNode('Layer1', [
|
||||
createNode('Layer2', [
|
||||
createNode('Layer3', [createNode('MissingDeep')])
|
||||
])
|
||||
])
|
||||
])
|
||||
const nodeDefs: NodeDefs = {
|
||||
Layer1: mockNodeDef,
|
||||
Layer2: mockNodeDef,
|
||||
Layer3: mockNodeDef
|
||||
}
|
||||
|
||||
const missingNodes = collectMissingNodes(deepGraph, nodeDefs)
|
||||
expect(missingNodes).toHaveLength(1)
|
||||
expect(missingNodes[0]?.type).toBe('MissingDeep')
|
||||
})
|
||||
})
|
||||