mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-10 09:07:54 +00:00
Compare commits
25 Commits
v1.33.2
...
coderabbit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d61dcbbe1 | ||
|
|
405fcb3ee5 | ||
|
|
177c12fcdc | ||
|
|
640fb97760 | ||
|
|
9da82f47ef | ||
|
|
a8d6f7baff | ||
|
|
a7daa5071c | ||
|
|
639975b804 | ||
|
|
ff0d385db8 | ||
|
|
6e2e591937 | ||
|
|
27fcc4554f | ||
|
|
e563c1be75 | ||
|
|
5a297e520c | ||
|
|
85bec5ee47 | ||
|
|
bdf6d4dea2 | ||
|
|
b8a796212c | ||
|
|
bc553f12be | ||
|
|
6bb35d46c1 | ||
|
|
68c38f0098 | ||
|
|
236247f05f | ||
|
|
87d6d18c57 | ||
|
|
87106ccb95 | ||
|
|
a20fb7d260 | ||
|
|
836cd7f9ba | ||
|
|
acd855601c |
3
.github/workflows/pr-backport.yaml
vendored
3
.github/workflows/pr-backport.yaml
vendored
@@ -78,8 +78,7 @@ jobs:
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
LABELS=$(gh pr view ${{ inputs.pr_number }} --json labels | jq -r '.labels[].name')
|
||||
else
|
||||
LABELS='${{ toJSON(github.event.pull_request.labels) }}'
|
||||
LABELS=$(echo "$LABELS" | jq -r '.[].name')
|
||||
LABELS=$(jq -r '.pull_request.labels[].name' "$GITHUB_EVENT_PATH")
|
||||
fi
|
||||
|
||||
add_target() {
|
||||
|
||||
@@ -557,7 +557,7 @@ export class ComfyPage {
|
||||
async dragAndDrop(source: Position, target: Position) {
|
||||
await this.page.mouse.move(source.x, source.y)
|
||||
await this.page.mouse.down()
|
||||
await this.page.mouse.move(target.x, target.y)
|
||||
await this.page.mouse.move(target.x, target.y, { steps: 100 })
|
||||
await this.page.mouse.up()
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
@@ -65,7 +65,9 @@ export class VueNodeHelpers {
|
||||
* Select a specific Vue node by ID
|
||||
*/
|
||||
async selectNode(nodeId: string): Promise<void> {
|
||||
await this.page.locator(`[data-node-id="${nodeId}"]`).click()
|
||||
await this.page
|
||||
.locator(`[data-node-id="${nodeId}"] .lg-node-header`)
|
||||
.click()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,11 +79,13 @@ export class VueNodeHelpers {
|
||||
// Select first node normally
|
||||
await this.selectNode(nodeIds[0])
|
||||
|
||||
// Add additional nodes with Ctrl+click
|
||||
// Add additional nodes with Ctrl+click on header
|
||||
for (let i = 1; i < nodeIds.length; i++) {
|
||||
await this.page.locator(`[data-node-id="${nodeIds[i]}"]`).click({
|
||||
modifiers: ['Control']
|
||||
})
|
||||
await this.page
|
||||
.locator(`[data-node-id="${nodeIds[i]}"] .lg-node-header`)
|
||||
.click({
|
||||
modifiers: ['Control']
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 99 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 97 KiB |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.33.2",
|
||||
"version": "1.33.5",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
severity="primary"
|
||||
size="small"
|
||||
:model="queueModeMenuItems"
|
||||
:disabled="hasMissingNodes"
|
||||
data-testid="queue-button"
|
||||
@click="queuePrompt"
|
||||
>
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
interface IconButtonProps extends BaseButtonProps {
|
||||
onClick: (event: Event) => void
|
||||
onClick?: (event: MouseEvent) => void
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
|
||||
@@ -47,7 +47,7 @@ const {
|
||||
} = defineProps<IconTextButtonProps>()
|
||||
|
||||
const buttonStyle = computed(() => {
|
||||
const baseClasses = `${getBaseButtonClasses()} justify-start! gap-2`
|
||||
const baseClasses = `${getBaseButtonClasses()} justify-start gap-2`
|
||||
const sizeClasses = getButtonSizeClasses(size)
|
||||
const typeClasses = border
|
||||
? getBorderButtonTypeClasses(type)
|
||||
|
||||
@@ -68,4 +68,8 @@ const toggle = (event: Event) => {
|
||||
const hide = () => {
|
||||
popover.value?.hide()
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
hide
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex w-[490px] flex-col border-t-1 border-b-1 border-border-default"
|
||||
class="flex w-[490px] flex-col border-t-1 border-border-default"
|
||||
:class="isCloud ? 'border-b-1' : ''"
|
||||
>
|
||||
<div class="flex h-full w-full flex-col gap-4 p-4">
|
||||
<!-- Description -->
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<InputText
|
||||
ref="inputRef"
|
||||
v-model="inputValue"
|
||||
:placeholder
|
||||
autofocus
|
||||
@keyup.enter="onConfirm"
|
||||
@focus="selectAllText"
|
||||
@@ -28,6 +29,7 @@ const props = defineProps<{
|
||||
message: string
|
||||
defaultValue: string
|
||||
onConfirm: (value: string) => void
|
||||
placeholder?: string
|
||||
}>()
|
||||
|
||||
const inputValue = ref<string>(props.defaultValue)
|
||||
|
||||
@@ -3,7 +3,7 @@ https://github.com/Nuked88/ComfyUI-N-Sidebar/blob/7ae7da4a9761009fb6629bc04c6830
|
||||
-->
|
||||
<template>
|
||||
<LGraphNodePreview v-if="shouldRenderVueNodes" :node-def="nodeDef" />
|
||||
<div v-else class="_sb_node_preview">
|
||||
<div v-else class="_sb_node_preview bg-component-node-background">
|
||||
<div class="_sb_table">
|
||||
<div
|
||||
class="node_header mr-4 text-ellipsis"
|
||||
@@ -200,7 +200,6 @@ const truncateDefaultValue = (value: any, charLimit: number = 32): string => {
|
||||
}
|
||||
|
||||
._sb_node_preview {
|
||||
background-color: var(--comfy-menu-bg);
|
||||
font-family: 'Open Sans', sans-serif;
|
||||
color: var(--descrip-text);
|
||||
border: 1px solid var(--descrip-text);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="group flex w-full items-center justify-between gap-3 rounded-lg border-0 bg-secondary-background p-1 text-left transition-colors duration-200 ease-in-out hover:cursor-pointer hover:bg-secondary-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
|
||||
<IconButton
|
||||
type="secondary"
|
||||
size="fit-content"
|
||||
class="group w-full justify-between gap-3 rounded-lg p-1 text-left font-normal hover:cursor-pointer focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
|
||||
:aria-label="props.ariaLabel"
|
||||
@click="emit('click', $event)"
|
||||
>
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<span v-if="props.mode === 'allFailed'" class="inline-flex items-center">
|
||||
@@ -76,10 +78,11 @@
|
||||
>
|
||||
<i class="icon-[lucide--chevron-down] block size-4 leading-none" />
|
||||
</span>
|
||||
</button>
|
||||
</IconButton>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconButton from '@/components/button/IconButton.vue'
|
||||
import type {
|
||||
CompletionSummary,
|
||||
CompletionSummaryMode
|
||||
@@ -96,4 +99,8 @@ type Props = {
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
thumbnailUrls: () => []
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'click', event: MouseEvent): void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -42,17 +42,19 @@
|
||||
t('sideToolbar.queueProgressOverlay.running')
|
||||
}}</span>
|
||||
</span>
|
||||
<button
|
||||
<IconButton
|
||||
v-if="runningCount > 0"
|
||||
v-tooltip.top="cancelJobTooltip"
|
||||
class="inline-flex size-6 cursor-pointer items-center justify-center rounded border-0 bg-secondary-background p-0 transition-colors hover:bg-destructive-background"
|
||||
type="secondary"
|
||||
size="sm"
|
||||
class="size-6 bg-secondary-background hover:bg-destructive-background"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.interruptAll')"
|
||||
@click="$emit('interruptAll')"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--x] block size-4 leading-none text-text-primary"
|
||||
/>
|
||||
</button>
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -62,26 +64,28 @@
|
||||
t('sideToolbar.queueProgressOverlay.queuedSuffix')
|
||||
}}</span>
|
||||
</span>
|
||||
<button
|
||||
<IconButton
|
||||
v-if="queuedCount > 0"
|
||||
v-tooltip.top="clearQueueTooltip"
|
||||
class="inline-flex size-6 cursor-pointer items-center justify-center rounded border-0 bg-secondary-background p-0 transition-colors hover:bg-destructive-background"
|
||||
type="secondary"
|
||||
size="sm"
|
||||
class="size-6 bg-secondary-background hover:bg-destructive-background"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.clearQueued')"
|
||||
@click="$emit('clearQueued')"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--list-x] block size-4 leading-none text-text-primary"
|
||||
/>
|
||||
</button>
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="inline-flex h-6 min-w-[120px] flex-1 cursor-pointer items-center justify-center rounded border-0 bg-secondary-background px-2 py-0 text-[12px] text-text-primary hover:bg-secondary-background-hover hover:opacity-90"
|
||||
<TextButton
|
||||
class="h-6 min-w-[120px] flex-1 px-2 py-0 text-[12px]"
|
||||
type="secondary"
|
||||
:label="t('sideToolbar.queueProgressOverlay.viewAllJobs')"
|
||||
@click="$emit('viewAllJobs')"
|
||||
>
|
||||
{{ t('sideToolbar.queueProgressOverlay.viewAllJobs') }}
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -90,6 +94,8 @@
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import IconButton from '@/components/button/IconButton.vue'
|
||||
import TextButton from '@/components/button/TextButton.vue'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
|
||||
defineProps<{
|
||||
|
||||
@@ -8,17 +8,20 @@
|
||||
/>
|
||||
|
||||
<div class="flex items-center justify-between px-3">
|
||||
<button
|
||||
class="inline-flex grow cursor-pointer items-center justify-center gap-1 rounded border-0 bg-secondary-background p-2 text-center font-inter text-[12px] leading-none text-text-primary hover:bg-secondary-background-hover hover:opacity-90"
|
||||
<IconTextButton
|
||||
class="grow gap-1 p-2 text-center font-inter text-[12px] leading-none hover:opacity-90 justify-center"
|
||||
type="secondary"
|
||||
:label="t('sideToolbar.queueProgressOverlay.showAssets')"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.showAssets')"
|
||||
@click="$emit('showAssets')"
|
||||
>
|
||||
<div
|
||||
class="pointer-events-none block size-4 shrink-0 leading-none icon-[comfy--image-ai-edit]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{{ t('sideToolbar.queueProgressOverlay.showAssets') }}</span>
|
||||
</button>
|
||||
<template #icon>
|
||||
<div
|
||||
class="pointer-events-none block size-4 shrink-0 leading-none icon-[comfy--image-ai-edit]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</template>
|
||||
</IconTextButton>
|
||||
<div class="ml-4 inline-flex items-center">
|
||||
<div
|
||||
class="inline-flex h-6 items-center text-[12px] leading-none text-text-primary opacity-90"
|
||||
@@ -28,16 +31,18 @@
|
||||
t('sideToolbar.queueProgressOverlay.queuedSuffix')
|
||||
}}</span>
|
||||
</div>
|
||||
<button
|
||||
<IconButton
|
||||
v-if="queuedCount > 0"
|
||||
class="group ml-2 inline-flex size-6 cursor-pointer items-center justify-center rounded border-0 bg-secondary-background p-0 transition-colors hover:bg-destructive-background"
|
||||
class="group ml-2 size-6 bg-secondary-background hover:bg-destructive-background"
|
||||
type="secondary"
|
||||
size="sm"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.clearQueued')"
|
||||
@click="$emit('clearQueued')"
|
||||
>
|
||||
<i
|
||||
class="pointer-events-none icon-[lucide--list-x] block size-4 leading-none text-text-primary transition-colors group-hover:text-base-background"
|
||||
/>
|
||||
</button>
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -75,6 +80,8 @@
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import IconButton from '@/components/button/IconButton.vue'
|
||||
import IconTextButton from '@/components/button/IconTextButton.vue'
|
||||
import type {
|
||||
JobGroup,
|
||||
JobListItem,
|
||||
|
||||
@@ -18,16 +18,18 @@
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
<IconButton
|
||||
v-tooltip.top="moreTooltipConfig"
|
||||
class="inline-flex size-6 cursor-pointer items-center justify-center rounded border-0 bg-transparent p-0 hover:bg-secondary-background hover:opacity-100"
|
||||
type="transparent"
|
||||
size="sm"
|
||||
class="size-6 bg-transparent hover:bg-secondary-background hover:opacity-100"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.moreOptions')"
|
||||
@click="onMoreClick"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--more-horizontal] block size-4 leading-none text-text-secondary"
|
||||
/>
|
||||
</button>
|
||||
</IconButton>
|
||||
<Popover
|
||||
ref="morePopoverRef"
|
||||
:dismissable="true"
|
||||
@@ -45,18 +47,19 @@
|
||||
<div
|
||||
class="flex flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3 font-inter"
|
||||
>
|
||||
<button
|
||||
class="inline-flex w-full cursor-pointer items-center justify-start gap-2 rounded-lg border-0 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90"
|
||||
<IconTextButton
|
||||
class="w-full justify-start gap-2 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90"
|
||||
type="transparent"
|
||||
:label="t('sideToolbar.queueProgressOverlay.clearHistory')"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.clearHistory')"
|
||||
@click="onClearHistoryFromMenu"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--file-x-2] block size-4 leading-none text-text-secondary"
|
||||
/>
|
||||
<span>{{
|
||||
t('sideToolbar.queueProgressOverlay.clearHistory')
|
||||
}}</span>
|
||||
</button>
|
||||
<template #icon>
|
||||
<i
|
||||
class="icon-[lucide--file-x-2] block size-4 leading-none text-text-secondary"
|
||||
/>
|
||||
</template>
|
||||
</IconTextButton>
|
||||
</div>
|
||||
</Popover>
|
||||
</div>
|
||||
@@ -69,6 +72,8 @@ import type { PopoverMethods } from 'primevue/popover'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import IconButton from '@/components/button/IconButton.vue'
|
||||
import IconTextButton from '@/components/button/IconTextButton.vue'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
|
||||
defineProps<{
|
||||
|
||||
@@ -8,13 +8,15 @@
|
||||
<p class="m-0 text-[14px] font-normal leading-none">
|
||||
{{ t('sideToolbar.queueProgressOverlay.clearHistoryDialogTitle') }}
|
||||
</p>
|
||||
<button
|
||||
class="inline-flex size-6 cursor-pointer items-center justify-center rounded border-0 bg-transparent p-0 text-text-secondary transition hover:bg-secondary-background hover:opacity-100"
|
||||
<IconButton
|
||||
type="transparent"
|
||||
size="sm"
|
||||
class="size-6 bg-transparent text-text-secondary hover:bg-secondary-background hover:opacity-100"
|
||||
:aria-label="t('g.close')"
|
||||
@click="onCancel"
|
||||
>
|
||||
<i class="icon-[lucide--x] block size-4 leading-none" />
|
||||
</button>
|
||||
</IconButton>
|
||||
</header>
|
||||
|
||||
<div class="flex flex-col gap-4 px-4 py-4 text-[14px] text-text-secondary">
|
||||
@@ -30,21 +32,19 @@
|
||||
|
||||
<footer class="flex items-center justify-end px-4 py-4">
|
||||
<div class="flex items-center gap-4 text-[14px] leading-none">
|
||||
<button
|
||||
class="inline-flex min-h-[24px] cursor-pointer items-center rounded-md border-0 bg-transparent px-1 py-1 text-[14px] leading-[1] text-text-secondary transition hover:text-text-primary"
|
||||
:aria-label="t('g.cancel')"
|
||||
<TextButton
|
||||
class="min-h-[24px] px-1 py-1 text-[14px] leading-[1] text-text-secondary hover:text-text-primary"
|
||||
type="transparent"
|
||||
:label="t('g.cancel')"
|
||||
@click="onCancel"
|
||||
>
|
||||
{{ t('g.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex min-h-[32px] items-center rounded-lg border-0 bg-secondary-background px-4 py-2 text-[12px] font-normal leading-[1] text-text-primary transition hover:bg-secondary-background-hover hover:text-text-primary disabled:cursor-not-allowed disabled:opacity-60"
|
||||
:aria-label="t('g.clear')"
|
||||
/>
|
||||
<TextButton
|
||||
class="min-h-[32px] px-4 py-2 text-[12px] font-normal leading-[1]"
|
||||
type="secondary"
|
||||
:label="t('g.clear')"
|
||||
:disabled="isClearing"
|
||||
@click="onConfirm"
|
||||
>
|
||||
{{ t('g.clear') }}
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</footer>
|
||||
</section>
|
||||
@@ -54,6 +54,8 @@
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import IconButton from '@/components/button/IconButton.vue'
|
||||
import TextButton from '@/components/button/TextButton.vue'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
|
||||
@@ -20,21 +20,24 @@
|
||||
<div v-if="entry.kind === 'divider'" class="px-2 py-1">
|
||||
<div class="h-px bg-interface-stroke" />
|
||||
</div>
|
||||
<button
|
||||
<IconTextButton
|
||||
v-else
|
||||
class="inline-flex w-full cursor-pointer items-center justify-start gap-2 rounded-lg border-0 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary transition-colors duration-150 hover:bg-interface-panel-hover-surface"
|
||||
class="w-full justify-start gap-2 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-interface-panel-hover-surface"
|
||||
type="transparent"
|
||||
:label="entry.label"
|
||||
:aria-label="entry.label"
|
||||
@click="onEntry(entry)"
|
||||
>
|
||||
<i
|
||||
v-if="entry.icon"
|
||||
:class="[
|
||||
entry.icon,
|
||||
'block size-4 shrink-0 leading-none text-text-secondary'
|
||||
]"
|
||||
/>
|
||||
<span>{{ entry.label }}</span>
|
||||
</button>
|
||||
<template #icon>
|
||||
<i
|
||||
v-if="entry.icon"
|
||||
:class="[
|
||||
entry.icon,
|
||||
'block size-4 shrink-0 leading-none text-text-secondary'
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</IconTextButton>
|
||||
</template>
|
||||
</div>
|
||||
</Popover>
|
||||
@@ -44,6 +47,7 @@
|
||||
import Popover from 'primevue/popover'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import IconTextButton from '@/components/button/IconTextButton.vue'
|
||||
import type { MenuEntry } from '@/composables/queue/useJobMenu'
|
||||
|
||||
defineProps<{ entries: MenuEntry[] }>()
|
||||
|
||||
@@ -20,17 +20,18 @@
|
||||
class="flex min-w-0 items-center text-[0.75rem] leading-normal font-normal text-text-secondary"
|
||||
>
|
||||
<span class="block min-w-0 truncate">{{ row.value }}</span>
|
||||
<button
|
||||
<IconButton
|
||||
v-if="row.canCopy"
|
||||
type="button"
|
||||
class="ml-2 inline-flex size-6 items-center justify-center rounded border-0 bg-transparent p-0 hover:opacity-90"
|
||||
type="transparent"
|
||||
size="sm"
|
||||
class="ml-2 size-6 bg-transparent hover:opacity-90"
|
||||
:aria-label="copyAriaLabel"
|
||||
@click.stop="copyJobId"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--copy] block size-4 leading-none text-text-secondary"
|
||||
/>
|
||||
</button>
|
||||
</IconButton>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -60,25 +61,31 @@
|
||||
{{ t('queue.jobDetails.errorMessage') }}
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-6 items-center justify-center gap-2 rounded border-none bg-transparent px-0 text-[0.75rem] leading-none text-text-secondary hover:opacity-90"
|
||||
<IconTextButton
|
||||
class="h-6 justify-start gap-2 bg-transparent px-0 text-[0.75rem] leading-none text-text-secondary hover:opacity-90"
|
||||
type="transparent"
|
||||
:label="copyAriaLabel"
|
||||
:aria-label="copyAriaLabel"
|
||||
icon-position="right"
|
||||
@click.stop="copyErrorMessage"
|
||||
>
|
||||
<span>{{ copyAriaLabel }}</span>
|
||||
<i class="icon-[lucide--copy] block size-3.5 leading-none" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-6 items-center justify-center gap-2 rounded border-none bg-transparent px-0 text-[0.75rem] leading-none text-text-secondary hover:opacity-90"
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--copy] block size-3.5 leading-none" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
<IconTextButton
|
||||
class="h-6 justify-start gap-2 bg-transparent px-0 text-[0.75rem] leading-none text-text-secondary hover:opacity-90"
|
||||
type="transparent"
|
||||
:label="t('queue.jobDetails.report')"
|
||||
icon-position="right"
|
||||
@click.stop="reportJobError"
|
||||
>
|
||||
<span>{{ t('queue.jobDetails.report') }}</span>
|
||||
<i
|
||||
class="icon-[lucide--message-circle-warning] block size-3.5 leading-none"
|
||||
/>
|
||||
</button>
|
||||
<template #icon>
|
||||
<i
|
||||
class="icon-[lucide--message-circle-warning] block size-3.5 leading-none"
|
||||
/>
|
||||
</template>
|
||||
</IconTextButton>
|
||||
</div>
|
||||
<div
|
||||
class="col-span-2 mt-2 rounded bg-interface-panel-hover-surface px-4 py-2 text-[0.75rem] leading-normal text-text-secondary"
|
||||
@@ -94,6 +101,8 @@
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import IconButton from '@/components/button/IconButton.vue'
|
||||
import IconTextButton from '@/components/button/IconTextButton.vue'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { t } from '@/i18n'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
|
||||
@@ -2,26 +2,26 @@
|
||||
<div class="flex items-center justify-between gap-2 px-3">
|
||||
<div class="min-w-0 flex-1 overflow-x-auto">
|
||||
<div class="inline-flex items-center gap-1 whitespace-nowrap">
|
||||
<button
|
||||
<TextButton
|
||||
v-for="tab in visibleJobTabs"
|
||||
:key="tab"
|
||||
class="h-6 cursor-pointer rounded border-0 px-3 py-1 text-[12px] leading-none hover:opacity-90"
|
||||
class="h-6 px-3 py-1 text-[12px] leading-none hover:opacity-90"
|
||||
:type="selectedJobTab === tab ? 'secondary' : 'transparent'"
|
||||
:class="[
|
||||
selectedJobTab === tab
|
||||
? 'bg-secondary-background text-text-primary'
|
||||
: 'bg-transparent text-text-secondary'
|
||||
selectedJobTab === tab ? 'text-text-primary' : 'text-text-secondary'
|
||||
]"
|
||||
:label="tabLabel(tab)"
|
||||
@click="$emit('update:selectedJobTab', tab)"
|
||||
>
|
||||
{{ tabLabel(tab) }}
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-2 flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
<IconButton
|
||||
v-if="showWorkflowFilter"
|
||||
v-tooltip.top="filterTooltipConfig"
|
||||
class="relative inline-flex size-6 cursor-pointer items-center justify-center rounded border-0 bg-secondary-background p-0 hover:bg-secondary-background-hover hover:opacity-90"
|
||||
type="secondary"
|
||||
size="sm"
|
||||
class="relative size-6 bg-secondary-background hover:bg-secondary-background-hover hover:opacity-90"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.filterJobs')"
|
||||
@click="onFilterClick"
|
||||
>
|
||||
@@ -32,7 +32,7 @@
|
||||
v-if="selectedWorkflowFilter !== 'all'"
|
||||
class="pointer-events-none absolute -top-1 -right-1 inline-block size-2 rounded-full bg-base-foreground"
|
||||
/>
|
||||
</button>
|
||||
</IconButton>
|
||||
<Popover
|
||||
v-if="showWorkflowFilter"
|
||||
ref="filterPopoverRef"
|
||||
@@ -51,46 +51,48 @@
|
||||
<div
|
||||
class="flex min-w-[12rem] flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3"
|
||||
>
|
||||
<button
|
||||
class="inline-flex w-full cursor-pointer items-center justify-start gap-1 rounded-lg border-0 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90"
|
||||
<IconTextButton
|
||||
class="w-full justify-between gap-1 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90"
|
||||
type="transparent"
|
||||
icon-position="right"
|
||||
:label="t('sideToolbar.queueProgressOverlay.filterAllWorkflows')"
|
||||
:aria-label="
|
||||
t('sideToolbar.queueProgressOverlay.filterAllWorkflows')
|
||||
"
|
||||
@click="selectWorkflowFilter('all')"
|
||||
>
|
||||
<span>{{
|
||||
t('sideToolbar.queueProgressOverlay.filterAllWorkflows')
|
||||
}}</span>
|
||||
<span class="ml-auto inline-flex items-center">
|
||||
<template #icon>
|
||||
<i
|
||||
v-if="selectedWorkflowFilter === 'all'"
|
||||
class="icon-[lucide--check] block size-4 leading-none text-text-secondary"
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
</IconTextButton>
|
||||
<div class="mx-2 mt-1 h-px" />
|
||||
<button
|
||||
class="inline-flex w-full cursor-pointer items-center justify-start gap-1 rounded-lg border-0 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90"
|
||||
<IconTextButton
|
||||
class="w-full justify-between gap-1 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90"
|
||||
type="transparent"
|
||||
icon-position="right"
|
||||
:label="t('sideToolbar.queueProgressOverlay.filterCurrentWorkflow')"
|
||||
:aria-label="
|
||||
t('sideToolbar.queueProgressOverlay.filterCurrentWorkflow')
|
||||
"
|
||||
@click="selectWorkflowFilter('current')"
|
||||
>
|
||||
<span>{{
|
||||
t('sideToolbar.queueProgressOverlay.filterCurrentWorkflow')
|
||||
}}</span>
|
||||
<span class="ml-auto inline-flex items-center">
|
||||
<template #icon>
|
||||
<i
|
||||
v-if="selectedWorkflowFilter === 'current'"
|
||||
class="icon-[lucide--check] block size-4 leading-none text-text-secondary"
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
</IconTextButton>
|
||||
</div>
|
||||
</Popover>
|
||||
<button
|
||||
<IconButton
|
||||
v-tooltip.top="sortTooltipConfig"
|
||||
class="relative inline-flex size-6 cursor-pointer items-center justify-center rounded border-0 bg-secondary-background p-0 hover:bg-secondary-background-hover hover:opacity-90"
|
||||
type="secondary"
|
||||
size="sm"
|
||||
class="relative size-6 bg-secondary-background hover:bg-secondary-background-hover hover:opacity-90"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.sortJobs')"
|
||||
@click="onSortClick"
|
||||
>
|
||||
@@ -101,7 +103,7 @@
|
||||
v-if="selectedSortMode !== 'mostRecent'"
|
||||
class="pointer-events-none absolute -top-1 -right-1 inline-block size-2 rounded-full bg-base-foreground"
|
||||
/>
|
||||
</button>
|
||||
</IconButton>
|
||||
<Popover
|
||||
ref="sortPopoverRef"
|
||||
:dismissable="true"
|
||||
@@ -120,19 +122,21 @@
|
||||
class="flex min-w-[12rem] flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3"
|
||||
>
|
||||
<template v-for="(mode, index) in jobSortModes" :key="mode">
|
||||
<button
|
||||
class="inline-flex w-full cursor-pointer items-center justify-start gap-1 rounded-lg border-0 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90"
|
||||
<IconTextButton
|
||||
class="w-full justify-between gap-1 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90"
|
||||
type="transparent"
|
||||
icon-position="right"
|
||||
:label="sortLabel(mode)"
|
||||
:aria-label="sortLabel(mode)"
|
||||
@click="selectSortMode(mode)"
|
||||
>
|
||||
<span>{{ sortLabel(mode) }}</span>
|
||||
<span class="ml-auto inline-flex items-center">
|
||||
<template #icon>
|
||||
<i
|
||||
v-if="selectedSortMode === mode"
|
||||
class="icon-[lucide--check] block size-4 leading-none text-text-secondary"
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
</IconTextButton>
|
||||
<div
|
||||
v-if="index < jobSortModes.length - 1"
|
||||
class="mx-2 mt-1 h-px"
|
||||
@@ -149,6 +153,9 @@ import Popover from 'primevue/popover'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import IconButton from '@/components/button/IconButton.vue'
|
||||
import IconTextButton from '@/components/button/IconTextButton.vue'
|
||||
import TextButton from '@/components/button/TextButton.vue'
|
||||
import { jobSortModes, jobTabs } from '@/composables/queue/useJobList'
|
||||
import type { JobSortMode, JobTab } from '@/composables/queue/useJobList'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
|
||||
@@ -108,45 +108,47 @@
|
||||
key="actions"
|
||||
class="inline-flex items-center gap-2 pr-1"
|
||||
>
|
||||
<button
|
||||
<IconButton
|
||||
v-if="props.state === 'failed' && computedShowClear"
|
||||
v-tooltip.top="deleteTooltipConfig"
|
||||
type="button"
|
||||
class="inline-flex h-6 transform cursor-pointer items-center gap-1 rounded border-0 bg-modal-card-button-surface px-1 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:bg-destructive-background hover:opacity-95"
|
||||
type="transparent"
|
||||
size="sm"
|
||||
class="h-6 transform gap-1 rounded bg-modal-card-button-surface px-1 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:bg-destructive-background hover:opacity-95"
|
||||
:aria-label="t('g.delete')"
|
||||
@click.stop="emit('delete')"
|
||||
>
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</button>
|
||||
<button
|
||||
</IconButton>
|
||||
<IconButton
|
||||
v-else-if="props.state !== 'completed' && computedShowClear"
|
||||
v-tooltip.top="cancelTooltipConfig"
|
||||
type="button"
|
||||
class="inline-flex h-6 transform cursor-pointer items-center gap-1 rounded border-0 bg-modal-card-button-surface px-1 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:bg-destructive-background hover:opacity-95"
|
||||
type="transparent"
|
||||
size="sm"
|
||||
class="h-6 transform gap-1 rounded bg-modal-card-button-surface px-1 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:bg-destructive-background hover:opacity-95"
|
||||
:aria-label="t('g.cancel')"
|
||||
@click.stop="emit('cancel')"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</button>
|
||||
<button
|
||||
</IconButton>
|
||||
<TextButton
|
||||
v-else-if="props.state === 'completed'"
|
||||
type="button"
|
||||
class="inline-flex h-6 transform cursor-pointer items-center gap-1 rounded border-0 bg-modal-card-button-surface px-2 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:opacity-95"
|
||||
class="h-6 transform gap-1 rounded bg-modal-card-button-surface px-2 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:opacity-95"
|
||||
type="transparent"
|
||||
:label="t('menuLabels.View')"
|
||||
:aria-label="t('menuLabels.View')"
|
||||
@click.stop="emit('view')"
|
||||
>
|
||||
<span>{{ t('menuLabels.View') }}</span>
|
||||
</button>
|
||||
<button
|
||||
/>
|
||||
<IconButton
|
||||
v-if="props.showMenu !== undefined ? props.showMenu : true"
|
||||
v-tooltip.top="moreTooltipConfig"
|
||||
type="button"
|
||||
class="inline-flex h-6 transform cursor-pointer items-center gap-1 rounded border-0 bg-modal-card-button-surface px-1 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:opacity-95"
|
||||
type="transparent"
|
||||
size="sm"
|
||||
class="h-6 transform gap-1 rounded bg-modal-card-button-surface px-1 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:opacity-95"
|
||||
:aria-label="t('g.more')"
|
||||
@click.stop="emit('menu', $event)"
|
||||
>
|
||||
<i class="icon-[lucide--more-horizontal] size-4" />
|
||||
</button>
|
||||
</IconButton>
|
||||
</div>
|
||||
<div v-else key="secondary" class="pr-2">
|
||||
<slot name="secondary">{{ props.rightText }}</slot>
|
||||
@@ -161,6 +163,8 @@
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import IconButton from '@/components/button/IconButton.vue'
|
||||
import TextButton from '@/components/button/TextButton.vue'
|
||||
import JobDetailsPopover from '@/components/queue/job/JobDetailsPopover.vue'
|
||||
import QueueAssetPreview from '@/components/queue/job/QueueAssetPreview.vue'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
|
||||
@@ -85,10 +85,13 @@
|
||||
:show-output-count="shouldShowOutputCount(item)"
|
||||
:output-count="getOutputCount(item)"
|
||||
:show-delete-button="shouldShowDeleteButton"
|
||||
:open-popover-id="openPopoverId"
|
||||
@click="handleAssetSelect(item)"
|
||||
@zoom="handleZoomClick(item)"
|
||||
@output-count-click="enterFolderView(item)"
|
||||
@asset-deleted="refreshAssets"
|
||||
@popover-opened="openPopoverId = item.id"
|
||||
@popover-closed="openPopoverId = null"
|
||||
/>
|
||||
</template>
|
||||
</VirtualGrid>
|
||||
@@ -199,6 +202,9 @@ const folderPromptId = ref<string | null>(null)
|
||||
const folderExecutionTime = ref<number | undefined>(undefined)
|
||||
const isInFolderView = computed(() => folderPromptId.value !== null)
|
||||
|
||||
// Track which asset's popover is open (for single-instance popover management)
|
||||
const openPopoverId = ref<string | null>(null)
|
||||
|
||||
// Determine if delete button should be shown
|
||||
// Hide delete button when in input tab and not in cloud (OSS mode - files are from local folders)
|
||||
const shouldShowDeleteButton = computed(() => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import type { NodeId } from '@/renderer/core/layout/types'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { isDOMWidget } from '@/scripts/domWidget'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
@@ -46,7 +47,7 @@ export interface SafeWidgetData {
|
||||
}
|
||||
|
||||
export interface VueNodeData {
|
||||
id: string
|
||||
id: NodeId
|
||||
title: string
|
||||
type: string
|
||||
mode: number
|
||||
|
||||
@@ -1545,7 +1545,26 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
}
|
||||
},
|
||||
GeminiImageNode: {
|
||||
displayPrice: '$0.03 per 1K tokens'
|
||||
displayPrice: '~$0.039/Image (1K)'
|
||||
},
|
||||
GeminiImage2Node: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
const resolutionWidget = node.widgets?.find(
|
||||
(w) => w.name === 'resolution'
|
||||
) as IComboWidget
|
||||
|
||||
if (!resolutionWidget) return 'Token-based'
|
||||
|
||||
const resolution = String(resolutionWidget.value)
|
||||
if (resolution.includes('1K')) {
|
||||
return '~$0.134/Image'
|
||||
} else if (resolution.includes('2K')) {
|
||||
return '~$0.134/Image'
|
||||
} else if (resolution.includes('4K')) {
|
||||
return '~$0.24/Image'
|
||||
}
|
||||
return 'Token-based'
|
||||
}
|
||||
},
|
||||
// OpenAI nodes
|
||||
OpenAIChatNode: {
|
||||
@@ -1829,6 +1848,7 @@ export const useNodePricing = () => {
|
||||
TripoTextureNode: ['texture_quality'],
|
||||
// Google/Gemini nodes
|
||||
GeminiNode: ['model'],
|
||||
GeminiImage2Node: ['resolution'],
|
||||
// OpenAI nodes
|
||||
OpenAIChatNode: ['model'],
|
||||
// ByteDance
|
||||
|
||||
114
src/core/graph/widgets/dynamicWidgets.ts
Normal file
114
src/core/graph/widgets/dynamicWidgets.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
|
||||
import type { ComboInputSpec, InputSpec } from '@/schemas/nodeDefSchema'
|
||||
import { zDynamicComboInputSpec } from '@/schemas/nodeDefSchema'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { ComfyApp } from '@/scripts/app'
|
||||
|
||||
function dynamicComboWidget(
|
||||
node: LGraphNode,
|
||||
inputName: string,
|
||||
untypedInputData: InputSpec,
|
||||
appArg: ComfyApp,
|
||||
widgetName?: string
|
||||
) {
|
||||
const { addNodeInput } = useLitegraphService()
|
||||
const parseResult = zDynamicComboInputSpec.safeParse(untypedInputData)
|
||||
if (!parseResult.success) throw new Error('invalid DynamicCombo spec')
|
||||
const inputData = parseResult.data
|
||||
const options = Object.fromEntries(
|
||||
inputData[1].options.map(({ key, inputs }) => [key, inputs])
|
||||
)
|
||||
const subSpec: ComboInputSpec = [Object.keys(options), {}]
|
||||
const { widget, minWidth, minHeight } = app.widgets['COMBO'](
|
||||
node,
|
||||
inputName,
|
||||
subSpec,
|
||||
appArg,
|
||||
widgetName
|
||||
)
|
||||
let currentDynamicNames: string[] = []
|
||||
const updateWidgets = (value?: string) => {
|
||||
if (!node.widgets) throw new Error('Not Reachable')
|
||||
const newSpec = value ? options[value] : undefined
|
||||
//TODO: Calculate intersection for widgets that persist across options
|
||||
//This would potentially allow links to be retained
|
||||
for (const name of currentDynamicNames) {
|
||||
const inputIndex = node.inputs.findIndex((input) => input.name === name)
|
||||
if (inputIndex !== -1) node.removeInput(inputIndex)
|
||||
const widgetIndex = node.widgets.findIndex(
|
||||
(widget) => widget.name === name
|
||||
)
|
||||
if (widgetIndex === -1) continue
|
||||
node.widgets[widgetIndex].value = undefined
|
||||
node.widgets.splice(widgetIndex, 1)
|
||||
}
|
||||
currentDynamicNames = []
|
||||
if (!newSpec) return
|
||||
|
||||
const insertionPoint = node.widgets.findIndex((w) => w === widget) + 1
|
||||
const startingLength = node.widgets.length
|
||||
const inputInsertionPoint =
|
||||
node.inputs.findIndex((i) => i.name === widget.name) + 1
|
||||
const startingInputLength = node.inputs.length
|
||||
if (insertionPoint === 0)
|
||||
throw new Error("Dynamic widget doesn't exist on node")
|
||||
const inputTypes: [Record<string, InputSpec> | undefined, boolean][] = [
|
||||
[newSpec.required, false],
|
||||
[newSpec.optional, true]
|
||||
]
|
||||
for (const [inputType, isOptional] of inputTypes)
|
||||
for (const name in inputType ?? {}) {
|
||||
addNodeInput(
|
||||
node,
|
||||
transformInputSpecV1ToV2(inputType![name], {
|
||||
name,
|
||||
isOptional
|
||||
})
|
||||
)
|
||||
currentDynamicNames.push(name)
|
||||
}
|
||||
|
||||
const addedWidgets = node.widgets.splice(startingLength)
|
||||
node.widgets.splice(insertionPoint, 0, ...addedWidgets)
|
||||
if (inputInsertionPoint === 0) {
|
||||
if (
|
||||
addedWidgets.length === 0 &&
|
||||
node.inputs.length !== startingInputLength
|
||||
)
|
||||
//input is inputOnly, but lacks an insertion point
|
||||
throw new Error('Failed to find input socket for ' + widget.name)
|
||||
return
|
||||
}
|
||||
const addedInputs = node
|
||||
.spliceInputs(startingInputLength)
|
||||
.map((addedInput) => {
|
||||
const existingInput = node.inputs.findIndex(
|
||||
(existingInput) => addedInput.name === existingInput.name
|
||||
)
|
||||
return existingInput === -1
|
||||
? addedInput
|
||||
: node.spliceInputs(existingInput, 1)[0]
|
||||
})
|
||||
//assume existing inputs are in correct order
|
||||
node.spliceInputs(inputInsertionPoint, 0, ...addedInputs)
|
||||
node.size[1] = node.computeSize([...node.size])[1]
|
||||
}
|
||||
//A little hacky, but onConfigure won't work.
|
||||
//It fires too late and is overly disruptive
|
||||
let widgetValue = widget.value
|
||||
Object.defineProperty(widget, 'value', {
|
||||
get() {
|
||||
return widgetValue
|
||||
},
|
||||
set(value) {
|
||||
widgetValue = value
|
||||
updateWidgets(value)
|
||||
}
|
||||
})
|
||||
widget.value = widgetValue
|
||||
return { widget, minWidth, minHeight }
|
||||
}
|
||||
|
||||
export const dynamicWidgets = { COMFY_DYNAMICCOMBO_V3: dynamicComboWidget }
|
||||
@@ -17,10 +17,10 @@ useExtensionService().registerExtension({
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
onNodeCreated ? onNodeCreated.apply(this, []) : undefined
|
||||
|
||||
const showValueWidget = ComfyWidgets['STRING'](
|
||||
const showValueWidget = ComfyWidgets['MARKDOWN'](
|
||||
this,
|
||||
'preview',
|
||||
['STRING', { multiline: true }],
|
||||
['MARKDOWN', {}],
|
||||
app
|
||||
).widget as DOMWidget<HTMLTextAreaElement, string>
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ export type {
|
||||
LGraphTriggerParam
|
||||
} from './types/graphTriggers'
|
||||
|
||||
export type rendererType = 'LG' | 'Vue'
|
||||
export type RendererType = 'LG' | 'Vue'
|
||||
|
||||
export interface LGraphState {
|
||||
lastGroupId: number
|
||||
@@ -106,7 +106,7 @@ export interface LGraphExtra extends Dictionary<unknown> {
|
||||
reroutes?: SerialisableReroute[]
|
||||
linkExtensions?: { id: number; parentId: number | undefined }[]
|
||||
ds?: DragAndScaleState
|
||||
workflowRendererVersion?: rendererType
|
||||
workflowRendererVersion?: RendererType
|
||||
}
|
||||
|
||||
export interface BaseLGraph {
|
||||
|
||||
@@ -1771,18 +1771,19 @@ export class LGraphCanvas
|
||||
}
|
||||
|
||||
static onMenuNodeClone(
|
||||
// @ts-expect-error - unused parameter
|
||||
value: IContextMenuValue,
|
||||
// @ts-expect-error - unused parameter
|
||||
options: IContextMenuOptions,
|
||||
// @ts-expect-error - unused parameter
|
||||
e: MouseEvent,
|
||||
// @ts-expect-error - unused parameter
|
||||
menu: ContextMenu,
|
||||
_value: IContextMenuValue,
|
||||
_options: IContextMenuOptions,
|
||||
_e: MouseEvent,
|
||||
_menu: ContextMenu,
|
||||
node: LGraphNode
|
||||
): void {
|
||||
const canvas = LGraphCanvas.active_canvas
|
||||
const nodes = canvas.selectedItems.size ? canvas.selectedItems : [node]
|
||||
const nodes = canvas.selectedItems.size ? [...canvas.selectedItems] : [node]
|
||||
if (nodes.length) LGraphCanvas.cloneNodes(nodes)
|
||||
}
|
||||
|
||||
static cloneNodes(nodes: Positionable[]) {
|
||||
const canvas = LGraphCanvas.active_canvas
|
||||
|
||||
// Find top-left-most boundary
|
||||
let offsetX = Infinity
|
||||
@@ -1792,11 +1793,11 @@ export class LGraphCanvas
|
||||
throw new TypeError(
|
||||
'Invalid node encountered on clone. `pos` was null.'
|
||||
)
|
||||
if (item.pos[0] < offsetX) offsetX = item.pos[0]
|
||||
if (item.pos[1] < offsetY) offsetY = item.pos[1]
|
||||
offsetX = Math.min(offsetX, item.pos[0])
|
||||
offsetY = Math.min(offsetY, item.pos[1])
|
||||
}
|
||||
|
||||
canvas._deserializeItems(canvas._serializeItems(nodes), {
|
||||
return canvas._deserializeItems(canvas._serializeItems(nodes), {
|
||||
position: [offsetX + 5, offsetY + 5]
|
||||
})
|
||||
}
|
||||
|
||||
@@ -848,15 +848,13 @@ export class LGraphNode
|
||||
}
|
||||
|
||||
if (info.widgets_values) {
|
||||
const widgetsWithValue = this.widgets.filter(
|
||||
(w) => w.serialize !== false
|
||||
const widgetsWithValue = this.widgets
|
||||
.values()
|
||||
.filter((w) => w.serialize !== false)
|
||||
.filter((_w, idx) => idx < info.widgets_values!.length)
|
||||
widgetsWithValue.forEach(
|
||||
(widget, i) => (widget.value = info.widgets_values![i])
|
||||
)
|
||||
for (let i = 0; i < info.widgets_values.length; ++i) {
|
||||
const widget = widgetsWithValue[i]
|
||||
if (widget) {
|
||||
widget.value = info.widgets_values[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -884,7 +882,7 @@ export class LGraphNode
|
||||
|
||||
// special case for when there were errors
|
||||
if (this.constructor === LGraphNode && this.last_serialization)
|
||||
return this.last_serialization
|
||||
return { ...this.last_serialization, mode: o.mode, pos: o.pos }
|
||||
|
||||
if (this.inputs)
|
||||
o.inputs = this.inputs.map((input) => inputAsSerialisable(input))
|
||||
@@ -1652,6 +1650,19 @@ export class LGraphNode
|
||||
this.onInputRemoved?.(slot, slot_info[0])
|
||||
this.setDirtyCanvas(true, true)
|
||||
}
|
||||
spliceInputs(
|
||||
startIndex: number,
|
||||
deleteCount = -1,
|
||||
...toAdd: INodeInputSlot[]
|
||||
): INodeInputSlot[] {
|
||||
if (deleteCount < 0) return this.inputs.splice(startIndex)
|
||||
const ret = this.inputs.splice(startIndex, deleteCount, ...toAdd)
|
||||
this.inputs.slice(startIndex).forEach((input, index) => {
|
||||
const link = input.link && this.graph?.links?.get(input.link)
|
||||
if (link) link.target_slot = startIndex + index
|
||||
})
|
||||
return ret
|
||||
}
|
||||
|
||||
/**
|
||||
* computes the minimum size of a node according to its inputs and output slots
|
||||
|
||||
@@ -1436,6 +1436,7 @@
|
||||
"INT": "INT",
|
||||
"LATENT": "LATENT",
|
||||
"LATENT_OPERATION": "LATENT_OPERATION",
|
||||
"LATENT_UPSCALE_MODEL": "LATENT_UPSCALE_MODEL",
|
||||
"LOAD_3D": "LOAD_3D",
|
||||
"LOAD_3D_ANIMATION": "LOAD_3D_ANIMATION",
|
||||
"LOAD3D_CAMERA": "LOAD3D_CAMERA",
|
||||
@@ -2075,7 +2076,36 @@
|
||||
"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": "Upload model",
|
||||
"uploadModel": "Import model",
|
||||
"uploadModelFromCivitai": "Import a model from Civitai",
|
||||
"uploadModelFailedToRetrieveMetadata": "Failed to retrieve metadata. Please check the link and try again.",
|
||||
"onlyCivitaiUrlsSupported": "Only Civitai URLs are supported",
|
||||
"uploadModelDescription1": "Paste a Civitai model download link to add it to your library.",
|
||||
"uploadModelDescription2": "Only links from https://civitai.com are supported at the moment",
|
||||
"uploadModelDescription3": "Max file size: 1 GB",
|
||||
"civitaiLinkLabel": "Civitai model download link",
|
||||
"civitaiLinkPlaceholder": "Paste link here",
|
||||
"civitaiLinkExample": "Example: https://civitai.com/api/download/models/833921?type=Model&format=SafeTensor",
|
||||
"confirmModelDetails": "Confirm Model Details",
|
||||
"fileName": "File Name",
|
||||
"fileSize": "File Size",
|
||||
"modelName": "Model Name",
|
||||
"modelNamePlaceholder": "Enter a name for this model",
|
||||
"tags": "Tags",
|
||||
"tagsPlaceholder": "e.g., models, checkpoint",
|
||||
"tagsHelp": "Separate tags with commas",
|
||||
"upload": "Import",
|
||||
"uploadingModel": "Importing model...",
|
||||
"uploadSuccess": "Model imported successfully!",
|
||||
"uploadFailed": "Import failed",
|
||||
"modelAssociatedWithLink": "The model associated with the link you provided:",
|
||||
"modelTypeSelectorLabel": "What type of model is this?",
|
||||
"modelTypeSelectorPlaceholder": "Select model type",
|
||||
"selectModelType": "Select model type",
|
||||
"notSureLeaveAsIs": "Not sure? Just leave this as is",
|
||||
"modelUploaded": "Model imported! 🎉",
|
||||
"findInLibrary": "Find it in the {type} section of the models library.",
|
||||
"finish": "Finish",
|
||||
"allModels": "All Models",
|
||||
"allCategory": "All {category}",
|
||||
"unknown": "Unknown",
|
||||
@@ -2087,6 +2117,13 @@
|
||||
"sortZA": "Z-A",
|
||||
"sortRecent": "Recent",
|
||||
"sortPopular": "Popular",
|
||||
"errorFileTooLarge": "File exceeds the maximum allowed size limit",
|
||||
"errorFormatNotAllowed": "Only SafeTensor format is allowed",
|
||||
"errorUnsafePickleScan": "CivitAI detected potentially unsafe code in this file",
|
||||
"errorUnsafeVirusScan": "CivitAI detected malware or suspicious content in this file",
|
||||
"errorModelTypeNotSupported": "This model type is not supported",
|
||||
"errorUnknown": "An unexpected error occurred",
|
||||
"errorUploadFailed": "Failed to import asset. Please try again.",
|
||||
"ariaLabel": {
|
||||
"assetCard": "{name} - {type} asset",
|
||||
"loadingAsset": "Loading asset"
|
||||
|
||||
@@ -2022,6 +2022,28 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"EmptyHunyuanVideo15Latent": {
|
||||
"display_name": "EmptyHunyuanVideo15Latent",
|
||||
"inputs": {
|
||||
"width": {
|
||||
"name": "width"
|
||||
},
|
||||
"height": {
|
||||
"name": "height"
|
||||
},
|
||||
"length": {
|
||||
"name": "length"
|
||||
},
|
||||
"batch_size": {
|
||||
"name": "batch_size"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"EmptyImage": {
|
||||
"display_name": "EmptyImage",
|
||||
"inputs": {
|
||||
@@ -2574,6 +2596,54 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"GeminiImage2Node": {
|
||||
"display_name": "Nano Banana Pro (Google Gemini Image)",
|
||||
"description": "Generate or edit images synchronously via Google Vertex API.",
|
||||
"inputs": {
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "Text prompt describing the image to generate or the edits to apply. Include any constraints, styles, or details the model should follow."
|
||||
},
|
||||
"model": {
|
||||
"name": "model"
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "When the seed is fixed to a specific value, the model makes a best effort to provide the same response for repeated requests. Deterministic output isn't guaranteed. Also, changing the model or parameter settings, such as the temperature, can cause variations in the response even when you use the same seed value. By default, a random seed value is used."
|
||||
},
|
||||
"aspect_ratio": {
|
||||
"name": "aspect_ratio",
|
||||
"tooltip": "If set to 'auto', matches your input image's aspect ratio; if no image is provided, generates a 1:1 square."
|
||||
},
|
||||
"resolution": {
|
||||
"name": "resolution",
|
||||
"tooltip": "Target output resolution. For 2K/4K the native Gemini upscaler is used."
|
||||
},
|
||||
"response_modalities": {
|
||||
"name": "response_modalities",
|
||||
"tooltip": "Choose 'IMAGE' for image-only output, or 'IMAGE+TEXT' to return both the generated image and a text response."
|
||||
},
|
||||
"images": {
|
||||
"name": "images",
|
||||
"tooltip": "Optional reference image(s). To include multiple images, use the Batch Images node (up to 14)."
|
||||
},
|
||||
"files": {
|
||||
"name": "files",
|
||||
"tooltip": "Optional file(s) to use as context for the model. Accepts inputs from the Gemini Generate Content Input Files node."
|
||||
},
|
||||
"control_after_generate": {
|
||||
"name": "control after generate"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"GeminiImageNode": {
|
||||
"display_name": "Nano Banana (Google Gemini Image)",
|
||||
"description": "Edit images synchronously via Google API.",
|
||||
@@ -2602,6 +2672,10 @@
|
||||
"name": "aspect_ratio",
|
||||
"tooltip": "Defaults to matching the output image size to that of your input image, or otherwise generates 1:1 squares."
|
||||
},
|
||||
"response_modalities": {
|
||||
"name": "response_modalities",
|
||||
"tooltip": "Choose 'IMAGE' for image-only output, or 'IMAGE+TEXT' to return both the generated image and a text response."
|
||||
},
|
||||
"control_after_generate": {
|
||||
"name": "control after generate"
|
||||
}
|
||||
@@ -2905,6 +2979,120 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"HunyuanVideo15ImageToVideo": {
|
||||
"display_name": "HunyuanVideo15ImageToVideo",
|
||||
"inputs": {
|
||||
"positive": {
|
||||
"name": "positive"
|
||||
},
|
||||
"negative": {
|
||||
"name": "negative"
|
||||
},
|
||||
"vae": {
|
||||
"name": "vae"
|
||||
},
|
||||
"width": {
|
||||
"name": "width"
|
||||
},
|
||||
"height": {
|
||||
"name": "height"
|
||||
},
|
||||
"length": {
|
||||
"name": "length"
|
||||
},
|
||||
"batch_size": {
|
||||
"name": "batch_size"
|
||||
},
|
||||
"start_image": {
|
||||
"name": "start_image"
|
||||
},
|
||||
"clip_vision_output": {
|
||||
"name": "clip_vision_output"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "positive",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "negative",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "latent",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"HunyuanVideo15LatentUpscaleWithModel": {
|
||||
"display_name": "Hunyuan Video 15 Latent Upscale With Model",
|
||||
"inputs": {
|
||||
"model": {
|
||||
"name": "model"
|
||||
},
|
||||
"samples": {
|
||||
"name": "samples"
|
||||
},
|
||||
"upscale_method": {
|
||||
"name": "upscale_method"
|
||||
},
|
||||
"width": {
|
||||
"name": "width"
|
||||
},
|
||||
"height": {
|
||||
"name": "height"
|
||||
},
|
||||
"crop": {
|
||||
"name": "crop"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"HunyuanVideo15SuperResolution": {
|
||||
"display_name": "HunyuanVideo15SuperResolution",
|
||||
"inputs": {
|
||||
"positive": {
|
||||
"name": "positive"
|
||||
},
|
||||
"negative": {
|
||||
"name": "negative"
|
||||
},
|
||||
"latent": {
|
||||
"name": "latent"
|
||||
},
|
||||
"noise_augmentation": {
|
||||
"name": "noise_augmentation"
|
||||
},
|
||||
"vae": {
|
||||
"name": "vae"
|
||||
},
|
||||
"start_image": {
|
||||
"name": "start_image"
|
||||
},
|
||||
"clip_vision_output": {
|
||||
"name": "clip_vision_output"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "positive",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "negative",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "latent",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"HypernetworkLoader": {
|
||||
"display_name": "HypernetworkLoader",
|
||||
"inputs": {
|
||||
@@ -4566,6 +4754,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"LatentUpscaleModelLoader": {
|
||||
"display_name": "Load Latent Upscale Model",
|
||||
"inputs": {
|
||||
"model_name": {
|
||||
"name": "model_name"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LazyCache": {
|
||||
"display_name": "LazyCache",
|
||||
"description": "A homebrew version of EasyCache - even 'easier' version of EasyCache to implement. Overall works worse than EasyCache, but better in some rare cases AND universal compatibility with everything in ComfyUI.",
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
:on-click="handleUploadClick"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--upload]" />
|
||||
<i class="icon-[lucide--package-plus]" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
</div>
|
||||
@@ -73,11 +73,14 @@ import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import AssetFilterBar from '@/platform/assets/components/AssetFilterBar.vue'
|
||||
import AssetGrid from '@/platform/assets/components/AssetGrid.vue'
|
||||
import UploadModelDialog from '@/platform/assets/components/UploadModelDialog.vue'
|
||||
import UploadModelDialogHeader from '@/platform/assets/components/UploadModelDialogHeader.vue'
|
||||
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
|
||||
import { useAssetBrowser } from '@/platform/assets/composables/useAssetBrowser'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import { formatCategoryLabel } from '@/platform/assets/utils/categoryLabel'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
|
||||
import { OnCloseKey } from '@/types/widgetTypes'
|
||||
|
||||
@@ -92,6 +95,7 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'asset-select': [asset: AssetDisplayItem]
|
||||
@@ -189,6 +193,15 @@ const { flags } = useFeatureFlags()
|
||||
const isUploadButtonEnabled = computed(() => flags.modelUploadButtonEnabled)
|
||||
|
||||
function handleUploadClick() {
|
||||
// Will be implemented in the future commit
|
||||
dialogStore.showDialog({
|
||||
key: 'upload-model',
|
||||
headerComponent: UploadModelDialogHeader,
|
||||
component: UploadModelDialog,
|
||||
props: {
|
||||
onUploadSuccess: async () => {
|
||||
await execute()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -71,9 +71,10 @@
|
||||
</div>
|
||||
<div v-tooltip.top="$t('mediaAsset.actions.more')">
|
||||
<MoreButton
|
||||
ref="moreButtonRef"
|
||||
size="sm"
|
||||
@menu-opened="isMenuOpen = true"
|
||||
@menu-closed="isMenuOpen = false"
|
||||
@menu-opened="handleMenuOpened"
|
||||
@menu-closed="handleMenuClosed"
|
||||
@mouseenter="handleOverlayMouseEnter"
|
||||
@mouseleave="handleOverlayMouseLeave"
|
||||
>
|
||||
@@ -139,7 +140,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useElementHover } from '@vueuse/core'
|
||||
import { useElementHover, whenever } from '@vueuse/core'
|
||||
import { computed, defineAsyncComponent, provide, ref, toRef } from 'vue'
|
||||
|
||||
import IconButton from '@/components/button/IconButton.vue'
|
||||
@@ -189,7 +190,8 @@ const {
|
||||
selected,
|
||||
showOutputCount,
|
||||
outputCount,
|
||||
showDeleteButton
|
||||
showDeleteButton,
|
||||
openPopoverId
|
||||
} = defineProps<{
|
||||
asset?: AssetItem
|
||||
loading?: boolean
|
||||
@@ -197,15 +199,19 @@ const {
|
||||
showOutputCount?: boolean
|
||||
outputCount?: number
|
||||
showDeleteButton?: boolean
|
||||
openPopoverId?: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
zoom: [asset: AssetItem]
|
||||
'output-count-click': []
|
||||
'asset-deleted': []
|
||||
'popover-opened': []
|
||||
'popover-closed': []
|
||||
}>()
|
||||
|
||||
const cardContainerRef = ref<HTMLElement>()
|
||||
const moreButtonRef = ref<InstanceType<typeof MoreButton>>()
|
||||
|
||||
const isVideoPlaying = ref(false)
|
||||
const isMenuOpen = ref(false)
|
||||
@@ -339,4 +345,22 @@ const handleOutputCountClick = () => {
|
||||
const handleAssetDelete = () => {
|
||||
emit('asset-deleted')
|
||||
}
|
||||
|
||||
const handleMenuOpened = () => {
|
||||
isMenuOpen.value = true
|
||||
emit('popover-opened')
|
||||
}
|
||||
|
||||
const handleMenuClosed = () => {
|
||||
isMenuOpen.value = false
|
||||
emit('popover-closed')
|
||||
}
|
||||
|
||||
// Close this popover when another opens
|
||||
whenever(
|
||||
() => openPopoverId && openPopoverId !== asset?.id && isMenuOpen.value,
|
||||
() => {
|
||||
moreButtonRef.value?.hide()
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
58
src/platform/assets/components/UploadModelConfirmation.vue
Normal file
58
src/platform/assets/components/UploadModelConfirmation.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- Model Info Section -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-sm text-muted m-0">
|
||||
{{ $t('assetBrowser.modelAssociatedWithLink') }}
|
||||
</p>
|
||||
<p class="text-sm mt-0">
|
||||
{{ metadata?.name || metadata?.filename }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Model Type Selection -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm text-muted">
|
||||
{{ $t('assetBrowser.modelTypeSelectorLabel') }}
|
||||
</label>
|
||||
<SingleSelect
|
||||
v-model="selectedModelType"
|
||||
:label="
|
||||
isLoading
|
||||
? $t('g.loading')
|
||||
: $t('assetBrowser.modelTypeSelectorPlaceholder')
|
||||
"
|
||||
:options="modelTypes"
|
||||
:disabled="isLoading"
|
||||
/>
|
||||
<div class="flex items-center gap-2 text-sm text-muted">
|
||||
<i class="icon-[lucide--info]" />
|
||||
<span>{{ $t('assetBrowser.notSureLeaveAsIs') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import SingleSelect from '@/components/input/SingleSelect.vue'
|
||||
import { useModelTypes } from '@/platform/assets/composables/useModelTypes'
|
||||
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string | undefined
|
||||
metadata: AssetMetadata | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string | undefined]
|
||||
}>()
|
||||
|
||||
const { modelTypes, isLoading } = useModelTypes()
|
||||
|
||||
const selectedModelType = computed({
|
||||
get: () => props.modelValue ?? null,
|
||||
set: (value: string | null) => emit('update:modelValue', value ?? undefined)
|
||||
})
|
||||
</script>
|
||||
108
src/platform/assets/components/UploadModelDialog.vue
Normal file
108
src/platform/assets/components/UploadModelDialog.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<div class="upload-model-dialog flex flex-col justify-between gap-6 p-4 pt-6">
|
||||
<!-- Step 1: Enter URL -->
|
||||
<UploadModelUrlInput
|
||||
v-if="currentStep === 1"
|
||||
v-model="wizardData.url"
|
||||
:error="uploadError"
|
||||
/>
|
||||
|
||||
<!-- Step 2: Confirm Metadata -->
|
||||
<UploadModelConfirmation
|
||||
v-else-if="currentStep === 2"
|
||||
v-model="selectedModelType"
|
||||
:metadata="wizardData.metadata"
|
||||
/>
|
||||
|
||||
<!-- Step 3: Upload Progress -->
|
||||
<UploadModelProgress
|
||||
v-else-if="currentStep === 3"
|
||||
:status="uploadStatus"
|
||||
:error="uploadError"
|
||||
:metadata="wizardData.metadata"
|
||||
:model-type="selectedModelType"
|
||||
/>
|
||||
|
||||
<!-- Navigation Footer -->
|
||||
<UploadModelFooter
|
||||
:current-step="currentStep"
|
||||
:is-fetching-metadata="isFetchingMetadata"
|
||||
:is-uploading="isUploading"
|
||||
:can-fetch-metadata="canFetchMetadata"
|
||||
:can-upload-model="canUploadModel"
|
||||
:upload-status="uploadStatus"
|
||||
@back="goToPreviousStep"
|
||||
@fetch-metadata="handleFetchMetadata"
|
||||
@upload="handleUploadModel"
|
||||
@close="handleClose"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
|
||||
import UploadModelConfirmation from '@/platform/assets/components/UploadModelConfirmation.vue'
|
||||
import UploadModelFooter from '@/platform/assets/components/UploadModelFooter.vue'
|
||||
import UploadModelProgress from '@/platform/assets/components/UploadModelProgress.vue'
|
||||
import UploadModelUrlInput from '@/platform/assets/components/UploadModelUrlInput.vue'
|
||||
import { useModelTypes } from '@/platform/assets/composables/useModelTypes'
|
||||
import { useUploadModelWizard } from '@/platform/assets/composables/useUploadModelWizard'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const dialogStore = useDialogStore()
|
||||
const { modelTypes, fetchModelTypes } = useModelTypes()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'upload-success': []
|
||||
}>()
|
||||
|
||||
const {
|
||||
currentStep,
|
||||
isFetchingMetadata,
|
||||
isUploading,
|
||||
uploadStatus,
|
||||
uploadError,
|
||||
wizardData,
|
||||
selectedModelType,
|
||||
canFetchMetadata,
|
||||
canUploadModel,
|
||||
fetchMetadata,
|
||||
uploadModel,
|
||||
goToPreviousStep
|
||||
} = useUploadModelWizard(modelTypes)
|
||||
|
||||
async function handleFetchMetadata() {
|
||||
await fetchMetadata()
|
||||
}
|
||||
|
||||
async function handleUploadModel() {
|
||||
const success = await uploadModel()
|
||||
if (success) {
|
||||
emit('upload-success')
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
dialogStore.closeDialog({ key: 'upload-model' })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchModelTypes()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.upload-model-dialog {
|
||||
width: 90vw;
|
||||
max-width: 800px;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.upload-model-dialog {
|
||||
width: auto;
|
||||
min-width: 600px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
12
src/platform/assets/components/UploadModelDialogHeader.vue
Normal file
12
src/platform/assets/components/UploadModelDialogHeader.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-3 px-4 py-2 font-bold">
|
||||
<span>{{ $t('assetBrowser.uploadModelFromCivitai') }}</span>
|
||||
<span
|
||||
class="rounded-full bg-white px-1.5 py-0 text-xxs font-medium uppercase text-black"
|
||||
>
|
||||
{{ $t('g.beta') }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts"></script>
|
||||
72
src/platform/assets/components/UploadModelFooter.vue
Normal file
72
src/platform/assets/components/UploadModelFooter.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<div class="flex justify-end gap-2">
|
||||
<TextButton
|
||||
v-if="currentStep !== 1 && currentStep !== 3"
|
||||
:label="$t('g.back')"
|
||||
type="secondary"
|
||||
size="md"
|
||||
:disabled="isFetchingMetadata || isUploading"
|
||||
@click="emit('back')"
|
||||
/>
|
||||
<span v-else />
|
||||
|
||||
<IconTextButton
|
||||
v-if="currentStep === 1"
|
||||
:label="$t('g.continue')"
|
||||
type="primary"
|
||||
size="md"
|
||||
:disabled="!canFetchMetadata || isFetchingMetadata"
|
||||
@click="emit('fetchMetadata')"
|
||||
>
|
||||
<template #icon>
|
||||
<i
|
||||
v-if="isFetchingMetadata"
|
||||
class="icon-[lucide--loader-circle] animate-spin"
|
||||
/>
|
||||
</template>
|
||||
</IconTextButton>
|
||||
<IconTextButton
|
||||
v-else-if="currentStep === 2"
|
||||
:label="$t('assetBrowser.upload')"
|
||||
type="primary"
|
||||
size="md"
|
||||
:disabled="!canUploadModel || isUploading"
|
||||
@click="emit('upload')"
|
||||
>
|
||||
<template #icon>
|
||||
<i
|
||||
v-if="isUploading"
|
||||
class="icon-[lucide--loader-circle] animate-spin"
|
||||
/>
|
||||
</template>
|
||||
</IconTextButton>
|
||||
<TextButton
|
||||
v-else-if="currentStep === 3 && uploadStatus === 'success'"
|
||||
:label="$t('assetBrowser.finish')"
|
||||
type="primary"
|
||||
size="md"
|
||||
@click="emit('close')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconTextButton from '@/components/button/IconTextButton.vue'
|
||||
import TextButton from '@/components/button/TextButton.vue'
|
||||
|
||||
defineProps<{
|
||||
currentStep: number
|
||||
isFetchingMetadata: boolean
|
||||
isUploading: boolean
|
||||
canFetchMetadata: boolean
|
||||
canUploadModel: boolean
|
||||
uploadStatus: 'idle' | 'uploading' | 'success' | 'error'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'back'): void
|
||||
(e: 'fetchMetadata'): void
|
||||
(e: 'upload'): void
|
||||
(e: 'close'): void
|
||||
}>()
|
||||
</script>
|
||||
68
src/platform/assets/components/UploadModelProgress.vue
Normal file
68
src/platform/assets/components/UploadModelProgress.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<div class="flex flex-1 flex-col gap-6">
|
||||
<!-- Uploading State -->
|
||||
<div
|
||||
v-if="status === 'uploading'"
|
||||
class="flex flex-1 flex-col items-center justify-center gap-6"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--loader-circle] animate-spin text-6xl text-primary"
|
||||
/>
|
||||
<div class="text-center">
|
||||
<p class="m-0 text-sm font-bold">
|
||||
{{ $t('assetBrowser.uploadingModel') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success State -->
|
||||
<div v-else-if="status === 'success'" class="flex flex-col gap-8">
|
||||
<div class="flex flex-col gap-4">
|
||||
<p class="text-sm text-muted m-0 font-bold">
|
||||
{{ $t('assetBrowser.modelUploaded') }}
|
||||
</p>
|
||||
<p class="text-sm text-muted m-0">
|
||||
{{ $t('assetBrowser.findInLibrary', { type: modelType }) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row items-start p-8 bg-neutral-800 rounded-lg">
|
||||
<div class="flex flex-col justify-center items-start gap-1 flex-1">
|
||||
<p class="text-sm m-0">
|
||||
{{ metadata?.name || metadata?.filename }}
|
||||
</p>
|
||||
<p class="text-sm text-muted m-0">
|
||||
{{ modelType }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div
|
||||
v-else-if="status === 'error'"
|
||||
class="flex flex-1 flex-col items-center justify-center gap-6"
|
||||
>
|
||||
<i class="icon-[lucide--x-circle] text-6xl text-error" />
|
||||
<div class="text-center">
|
||||
<p class="m-0 text-sm font-bold">
|
||||
{{ $t('assetBrowser.uploadFailed') }}
|
||||
</p>
|
||||
<p v-if="error" class="text-sm text-muted mb-0">
|
||||
{{ error }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
defineProps<{
|
||||
status: 'idle' | 'uploading' | 'success' | 'error'
|
||||
error?: string
|
||||
metadata: AssetMetadata | null
|
||||
modelType: string | undefined
|
||||
}>()
|
||||
</script>
|
||||
49
src/platform/assets/components/UploadModelUrlInput.vue
Normal file
49
src/platform/assets/components/UploadModelUrlInput.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-sm text-muted m-0">
|
||||
{{ $t('assetBrowser.uploadModelDescription1') }}
|
||||
</p>
|
||||
<ul class="list-disc space-y-1 pl-5 mt-0 text-sm text-muted">
|
||||
<li>{{ $t('assetBrowser.uploadModelDescription2') }}</li>
|
||||
<li>{{ $t('assetBrowser.uploadModelDescription3') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm text-muted mb-0">
|
||||
{{ $t('assetBrowser.civitaiLinkLabel') }}
|
||||
</label>
|
||||
<InputText
|
||||
v-model="url"
|
||||
:placeholder="$t('assetBrowser.civitaiLinkPlaceholder')"
|
||||
class="w-full"
|
||||
/>
|
||||
<p v-if="error" class="text-xs text-error">
|
||||
{{ error }}
|
||||
</p>
|
||||
<p v-else class="text-xs text-muted">
|
||||
{{ $t('assetBrowser.civitaiLinkExample') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import InputText from 'primevue/inputtext'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
error?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const url = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: string) => emit('update:modelValue', value)
|
||||
})
|
||||
</script>
|
||||
73
src/platform/assets/composables/useModelTypes.ts
Normal file
73
src/platform/assets/composables/useModelTypes.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { createSharedComposable, useAsyncState } from '@vueuse/core'
|
||||
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
/**
|
||||
* Format folder name to display name
|
||||
* Converts "upscale_models" -> "Upscale Models"
|
||||
* Converts "loras" -> "LoRAs"
|
||||
*/
|
||||
function formatDisplayName(folderName: string): string {
|
||||
// Special cases for acronyms and proper nouns
|
||||
const specialCases: Record<string, string> = {
|
||||
loras: 'LoRAs',
|
||||
ipadapter: 'IP-Adapter',
|
||||
sams: 'SAMs',
|
||||
clip_vision: 'CLIP Vision',
|
||||
animatediff_motion_lora: 'AnimateDiff Motion LoRA',
|
||||
animatediff_models: 'AnimateDiff Models',
|
||||
vae: 'VAE',
|
||||
sam2: 'SAM 2',
|
||||
controlnet: 'ControlNet',
|
||||
gligen: 'GLIGEN'
|
||||
}
|
||||
|
||||
if (specialCases[folderName]) {
|
||||
return specialCases[folderName]
|
||||
}
|
||||
|
||||
return folderName
|
||||
.split('_')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
interface ModelTypeOption {
|
||||
name: string // Display name
|
||||
value: string // Actual tag value
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for fetching and managing model types from the API
|
||||
* Uses shared state to ensure data is only fetched once
|
||||
*/
|
||||
export const useModelTypes = createSharedComposable(() => {
|
||||
const {
|
||||
state: modelTypes,
|
||||
isLoading,
|
||||
error,
|
||||
execute: fetchModelTypes
|
||||
} = useAsyncState(
|
||||
async (): Promise<ModelTypeOption[]> => {
|
||||
const response = await api.getModelFolders()
|
||||
return response.map((folder) => ({
|
||||
name: formatDisplayName(folder.name),
|
||||
value: folder.name
|
||||
}))
|
||||
},
|
||||
[] as ModelTypeOption[],
|
||||
{
|
||||
immediate: false,
|
||||
onError: (err) => {
|
||||
console.error('Failed to fetch model types:', err)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
modelTypes,
|
||||
isLoading,
|
||||
error,
|
||||
fetchModelTypes
|
||||
}
|
||||
})
|
||||
184
src/platform/assets/composables/useUploadModelWizard.ts
Normal file
184
src/platform/assets/composables/useUploadModelWizard.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { st } from '@/i18n'
|
||||
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
|
||||
interface WizardData {
|
||||
url: string
|
||||
metadata: AssetMetadata | null
|
||||
name: string
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
interface ModelTypeOption {
|
||||
name: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
|
||||
const currentStep = ref(1)
|
||||
const isFetchingMetadata = ref(false)
|
||||
const isUploading = ref(false)
|
||||
const uploadStatus = ref<'idle' | 'uploading' | 'success' | 'error'>('idle')
|
||||
const uploadError = ref('')
|
||||
|
||||
const wizardData = ref<WizardData>({
|
||||
url: '',
|
||||
metadata: null,
|
||||
name: '',
|
||||
tags: []
|
||||
})
|
||||
|
||||
const selectedModelType = ref<string | undefined>(undefined)
|
||||
|
||||
// Clear error when URL changes
|
||||
watch(
|
||||
() => wizardData.value.url,
|
||||
() => {
|
||||
uploadError.value = ''
|
||||
}
|
||||
)
|
||||
|
||||
// Validation
|
||||
const canFetchMetadata = computed(() => {
|
||||
return wizardData.value.url.trim().length > 0
|
||||
})
|
||||
|
||||
const canUploadModel = computed(() => {
|
||||
return !!selectedModelType.value
|
||||
})
|
||||
|
||||
function isCivitaiUrl(url: string): boolean {
|
||||
try {
|
||||
const hostname = new URL(url).hostname.toLowerCase()
|
||||
return hostname === 'civitai.com' || hostname.endsWith('.civitai.com')
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMetadata() {
|
||||
if (!canFetchMetadata.value) return
|
||||
|
||||
// Clean and normalize URL
|
||||
let cleanedUrl = wizardData.value.url.trim()
|
||||
try {
|
||||
cleanedUrl = new URL(encodeURI(cleanedUrl)).toString()
|
||||
} catch {
|
||||
// If URL parsing fails, just use the trimmed input
|
||||
}
|
||||
wizardData.value.url = cleanedUrl
|
||||
|
||||
if (!isCivitaiUrl(wizardData.value.url)) {
|
||||
uploadError.value = st(
|
||||
'assetBrowser.onlyCivitaiUrlsSupported',
|
||||
'Only Civitai URLs are supported'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
isFetchingMetadata.value = true
|
||||
try {
|
||||
const metadata = await assetService.getAssetMetadata(wizardData.value.url)
|
||||
wizardData.value.metadata = metadata
|
||||
|
||||
// Pre-fill name from metadata
|
||||
wizardData.value.name = metadata.filename || metadata.name || ''
|
||||
|
||||
// Pre-fill model type from metadata tags if available
|
||||
if (metadata.tags && metadata.tags.length > 0) {
|
||||
wizardData.value.tags = metadata.tags
|
||||
// Try to detect model type from tags
|
||||
const typeTag = metadata.tags.find((tag) =>
|
||||
modelTypes.value.some((type) => type.value === tag)
|
||||
)
|
||||
if (typeTag) {
|
||||
selectedModelType.value = typeTag
|
||||
}
|
||||
}
|
||||
|
||||
currentStep.value = 2
|
||||
} catch (error) {
|
||||
console.error('Failed to retrieve metadata:', error)
|
||||
uploadError.value =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: st(
|
||||
'assetBrowser.uploadModelFailedToRetrieveMetadata',
|
||||
'Failed to retrieve metadata. Please check the link and try again.'
|
||||
)
|
||||
currentStep.value = 1
|
||||
} finally {
|
||||
isFetchingMetadata.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadModel() {
|
||||
if (!canUploadModel.value) return
|
||||
|
||||
isUploading.value = true
|
||||
uploadStatus.value = 'uploading'
|
||||
|
||||
try {
|
||||
const tags = selectedModelType.value
|
||||
? ['models', selectedModelType.value]
|
||||
: ['models']
|
||||
const filename =
|
||||
wizardData.value.metadata?.filename ||
|
||||
wizardData.value.metadata?.name ||
|
||||
'model'
|
||||
|
||||
await assetService.uploadAssetFromUrl({
|
||||
url: wizardData.value.url,
|
||||
name: filename,
|
||||
tags,
|
||||
user_metadata: {
|
||||
source: 'civitai',
|
||||
source_url: wizardData.value.url,
|
||||
model_type: selectedModelType.value
|
||||
}
|
||||
})
|
||||
|
||||
uploadStatus.value = 'success'
|
||||
currentStep.value = 3
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Failed to upload asset:', error)
|
||||
uploadStatus.value = 'error'
|
||||
uploadError.value =
|
||||
error instanceof Error ? error.message : 'Failed to upload model'
|
||||
currentStep.value = 3
|
||||
return false
|
||||
} finally {
|
||||
isUploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goToPreviousStep() {
|
||||
if (currentStep.value > 1) {
|
||||
currentStep.value = currentStep.value - 1
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
currentStep,
|
||||
isFetchingMetadata,
|
||||
isUploading,
|
||||
uploadStatus,
|
||||
uploadError,
|
||||
wizardData,
|
||||
selectedModelType,
|
||||
|
||||
// Computed
|
||||
canFetchMetadata,
|
||||
canUploadModel,
|
||||
|
||||
// Actions
|
||||
fetchMetadata,
|
||||
uploadModel,
|
||||
goToPreviousStep
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,29 @@ const zModelFile = z.object({
|
||||
pathIndex: z.number()
|
||||
})
|
||||
|
||||
const zValidationError = z.object({
|
||||
code: z.string(),
|
||||
message: z.string(),
|
||||
field: z.string()
|
||||
})
|
||||
|
||||
const zValidationResult = z.object({
|
||||
is_valid: z.boolean(),
|
||||
errors: z.array(zValidationError).optional(),
|
||||
warnings: z.array(zValidationError).optional()
|
||||
})
|
||||
|
||||
const zAssetMetadata = z.object({
|
||||
content_length: z.number(),
|
||||
final_url: z.string(),
|
||||
content_type: z.string().optional(),
|
||||
filename: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
preview_url: z.string().optional(),
|
||||
validation: zValidationResult.optional()
|
||||
})
|
||||
|
||||
// Filename validation schema
|
||||
export const assetFilenameSchema = z
|
||||
.string()
|
||||
@@ -48,6 +71,7 @@ export const assetResponseSchema = zAssetResponse
|
||||
// Export types derived from Zod schemas
|
||||
export type AssetItem = z.infer<typeof zAsset>
|
||||
export type AssetResponse = z.infer<typeof zAssetResponse>
|
||||
export type AssetMetadata = z.infer<typeof zAssetMetadata>
|
||||
export type ModelFolder = z.infer<typeof zModelFolder>
|
||||
export type ModelFile = z.infer<typeof zModelFile>
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { fromZodError } from 'zod-validation-error'
|
||||
|
||||
import { st } from '@/i18n'
|
||||
import { assetResponseSchema } from '@/platform/assets/schemas/assetSchema'
|
||||
import type {
|
||||
AssetItem,
|
||||
AssetMetadata,
|
||||
AssetResponse,
|
||||
ModelFile,
|
||||
ModelFolder
|
||||
@@ -10,6 +12,36 @@ import type {
|
||||
import { api } from '@/scripts/api'
|
||||
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
|
||||
|
||||
/**
|
||||
* Maps CivitAI validation error codes to localized error messages
|
||||
*/
|
||||
function getLocalizedErrorMessage(errorCode: string): string {
|
||||
const errorMessages: Record<string, string> = {
|
||||
FILE_TOO_LARGE: st('assetBrowser.errorFileTooLarge', 'File too large'),
|
||||
FORMAT_NOT_ALLOWED: st(
|
||||
'assetBrowser.errorFormatNotAllowed',
|
||||
'Format not allowed'
|
||||
),
|
||||
UNSAFE_PICKLE_SCAN: st(
|
||||
'assetBrowser.errorUnsafePickleScan',
|
||||
'Unsafe pickle scan'
|
||||
),
|
||||
UNSAFE_VIRUS_SCAN: st(
|
||||
'assetBrowser.errorUnsafeVirusScan',
|
||||
'Unsafe virus scan'
|
||||
),
|
||||
MODEL_TYPE_NOT_SUPPORTED: st(
|
||||
'assetBrowser.errorModelTypeNotSupported',
|
||||
'Model type not supported'
|
||||
)
|
||||
}
|
||||
return (
|
||||
errorMessages[errorCode] ||
|
||||
st('assetBrowser.errorUnknown', 'Unknown error') ||
|
||||
'Unknown error'
|
||||
)
|
||||
}
|
||||
|
||||
const ASSETS_ENDPOINT = '/assets'
|
||||
const EXPERIMENTAL_WARNING = `EXPERIMENTAL: If you are seeing this please make sure "Comfy.Assets.UseAssetAPI" is set to "false" in your ComfyUI Settings.\n`
|
||||
const DEFAULT_LIMIT = 500
|
||||
@@ -249,6 +281,77 @@ function createAssetService() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves metadata from a download URL without downloading the file
|
||||
*
|
||||
* @param url - Download URL to retrieve metadata from (will be URL-encoded)
|
||||
* @returns Promise with metadata including content_length, final_url, filename, etc.
|
||||
* @throws Error if metadata retrieval fails
|
||||
*/
|
||||
async function getAssetMetadata(url: string): Promise<AssetMetadata> {
|
||||
const encodedUrl = encodeURIComponent(url)
|
||||
const res = await api.fetchApi(
|
||||
`${ASSETS_ENDPOINT}/remote-metadata?url=${encodedUrl}`
|
||||
)
|
||||
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json().catch(() => ({}))
|
||||
throw new Error(
|
||||
getLocalizedErrorMessage(errorData.code || 'UNKNOWN_ERROR')
|
||||
)
|
||||
}
|
||||
|
||||
const data: AssetMetadata = await res.json()
|
||||
if (data.validation?.is_valid === false) {
|
||||
throw new Error(
|
||||
getLocalizedErrorMessage(
|
||||
data.validation?.errors?.[0]?.code || 'UNKNOWN_ERROR'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads an asset by providing a URL to download from
|
||||
*
|
||||
* @param params - Upload parameters
|
||||
* @param params.url - HTTP/HTTPS URL to download from
|
||||
* @param params.name - Display name (determines extension)
|
||||
* @param params.tags - Optional freeform tags
|
||||
* @param params.user_metadata - Optional custom metadata object
|
||||
* @param params.preview_id - Optional UUID for preview asset
|
||||
* @returns Promise<AssetItem & { created_new: boolean }> - Asset object with created_new flag
|
||||
* @throws Error if upload fails
|
||||
*/
|
||||
async function uploadAssetFromUrl(params: {
|
||||
url: string
|
||||
name: string
|
||||
tags?: string[]
|
||||
user_metadata?: Record<string, any>
|
||||
preview_id?: string
|
||||
}): Promise<AssetItem & { created_new: boolean }> {
|
||||
const res = await api.fetchApi(ASSETS_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(params)
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
st(
|
||||
'assetBrowser.errorUploadFailed',
|
||||
'Failed to upload asset. Please try again.'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return await res.json()
|
||||
}
|
||||
|
||||
return {
|
||||
getAssetModelFolders,
|
||||
getAssetModels,
|
||||
@@ -256,7 +359,9 @@ function createAssetService() {
|
||||
getAssetsForNodeType,
|
||||
getAssetDetails,
|
||||
getAssetsByTag,
|
||||
deleteAsset
|
||||
deleteAsset,
|
||||
getAssetMetadata,
|
||||
uploadAssetFromUrl
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import type { InjectionKey } from 'vue'
|
||||
|
||||
import type { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
|
||||
|
||||
/**
|
||||
* Lightweight, injectable transform state used by layout-aware components.
|
||||
*
|
||||
* Consumers use this interface to convert coordinates between LiteGraph's
|
||||
* canvas space and the DOM's screen space, access the current pan/zoom
|
||||
* (camera), and perform basic viewport culling checks.
|
||||
*
|
||||
* Coordinate mapping:
|
||||
* - screen = (canvas + offset) * scale
|
||||
* - canvas = screen / scale - offset
|
||||
*
|
||||
* The full implementation and additional helpers live in
|
||||
* `useTransformState()`. This interface deliberately exposes only the
|
||||
* minimal surface needed outside that composable.
|
||||
*
|
||||
* @example
|
||||
* const state = inject(TransformStateKey)!
|
||||
* const screen = state.canvasToScreen({ x: 100, y: 50 })
|
||||
*/
|
||||
export interface TransformState
|
||||
extends Pick<
|
||||
ReturnType<typeof useTransformState>,
|
||||
'screenToCanvas' | 'canvasToScreen' | 'camera' | 'isNodeInViewport'
|
||||
> {}
|
||||
|
||||
export const TransformStateKey: InjectionKey<TransformState> =
|
||||
Symbol('transformState')
|
||||
@@ -17,10 +17,9 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRafFn } from '@vueuse/core'
|
||||
import { computed, provide } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
|
||||
import { TransformStateKey } from '@/renderer/core/layout/injectionKeys'
|
||||
import { useTransformSettling } from '@/renderer/core/layout/transform/useTransformSettling'
|
||||
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
|
||||
import { useLOD } from '@/renderer/extensions/vueNodes/lod/useLOD'
|
||||
@@ -32,14 +31,7 @@ interface TransformPaneProps {
|
||||
|
||||
const props = defineProps<TransformPaneProps>()
|
||||
|
||||
const {
|
||||
camera,
|
||||
transformStyle,
|
||||
syncWithCanvas,
|
||||
canvasToScreen,
|
||||
screenToCanvas,
|
||||
isNodeInViewport
|
||||
} = useTransformState()
|
||||
const { camera, transformStyle, syncWithCanvas } = useTransformState()
|
||||
|
||||
const { isLOD } = useLOD(camera)
|
||||
|
||||
@@ -48,13 +40,6 @@ const { isTransforming: isInteracting } = useTransformSettling(canvasElement, {
|
||||
settleDelay: 512
|
||||
})
|
||||
|
||||
provide(TransformStateKey, {
|
||||
camera,
|
||||
canvasToScreen,
|
||||
screenToCanvas,
|
||||
isNodeInViewport
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
transformUpdate: []
|
||||
}>()
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
import { computed, reactive, readonly } from 'vue'
|
||||
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
|
||||
interface Point {
|
||||
x: number
|
||||
@@ -64,7 +65,7 @@ interface Camera {
|
||||
z: number // scale/zoom
|
||||
}
|
||||
|
||||
export const useTransformState = () => {
|
||||
function useTransformStateIndividual() {
|
||||
// Reactive state mirroring LiteGraph's canvas transform
|
||||
const camera = reactive<Camera>({
|
||||
x: 0,
|
||||
@@ -91,7 +92,7 @@ export const useTransformState = () => {
|
||||
*
|
||||
* @param canvas - LiteGraph canvas instance with DragAndScale (ds) transform state
|
||||
*/
|
||||
const syncWithCanvas = (canvas: LGraphCanvas) => {
|
||||
function syncWithCanvas(canvas: LGraphCanvas) {
|
||||
if (!canvas || !canvas.ds) return
|
||||
|
||||
// Mirror LiteGraph's transform state to Vue's reactive state
|
||||
@@ -112,7 +113,7 @@ export const useTransformState = () => {
|
||||
* @param point - Point in canvas coordinate system
|
||||
* @returns Point in screen coordinate system
|
||||
*/
|
||||
const canvasToScreen = (point: Point): Point => {
|
||||
function canvasToScreen(point: Point): Point {
|
||||
return {
|
||||
x: (point.x + camera.x) * camera.z,
|
||||
y: (point.y + camera.y) * camera.z
|
||||
@@ -138,10 +139,10 @@ export const useTransformState = () => {
|
||||
}
|
||||
|
||||
// Get node's screen bounds for culling
|
||||
const getNodeScreenBounds = (
|
||||
pos: ArrayLike<number>,
|
||||
size: ArrayLike<number>
|
||||
): DOMRect => {
|
||||
function getNodeScreenBounds(
|
||||
pos: [number, number],
|
||||
size: [number, number]
|
||||
): DOMRect {
|
||||
const topLeft = canvasToScreen({ x: pos[0], y: pos[1] })
|
||||
const width = size[0] * camera.z
|
||||
const height = size[1] * camera.z
|
||||
@@ -150,23 +151,23 @@ export const useTransformState = () => {
|
||||
}
|
||||
|
||||
// Helper: Calculate zoom-adjusted margin for viewport culling
|
||||
const calculateAdjustedMargin = (baseMargin: number): number => {
|
||||
function calculateAdjustedMargin(baseMargin: number): number {
|
||||
if (camera.z < 0.1) return Math.min(baseMargin * 5, 2.0)
|
||||
if (camera.z > 3.0) return Math.max(baseMargin * 0.5, 0.05)
|
||||
return baseMargin
|
||||
}
|
||||
|
||||
// Helper: Check if node is too small to be visible at current zoom
|
||||
const isNodeTooSmall = (nodeSize: ArrayLike<number>): boolean => {
|
||||
function isNodeTooSmall(nodeSize: [number, number]): boolean {
|
||||
const nodeScreenSize = Math.max(nodeSize[0], nodeSize[1]) * camera.z
|
||||
return nodeScreenSize < 4
|
||||
}
|
||||
|
||||
// Helper: Calculate expanded viewport bounds with margin
|
||||
const getExpandedViewportBounds = (
|
||||
function getExpandedViewportBounds(
|
||||
viewport: { width: number; height: number },
|
||||
margin: number
|
||||
) => {
|
||||
) {
|
||||
const marginX = viewport.width * margin
|
||||
const marginY = viewport.height * margin
|
||||
return {
|
||||
@@ -178,11 +179,11 @@ export const useTransformState = () => {
|
||||
}
|
||||
|
||||
// Helper: Test if node intersects with viewport bounds
|
||||
const testViewportIntersection = (
|
||||
function testViewportIntersection(
|
||||
screenPos: { x: number; y: number },
|
||||
nodeSize: ArrayLike<number>,
|
||||
nodeSize: [number, number],
|
||||
bounds: { left: number; right: number; top: number; bottom: number }
|
||||
): boolean => {
|
||||
): boolean {
|
||||
const nodeRight = screenPos.x + nodeSize[0] * camera.z
|
||||
const nodeBottom = screenPos.y + nodeSize[1] * camera.z
|
||||
|
||||
@@ -195,12 +196,12 @@ export const useTransformState = () => {
|
||||
}
|
||||
|
||||
// Check if node is within viewport with frustum and size-based culling
|
||||
const isNodeInViewport = (
|
||||
nodePos: ArrayLike<number>,
|
||||
nodeSize: ArrayLike<number>,
|
||||
function isNodeInViewport(
|
||||
nodePos: [number, number],
|
||||
nodeSize: [number, number],
|
||||
viewport: { width: number; height: number },
|
||||
margin: number = 0.2
|
||||
): boolean => {
|
||||
): boolean {
|
||||
// Early exit for tiny nodes
|
||||
if (isNodeTooSmall(nodeSize)) return false
|
||||
|
||||
@@ -212,10 +213,10 @@ export const useTransformState = () => {
|
||||
}
|
||||
|
||||
// Get viewport bounds in canvas coordinates (for spatial index queries)
|
||||
const getViewportBounds = (
|
||||
function getViewportBounds(
|
||||
viewport: { width: number; height: number },
|
||||
margin: number = 0.2
|
||||
) => {
|
||||
) {
|
||||
const marginX = viewport.width * margin
|
||||
const marginY = viewport.height * margin
|
||||
|
||||
@@ -244,3 +245,7 @@ export const useTransformState = () => {
|
||||
getViewportBounds
|
||||
}
|
||||
}
|
||||
|
||||
export const useTransformState = createSharedComposable(
|
||||
useTransformStateIndividual
|
||||
)
|
||||
|
||||
@@ -11,9 +11,9 @@ interface SpatialBounds {
|
||||
height: number
|
||||
}
|
||||
|
||||
interface PositionedNode {
|
||||
pos: ArrayLike<number>
|
||||
size: ArrayLike<number>
|
||||
export interface PositionedNode {
|
||||
pos: [number, number]
|
||||
size: [number, number]
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { calculateNodeBounds } from '@/renderer/core/spatial/boundsCalculator'
|
||||
import type { PositionedNode } from '@/renderer/core/spatial/boundsCalculator'
|
||||
|
||||
import type {
|
||||
IMinimapDataSource,
|
||||
@@ -29,10 +30,12 @@ export abstract class AbstractMinimapDataSource implements IMinimapDataSource {
|
||||
}
|
||||
|
||||
// Convert MinimapNodeData to the format expected by calculateNodeBounds
|
||||
const compatibleNodes = nodes.map((node) => ({
|
||||
pos: [node.x, node.y],
|
||||
size: [node.width, node.height]
|
||||
}))
|
||||
const compatibleNodes = nodes.map(
|
||||
(node): PositionedNode => ({
|
||||
pos: [node.x, node.y],
|
||||
size: [node.width, node.height]
|
||||
})
|
||||
)
|
||||
|
||||
const bounds = calculateNodeBounds(compatibleNodes)
|
||||
if (!bounds) {
|
||||
|
||||
@@ -19,12 +19,12 @@
|
||||
'outline-transparent outline-2',
|
||||
borderClass,
|
||||
outlineClass,
|
||||
cursorClass,
|
||||
{
|
||||
'before:rounded-2xl before:pointer-events-none before:absolute before:bg-bypass/60 before:inset-0':
|
||||
bypassed,
|
||||
'before:rounded-2xl before:pointer-events-none before:absolute before:inset-0':
|
||||
muted,
|
||||
'will-change-transform': isDragging,
|
||||
'ring-4 ring-primary-500 bg-primary-500/10': isDraggingOver
|
||||
},
|
||||
|
||||
@@ -39,10 +39,10 @@
|
||||
zIndex: zIndex,
|
||||
opacity: nodeOpacity,
|
||||
'--component-node-background': nodeBodyBackgroundColor
|
||||
},
|
||||
dragStyle
|
||||
}
|
||||
]"
|
||||
v-bind="pointerHandlers"
|
||||
v-bind="remainingPointerHandlers"
|
||||
@pointerdown="nodeOnPointerdown"
|
||||
@wheel="handleWheel"
|
||||
@contextmenu="handleContextMenu"
|
||||
@dragover.prevent="handleDragOver"
|
||||
@@ -137,24 +137,31 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, inject, onErrorCaptured, onMounted, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onErrorCaptured, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { toggleNodeOptions } from '@/composables/graph/useMoreOptionsMenu'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { st } from '@/i18n'
|
||||
import { LGraphEventMode, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
LGraphCanvas,
|
||||
LGraphEventMode,
|
||||
LiteGraph
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
|
||||
import { TransformStateKey } from '@/renderer/core/layout/injectionKeys'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
|
||||
import SlotConnectionDot from '@/renderer/extensions/vueNodes/components/SlotConnectionDot.vue'
|
||||
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
|
||||
import { useNodePointerInteractions } from '@/renderer/extensions/vueNodes/composables/useNodePointerInteractions'
|
||||
import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNodeZIndex'
|
||||
import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking'
|
||||
import { useNodeExecutionState } from '@/renderer/extensions/vueNodes/execution/useNodeExecutionState'
|
||||
import { useNodeDrag } from '@/renderer/extensions/vueNodes/layout/useNodeDrag'
|
||||
import { useNodeLayout } from '@/renderer/extensions/vueNodes/layout/useNodeLayout'
|
||||
import { useNodePreviewState } from '@/renderer/extensions/vueNodes/preview/useNodePreviewState'
|
||||
import { nonWidgetedInputs } from '@/renderer/extensions/vueNodes/utils/nodeDataUtils'
|
||||
@@ -188,16 +195,13 @@ const { nodeData, error = null } = defineProps<LGraphNodeProps>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const {
|
||||
handleNodeCollapse,
|
||||
handleNodeTitleUpdate,
|
||||
handleNodeSelect,
|
||||
handleNodeRightClick
|
||||
} = useNodeEventHandlers()
|
||||
const { handleNodeCollapse, handleNodeTitleUpdate, handleNodeRightClick } =
|
||||
useNodeEventHandlers()
|
||||
const { bringNodeToFront } = useNodeZIndex()
|
||||
|
||||
useVueElementTracking(() => nodeData.id, 'node')
|
||||
|
||||
const transformState = inject(TransformStateKey)
|
||||
const transformState = useTransformState()
|
||||
if (!transformState) {
|
||||
throw new Error(
|
||||
'TransformState must be provided for node resize functionality'
|
||||
@@ -272,10 +276,24 @@ onErrorCaptured((error) => {
|
||||
})
|
||||
|
||||
const { position, size, zIndex, moveNodeTo } = useNodeLayout(() => nodeData.id)
|
||||
const { pointerHandlers, isDragging, dragStyle } = useNodePointerInteractions(
|
||||
() => nodeData,
|
||||
handleNodeSelect
|
||||
)
|
||||
const { pointerHandlers } = useNodePointerInteractions(() => nodeData.id)
|
||||
const { onPointerdown, ...remainingPointerHandlers } = pointerHandlers
|
||||
const { startDrag } = useNodeDrag()
|
||||
|
||||
async function nodeOnPointerdown(event: PointerEvent) {
|
||||
if (event.altKey && lgraphNode.value) {
|
||||
const result = LGraphCanvas.cloneNodes([lgraphNode.value])
|
||||
if (result?.created?.length) {
|
||||
const [newNode] = result.created
|
||||
startDrag(event, `${newNode.id}`)
|
||||
layoutStore.isDraggingVueNodes.value = true
|
||||
await nextTick()
|
||||
bringNodeToFront(`${newNode.id}`)
|
||||
return
|
||||
}
|
||||
}
|
||||
onPointerdown(event)
|
||||
}
|
||||
|
||||
// Handle right-click context menu
|
||||
const handleContextMenu = (event: MouseEvent) => {
|
||||
@@ -283,7 +301,7 @@ const handleContextMenu = (event: MouseEvent) => {
|
||||
event.stopPropagation()
|
||||
|
||||
// First handle the standard right-click behavior (selection)
|
||||
handleNodeRightClick(event as PointerEvent, nodeData)
|
||||
handleNodeRightClick(event as PointerEvent, nodeData.id)
|
||||
|
||||
// Show the node options menu at the cursor position
|
||||
const targetElement = event.currentTarget as HTMLElement
|
||||
@@ -422,6 +440,16 @@ const outlineClass = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
const cursorClass = computed(() => {
|
||||
return cn(
|
||||
nodeData.flags?.pinned
|
||||
? 'cursor-default'
|
||||
: layoutStore.isDraggingVueNodes.value
|
||||
? 'cursor-grabbing'
|
||||
: 'cursor-grab'
|
||||
)
|
||||
})
|
||||
|
||||
// Event handlers
|
||||
const handleCollapse = () => {
|
||||
handleNodeCollapse(nodeData.id, !isCollapsed.value)
|
||||
|
||||
@@ -10,12 +10,12 @@
|
||||
*/
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
|
||||
import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNodeZIndex'
|
||||
import { isMultiSelectKey } from '@/renderer/extensions/vueNodes/utils/selectionUtils'
|
||||
import type { NodeId } from '@/renderer/core/layout/types'
|
||||
|
||||
function useNodeEventHandlersIndividual() {
|
||||
const canvasStore = useCanvasStore()
|
||||
@@ -27,12 +27,12 @@ function useNodeEventHandlersIndividual() {
|
||||
* Handle node selection events
|
||||
* Supports single selection and multi-select with Ctrl/Cmd
|
||||
*/
|
||||
const handleNodeSelect = (event: PointerEvent, nodeData: VueNodeData) => {
|
||||
function handleNodeSelect(event: PointerEvent, nodeId: NodeId) {
|
||||
if (!shouldHandleNodePointerEvents.value) return
|
||||
|
||||
if (!canvasStore.canvas || !nodeManager.value) return
|
||||
|
||||
const node = nodeManager.value.getNode(nodeData.id)
|
||||
const node = nodeManager.value.getNode(nodeId)
|
||||
if (!node) return
|
||||
|
||||
const multiSelect = isMultiSelectKey(event)
|
||||
@@ -53,7 +53,7 @@ function useNodeEventHandlersIndividual() {
|
||||
// Bring node to front when clicked (similar to LiteGraph behavior)
|
||||
// Skip if node is pinned to avoid unwanted movement
|
||||
if (!node.flags?.pinned) {
|
||||
bringNodeToFront(nodeData.id)
|
||||
bringNodeToFront(nodeId)
|
||||
}
|
||||
|
||||
// Update canvas selection tracking
|
||||
@@ -64,7 +64,7 @@ function useNodeEventHandlersIndividual() {
|
||||
* Handle node collapse/expand state changes
|
||||
* Uses LiteGraph's native collapse method for proper state management
|
||||
*/
|
||||
const handleNodeCollapse = (nodeId: string, collapsed: boolean) => {
|
||||
function handleNodeCollapse(nodeId: NodeId, collapsed: boolean) {
|
||||
if (!shouldHandleNodePointerEvents.value) return
|
||||
|
||||
if (!nodeManager.value) return
|
||||
@@ -83,7 +83,7 @@ function useNodeEventHandlersIndividual() {
|
||||
* Handle node title updates
|
||||
* Updates the title in LiteGraph for persistence across sessions
|
||||
*/
|
||||
const handleNodeTitleUpdate = (nodeId: string, newTitle: string) => {
|
||||
function handleNodeTitleUpdate(nodeId: NodeId, newTitle: string) {
|
||||
if (!shouldHandleNodePointerEvents.value) return
|
||||
|
||||
if (!nodeManager.value) return
|
||||
@@ -95,41 +95,16 @@ function useNodeEventHandlersIndividual() {
|
||||
node.title = newTitle
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle node double-click events
|
||||
* Can be used for custom actions like opening node editor
|
||||
*/
|
||||
const handleNodeDoubleClick = (
|
||||
event: PointerEvent,
|
||||
nodeData: VueNodeData
|
||||
) => {
|
||||
if (!shouldHandleNodePointerEvents.value) return
|
||||
|
||||
if (!canvasStore.canvas || !nodeManager.value) return
|
||||
|
||||
const node = nodeManager.value.getNode(nodeData.id)
|
||||
if (!node) return
|
||||
|
||||
// Prevent default browser behavior
|
||||
event.preventDefault()
|
||||
|
||||
// TODO: add custom double-click behavior here
|
||||
// For now, ensure node is selected
|
||||
if (!node.selected) {
|
||||
handleNodeSelect(event, nodeData)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle node right-click context menu events
|
||||
* Integrates with LiteGraph's context menu system
|
||||
*/
|
||||
const handleNodeRightClick = (event: PointerEvent, nodeData: VueNodeData) => {
|
||||
function handleNodeRightClick(event: PointerEvent, nodeId: NodeId) {
|
||||
if (!shouldHandleNodePointerEvents.value) return
|
||||
|
||||
if (!canvasStore.canvas || !nodeManager.value) return
|
||||
|
||||
const node = nodeManager.value.getNode(nodeData.id)
|
||||
const node = nodeManager.value.getNode(nodeId)
|
||||
if (!node) return
|
||||
|
||||
// Prevent default context menu
|
||||
@@ -137,128 +112,17 @@ function useNodeEventHandlersIndividual() {
|
||||
|
||||
// Select the node if not already selected
|
||||
if (!node.selected) {
|
||||
handleNodeSelect(event, nodeData)
|
||||
handleNodeSelect(event, nodeId)
|
||||
}
|
||||
|
||||
// Let LiteGraph handle the context menu
|
||||
// The canvas will handle showing the appropriate context menu
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle node drag start events
|
||||
* Prepares node for dragging and sets appropriate visual state
|
||||
*/
|
||||
const handleNodeDragStart = (event: DragEvent, nodeData: VueNodeData) => {
|
||||
if (!shouldHandleNodePointerEvents.value) return
|
||||
|
||||
if (!canvasStore.canvas || !nodeManager.value) return
|
||||
|
||||
const node = nodeManager.value.getNode(nodeData.id)
|
||||
if (!node) return
|
||||
|
||||
// Ensure node is selected before dragging
|
||||
if (!node.selected) {
|
||||
// Create a synthetic pointer event for selection
|
||||
const syntheticEvent = new PointerEvent('pointerdown', {
|
||||
ctrlKey: event.ctrlKey,
|
||||
metaKey: event.metaKey,
|
||||
bubbles: true
|
||||
})
|
||||
handleNodeSelect(syntheticEvent, nodeData)
|
||||
}
|
||||
|
||||
// Set drag data for potential drop operations
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.setData('application/comfy-node-id', nodeData.id)
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch select multiple nodes
|
||||
* Useful for selection toolbox or area selection
|
||||
*/
|
||||
const selectNodes = (nodeIds: string[], addToSelection = false) => {
|
||||
if (!shouldHandleNodePointerEvents.value) return
|
||||
|
||||
if (!canvasStore.canvas || !nodeManager.value) return
|
||||
|
||||
if (!addToSelection) {
|
||||
canvasStore.canvas.deselectAll()
|
||||
}
|
||||
|
||||
nodeIds.forEach((nodeId) => {
|
||||
const node = nodeManager.value?.getNode(nodeId)
|
||||
if (node && canvasStore.canvas) {
|
||||
canvasStore.canvas.select(node)
|
||||
}
|
||||
})
|
||||
|
||||
canvasStore.updateSelectedItems()
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure node is selected for shift-drag operations
|
||||
* Handles special logic for promoting a node to selection when shift-dragging
|
||||
* @param event - The pointer event (for multi-select key detection)
|
||||
* @param nodeData - The node data for the node being dragged
|
||||
* @param wasSelectedAtPointerDown - Whether the node was selected when pointer-down occurred
|
||||
*/
|
||||
const ensureNodeSelectedForShiftDrag = (
|
||||
event: PointerEvent,
|
||||
nodeData: VueNodeData,
|
||||
wasSelectedAtPointerDown: boolean
|
||||
) => {
|
||||
if (wasSelectedAtPointerDown) return
|
||||
|
||||
const multiSelectKeyPressed = isMultiSelectKey(event)
|
||||
if (!multiSelectKeyPressed) return
|
||||
|
||||
if (!canvasStore.canvas || !nodeManager.value) return
|
||||
const node = nodeManager.value.getNode(nodeData.id)
|
||||
if (!node || node.selected) return
|
||||
|
||||
const selectionCount = canvasStore.selectedItems.length
|
||||
const addToSelection = selectionCount > 0
|
||||
selectNodes([nodeData.id], addToSelection)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deselect specific nodes
|
||||
*/
|
||||
const deselectNodes = (nodeIds: string[]) => {
|
||||
if (!shouldHandleNodePointerEvents.value) return
|
||||
|
||||
if (!canvasStore.canvas || !nodeManager.value) return
|
||||
|
||||
nodeIds.forEach((nodeId) => {
|
||||
const node = nodeManager.value?.getNode(nodeId)
|
||||
if (node && canvasStore.canvas) {
|
||||
canvasStore.canvas.deselect(node)
|
||||
}
|
||||
})
|
||||
|
||||
canvasStore.updateSelectedItems()
|
||||
}
|
||||
|
||||
const deselectNode = (nodeId: string) => {
|
||||
const node = nodeManager.value?.getNode(nodeId)
|
||||
if (node) {
|
||||
canvasStore.canvas?.deselect(node)
|
||||
canvasStore.updateSelectedItems()
|
||||
}
|
||||
}
|
||||
|
||||
const toggleNodeSelectionAfterPointerUp = (
|
||||
nodeId: string,
|
||||
{
|
||||
wasSelectedAtPointerDown,
|
||||
multiSelect
|
||||
}: {
|
||||
wasSelectedAtPointerDown: boolean
|
||||
multiSelect: boolean
|
||||
}
|
||||
) => {
|
||||
function toggleNodeSelectionAfterPointerUp(
|
||||
nodeId: NodeId,
|
||||
multiSelect: boolean
|
||||
) {
|
||||
if (!shouldHandleNodePointerEvents.value) return
|
||||
|
||||
if (!canvasStore.canvas || !nodeManager.value) return
|
||||
@@ -267,22 +131,19 @@ function useNodeEventHandlersIndividual() {
|
||||
if (!node) return
|
||||
|
||||
if (!multiSelect) {
|
||||
const multipleSelected = canvasStore.selectedItems.length > 1
|
||||
if (multipleSelected && wasSelectedAtPointerDown) {
|
||||
canvasStore.canvas.deselectAll()
|
||||
canvasStore.canvas.select(node)
|
||||
canvasStore.updateSelectedItems()
|
||||
}
|
||||
canvasStore.canvas.deselectAll()
|
||||
canvasStore.canvas.select(node)
|
||||
canvasStore.updateSelectedItems()
|
||||
return
|
||||
}
|
||||
|
||||
if (wasSelectedAtPointerDown) {
|
||||
if (node.selected) {
|
||||
canvasStore.canvas.deselect(node)
|
||||
canvasStore.updateSelectedItems()
|
||||
} else {
|
||||
canvasStore.canvas.select(node)
|
||||
}
|
||||
|
||||
// No action needed when the node was not previously selected since the pointer-down
|
||||
// handler already added it to the selection.
|
||||
canvasStore.updateSelectedItems()
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -290,15 +151,9 @@ function useNodeEventHandlersIndividual() {
|
||||
handleNodeSelect,
|
||||
handleNodeCollapse,
|
||||
handleNodeTitleUpdate,
|
||||
handleNodeDoubleClick,
|
||||
handleNodeRightClick,
|
||||
handleNodeDragStart,
|
||||
|
||||
// Batch operations
|
||||
selectNodes,
|
||||
deselectNodes,
|
||||
deselectNode,
|
||||
ensureNodeSelectedForShiftDrag,
|
||||
toggleNodeSelectionAfterPointerUp
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useNodePointerInteractions } from '@/renderer/extensions/vueNodes/composables/useNodePointerInteractions'
|
||||
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import type { NodeLayout } from '@/renderer/core/layout/types'
|
||||
import { useNodeDrag } from '@/renderer/extensions/vueNodes/layout/useNodeDrag'
|
||||
|
||||
const forwardEventToCanvasMock = vi.fn()
|
||||
const deselectNodeMock = vi.fn()
|
||||
const selectNodesMock = vi.fn()
|
||||
const toggleNodeSelectionAfterPointerUpMock = vi.fn()
|
||||
const ensureNodeSelectedForShiftDragMock = vi.fn()
|
||||
const selectedItemsState: { items: Array<{ id?: string }> } = { items: [] }
|
||||
|
||||
// Mock the dependencies
|
||||
@@ -20,19 +20,18 @@ vi.mock('@/renderer/core/canvas/useCanvasInteractions', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/extensions/vueNodes/layout/useNodeLayout', () => ({
|
||||
useNodeLayout: () => ({
|
||||
startDrag: vi.fn(),
|
||||
endDrag: vi.fn().mockResolvedValue(undefined),
|
||||
handleDrag: vi.fn().mockResolvedValue(undefined)
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
|
||||
layoutStore: {
|
||||
isDraggingVueNodes: ref(false)
|
||||
vi.mock('@/renderer/extensions/vueNodes/layout/useNodeDrag', () => {
|
||||
const startDrag = vi.fn()
|
||||
const handleDrag = vi.fn()
|
||||
const endDrag = vi.fn()
|
||||
return {
|
||||
useNodeDrag: () => ({
|
||||
startDrag,
|
||||
handleDrag,
|
||||
endDrag
|
||||
})
|
||||
}
|
||||
}))
|
||||
})
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
@@ -44,14 +43,23 @@ vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/composables/useNodeEventHandlers',
|
||||
() => ({
|
||||
useNodeEventHandlers: () => ({
|
||||
deselectNode: deselectNodeMock,
|
||||
selectNodes: selectNodesMock,
|
||||
toggleNodeSelectionAfterPointerUp: toggleNodeSelectionAfterPointerUpMock,
|
||||
ensureNodeSelectedForShiftDrag: ensureNodeSelectedForShiftDragMock
|
||||
})
|
||||
})
|
||||
() => {
|
||||
const handleNodeSelect = vi.fn()
|
||||
const deselectNode = vi.fn()
|
||||
const selectNodes = vi.fn()
|
||||
const toggleNodeSelectionAfterPointerUp = vi.fn()
|
||||
const ensureNodeSelectedForShiftDrag = vi.fn()
|
||||
|
||||
return {
|
||||
useNodeEventHandlers: () => ({
|
||||
handleNodeSelect,
|
||||
deselectNode,
|
||||
selectNodes,
|
||||
toggleNodeSelectionAfterPointerUp,
|
||||
ensureNodeSelectedForShiftDrag
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
vi.mock('@/composables/graph/useVueNodeLifecycle', () => ({
|
||||
@@ -65,19 +73,35 @@ vi.mock('@/composables/graph/useVueNodeLifecycle', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
const createMockVueNodeData = (
|
||||
overrides: Partial<VueNodeData> = {}
|
||||
): VueNodeData => ({
|
||||
id: 'test-node-123',
|
||||
title: 'Test Node',
|
||||
type: 'TestNodeType',
|
||||
mode: 0,
|
||||
selected: false,
|
||||
executing: false,
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
widgets: [],
|
||||
...overrides
|
||||
const mockData = vi.hoisted(() => {
|
||||
const fakeNodeLayout: NodeLayout = {
|
||||
id: '',
|
||||
position: { x: 0, y: 0 },
|
||||
size: { width: 100, height: 100 },
|
||||
zIndex: 1,
|
||||
visible: true,
|
||||
bounds: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100
|
||||
}
|
||||
}
|
||||
return { fakeNodeLayout }
|
||||
})
|
||||
|
||||
vi.mock('@/renderer/core/layout/store/layoutStore', () => {
|
||||
const isDraggingVueNodes = ref(false)
|
||||
const fakeNodeLayoutRef = ref(mockData.fakeNodeLayout)
|
||||
const getNodeLayoutRef = vi.fn(() => fakeNodeLayoutRef)
|
||||
const setSource = vi.fn()
|
||||
return {
|
||||
layoutStore: {
|
||||
isDraggingVueNodes,
|
||||
getNodeLayoutRef,
|
||||
setSource
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const createPointerEvent = (
|
||||
@@ -107,46 +131,34 @@ const createMouseEvent = (
|
||||
|
||||
describe('useNodePointerInteractions', () => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
vi.restoreAllMocks()
|
||||
selectedItemsState.items = []
|
||||
setActivePinia(createPinia())
|
||||
// Reset layout store state between tests
|
||||
const { layoutStore } = await import(
|
||||
'@/renderer/core/layout/store/layoutStore'
|
||||
)
|
||||
layoutStore.isDraggingVueNodes.value = false
|
||||
setActivePinia(createTestingPinia())
|
||||
})
|
||||
|
||||
it('should only start drag on left-click', async () => {
|
||||
const mockNodeData = createMockVueNodeData()
|
||||
const mockOnNodeSelect = vi.fn()
|
||||
const { handleNodeSelect } = useNodeEventHandlers()
|
||||
const { startDrag } = useNodeDrag()
|
||||
|
||||
const { pointerHandlers } = useNodePointerInteractions(
|
||||
ref(mockNodeData),
|
||||
mockOnNodeSelect
|
||||
)
|
||||
const { pointerHandlers } = useNodePointerInteractions('test-node-123')
|
||||
|
||||
// Right-click should not trigger selection
|
||||
const rightClickEvent = createPointerEvent('pointerdown', { button: 2 })
|
||||
pointerHandlers.onPointerdown(rightClickEvent)
|
||||
|
||||
expect(mockOnNodeSelect).not.toHaveBeenCalled()
|
||||
expect(handleNodeSelect).not.toHaveBeenCalled()
|
||||
|
||||
// Left-click should trigger selection on pointer down
|
||||
const leftClickEvent = createPointerEvent('pointerdown', { button: 0 })
|
||||
pointerHandlers.onPointerdown(leftClickEvent)
|
||||
|
||||
expect(mockOnNodeSelect).toHaveBeenCalledWith(leftClickEvent, mockNodeData)
|
||||
expect(startDrag).toHaveBeenCalledWith(leftClickEvent, 'test-node-123')
|
||||
})
|
||||
|
||||
it('should call onNodeSelect on pointer down', async () => {
|
||||
const mockNodeData = createMockVueNodeData()
|
||||
const mockOnNodeSelect = vi.fn()
|
||||
it.skip('should call onNodeSelect on pointer down', async () => {
|
||||
const { handleNodeSelect } = useNodeEventHandlers()
|
||||
|
||||
const { pointerHandlers } = useNodePointerInteractions(
|
||||
ref(mockNodeData),
|
||||
mockOnNodeSelect
|
||||
)
|
||||
const { pointerHandlers } = useNodePointerInteractions('test-node-123')
|
||||
|
||||
// Selection should happen on pointer down
|
||||
const downEvent = createPointerEvent('pointerdown', {
|
||||
@@ -155,9 +167,9 @@ describe('useNodePointerInteractions', () => {
|
||||
})
|
||||
pointerHandlers.onPointerdown(downEvent)
|
||||
|
||||
expect(mockOnNodeSelect).toHaveBeenCalledWith(downEvent, mockNodeData)
|
||||
expect(handleNodeSelect).toHaveBeenCalledWith(downEvent, 'test-node-123')
|
||||
|
||||
mockOnNodeSelect.mockClear()
|
||||
vi.mocked(handleNodeSelect).mockClear()
|
||||
|
||||
// Even if we drag, selection already happened on pointer down
|
||||
pointerHandlers.onPointerup(
|
||||
@@ -165,35 +177,36 @@ describe('useNodePointerInteractions', () => {
|
||||
)
|
||||
|
||||
// onNodeSelect should not be called again on pointer up
|
||||
expect(mockOnNodeSelect).not.toHaveBeenCalled()
|
||||
expect(handleNodeSelect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle drag termination via cancel and context menu', async () => {
|
||||
const mockNodeData = createMockVueNodeData()
|
||||
const mockOnNodeSelect = vi.fn()
|
||||
const { handleNodeSelect } = useNodeEventHandlers()
|
||||
|
||||
const { pointerHandlers } = useNodePointerInteractions(
|
||||
ref(mockNodeData),
|
||||
mockOnNodeSelect
|
||||
)
|
||||
const { pointerHandlers } = useNodePointerInteractions('test-node-123')
|
||||
|
||||
// Test pointer cancel - selection happens on pointer down
|
||||
pointerHandlers.onPointerdown(
|
||||
createPointerEvent('pointerdown', { clientX: 100, clientY: 100 })
|
||||
)
|
||||
expect(mockOnNodeSelect).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Simulate drag by moving pointer beyond threshold
|
||||
pointerHandlers.onPointermove(
|
||||
createPointerEvent('pointermove', { clientX: 110, clientY: 110 })
|
||||
createPointerEvent('pointermove', {
|
||||
clientX: 110,
|
||||
clientY: 110,
|
||||
buttons: 1
|
||||
})
|
||||
)
|
||||
|
||||
expect(handleNodeSelect).toHaveBeenCalledTimes(1)
|
||||
|
||||
pointerHandlers.onPointercancel(createPointerEvent('pointercancel'))
|
||||
|
||||
// Selection should have been called on pointer down only
|
||||
expect(mockOnNodeSelect).toHaveBeenCalledTimes(1)
|
||||
expect(handleNodeSelect).toHaveBeenCalledTimes(1)
|
||||
|
||||
mockOnNodeSelect.mockClear()
|
||||
vi.mocked(handleNodeSelect).mockClear()
|
||||
|
||||
// Test context menu during drag prevents default
|
||||
pointerHandlers.onPointerdown(
|
||||
@@ -201,7 +214,11 @@ describe('useNodePointerInteractions', () => {
|
||||
)
|
||||
// Simulate drag by moving pointer beyond threshold
|
||||
pointerHandlers.onPointermove(
|
||||
createPointerEvent('pointermove', { clientX: 110, clientY: 110 })
|
||||
createPointerEvent('pointermove', {
|
||||
clientX: 110,
|
||||
clientY: 110,
|
||||
buttons: 1
|
||||
})
|
||||
)
|
||||
|
||||
const contextMenuEvent = createMouseEvent('contextmenu')
|
||||
@@ -212,36 +229,8 @@ describe('useNodePointerInteractions', () => {
|
||||
expect(preventDefaultSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not call onNodeSelect when nodeData is null', async () => {
|
||||
const mockNodeData = createMockVueNodeData()
|
||||
const mockOnNodeSelect = vi.fn()
|
||||
const nodeDataRef = ref<VueNodeData | null>(mockNodeData)
|
||||
|
||||
const { pointerHandlers } = useNodePointerInteractions(
|
||||
nodeDataRef,
|
||||
mockOnNodeSelect
|
||||
)
|
||||
|
||||
// Clear nodeData before pointer down
|
||||
nodeDataRef.value = null
|
||||
await nextTick()
|
||||
|
||||
pointerHandlers.onPointerdown(createPointerEvent('pointerdown'))
|
||||
|
||||
expect(mockOnNodeSelect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should integrate with layout store dragging state', async () => {
|
||||
const mockNodeData = createMockVueNodeData()
|
||||
const mockOnNodeSelect = vi.fn()
|
||||
const { layoutStore } = await import(
|
||||
'@/renderer/core/layout/store/layoutStore'
|
||||
)
|
||||
|
||||
const { pointerHandlers } = useNodePointerInteractions(
|
||||
ref(mockNodeData),
|
||||
mockOnNodeSelect
|
||||
)
|
||||
const { pointerHandlers } = useNodePointerInteractions('test-node-123')
|
||||
|
||||
// Pointer down alone shouldn't set dragging state
|
||||
pointerHandlers.onPointerdown(
|
||||
@@ -251,7 +240,11 @@ describe('useNodePointerInteractions', () => {
|
||||
|
||||
// Move pointer beyond threshold to start drag
|
||||
pointerHandlers.onPointermove(
|
||||
createPointerEvent('pointermove', { clientX: 110, clientY: 110 })
|
||||
createPointerEvent('pointermove', {
|
||||
clientX: 110,
|
||||
clientY: 110,
|
||||
buttons: 1
|
||||
})
|
||||
)
|
||||
await nextTick()
|
||||
expect(layoutStore.isDraggingVueNodes.value).toBe(true)
|
||||
@@ -262,63 +255,8 @@ describe('useNodePointerInteractions', () => {
|
||||
expect(layoutStore.isDraggingVueNodes.value).toBe(false)
|
||||
})
|
||||
|
||||
it('should select node on pointer down with ctrl key for multi-select', async () => {
|
||||
const mockNodeData = createMockVueNodeData()
|
||||
const mockOnNodeSelect = vi.fn()
|
||||
|
||||
const { pointerHandlers } = useNodePointerInteractions(
|
||||
ref(mockNodeData),
|
||||
mockOnNodeSelect
|
||||
)
|
||||
|
||||
// Pointer down with ctrl key should pass the event with ctrl key set
|
||||
const ctrlDownEvent = createPointerEvent('pointerdown', {
|
||||
ctrlKey: true,
|
||||
clientX: 100,
|
||||
clientY: 100
|
||||
})
|
||||
pointerHandlers.onPointerdown(ctrlDownEvent)
|
||||
|
||||
expect(mockOnNodeSelect).toHaveBeenCalledWith(ctrlDownEvent, mockNodeData)
|
||||
expect(mockOnNodeSelect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should select pinned node on pointer down but not start drag', async () => {
|
||||
const mockNodeData = createMockVueNodeData({
|
||||
flags: { pinned: true }
|
||||
})
|
||||
const mockOnNodeSelect = vi.fn()
|
||||
const { layoutStore } = await import(
|
||||
'@/renderer/core/layout/store/layoutStore'
|
||||
)
|
||||
|
||||
const { pointerHandlers } = useNodePointerInteractions(
|
||||
ref(mockNodeData),
|
||||
mockOnNodeSelect
|
||||
)
|
||||
|
||||
// Pointer down on pinned node
|
||||
const downEvent = createPointerEvent('pointerdown')
|
||||
pointerHandlers.onPointerdown(downEvent)
|
||||
|
||||
// Should select the node
|
||||
expect(mockOnNodeSelect).toHaveBeenCalledWith(downEvent, mockNodeData)
|
||||
|
||||
// But should not start dragging
|
||||
expect(layoutStore.isDraggingVueNodes.value).toBe(false)
|
||||
})
|
||||
|
||||
it('should select node immediately when drag starts', async () => {
|
||||
const mockNodeData = createMockVueNodeData()
|
||||
const mockOnNodeSelect = vi.fn()
|
||||
const { layoutStore } = await import(
|
||||
'@/renderer/core/layout/store/layoutStore'
|
||||
)
|
||||
|
||||
const { pointerHandlers } = useNodePointerInteractions(
|
||||
ref(mockNodeData),
|
||||
mockOnNodeSelect
|
||||
)
|
||||
const { pointerHandlers } = useNodePointerInteractions('test-node-123')
|
||||
|
||||
// Pointer down should select node immediately
|
||||
const downEvent = createPointerEvent('pointerdown', {
|
||||
@@ -326,24 +264,25 @@ describe('useNodePointerInteractions', () => {
|
||||
clientY: 100
|
||||
})
|
||||
pointerHandlers.onPointerdown(downEvent)
|
||||
|
||||
// Selection should happen on pointer down (before move)
|
||||
expect(mockOnNodeSelect).toHaveBeenCalledWith(downEvent, mockNodeData)
|
||||
expect(mockOnNodeSelect).toHaveBeenCalledTimes(1)
|
||||
const { handleNodeSelect } = useNodeEventHandlers()
|
||||
|
||||
// Dragging state should NOT be active yet
|
||||
expect(layoutStore.isDraggingVueNodes.value).toBe(false)
|
||||
|
||||
const pointerMove = createPointerEvent('pointermove', {
|
||||
clientX: 150,
|
||||
clientY: 150,
|
||||
buttons: 1
|
||||
})
|
||||
// Move the pointer beyond threshold (start dragging)
|
||||
pointerHandlers.onPointermove(
|
||||
createPointerEvent('pointermove', { clientX: 150, clientY: 150 })
|
||||
)
|
||||
pointerHandlers.onPointermove(pointerMove)
|
||||
|
||||
// Now dragging state should be active
|
||||
expect(layoutStore.isDraggingVueNodes.value).toBe(true)
|
||||
|
||||
// Selection should still only have been called once (on pointer down)
|
||||
expect(mockOnNodeSelect).toHaveBeenCalledTimes(1)
|
||||
// Selection should happen on pointer down (before move)
|
||||
expect(handleNodeSelect).toHaveBeenCalledWith(pointerMove, 'test-node-123')
|
||||
expect(handleNodeSelect).toHaveBeenCalledTimes(1)
|
||||
|
||||
// End drag
|
||||
pointerHandlers.onPointerup(
|
||||
@@ -351,17 +290,12 @@ describe('useNodePointerInteractions', () => {
|
||||
)
|
||||
|
||||
// Selection should still only have been called once
|
||||
expect(mockOnNodeSelect).toHaveBeenCalledTimes(1)
|
||||
expect(handleNodeSelect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('on ctrl+click: calls toggleNodeSelectionAfterPointerUp on pointer up (not pointer down)', async () => {
|
||||
const mockNodeData = createMockVueNodeData()
|
||||
const mockOnNodeSelect = vi.fn()
|
||||
|
||||
const { pointerHandlers } = useNodePointerInteractions(
|
||||
ref(mockNodeData),
|
||||
mockOnNodeSelect
|
||||
)
|
||||
const { pointerHandlers } = useNodePointerInteractions('test-node-123')
|
||||
const { toggleNodeSelectionAfterPointerUp } = useNodeEventHandlers()
|
||||
|
||||
// Pointer down with ctrl
|
||||
const downEvent = createPointerEvent('pointerdown', {
|
||||
@@ -372,7 +306,7 @@ describe('useNodePointerInteractions', () => {
|
||||
pointerHandlers.onPointerdown(downEvent)
|
||||
|
||||
// On pointer down: toggle handler should NOT be called yet
|
||||
expect(toggleNodeSelectionAfterPointerUpMock).not.toHaveBeenCalled()
|
||||
expect(toggleNodeSelectionAfterPointerUp).not.toHaveBeenCalled()
|
||||
|
||||
// Pointer up with ctrl (no drag - same position)
|
||||
const upEvent = createPointerEvent('pointerup', {
|
||||
@@ -383,116 +317,9 @@ describe('useNodePointerInteractions', () => {
|
||||
pointerHandlers.onPointerup(upEvent)
|
||||
|
||||
// On pointer up: toggle handler IS called with correct params
|
||||
expect(toggleNodeSelectionAfterPointerUpMock).toHaveBeenCalledWith(
|
||||
mockNodeData.id,
|
||||
{
|
||||
wasSelectedAtPointerDown: false,
|
||||
multiSelect: true
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('on ctrl+drag: does NOT call toggleNodeSelectionAfterPointerUp', async () => {
|
||||
const mockNodeData = createMockVueNodeData()
|
||||
const mockOnNodeSelect = vi.fn()
|
||||
|
||||
const { pointerHandlers } = useNodePointerInteractions(
|
||||
ref(mockNodeData),
|
||||
mockOnNodeSelect
|
||||
)
|
||||
|
||||
// Pointer down with ctrl
|
||||
const downEvent = createPointerEvent('pointerdown', {
|
||||
ctrlKey: true,
|
||||
clientX: 100,
|
||||
clientY: 100
|
||||
})
|
||||
pointerHandlers.onPointerdown(downEvent)
|
||||
|
||||
// Move beyond drag threshold
|
||||
pointerHandlers.onPointermove(
|
||||
createPointerEvent('pointermove', {
|
||||
ctrlKey: true,
|
||||
clientX: 110,
|
||||
clientY: 110
|
||||
})
|
||||
)
|
||||
|
||||
// Pointer up after drag
|
||||
const upEvent = createPointerEvent('pointerup', {
|
||||
ctrlKey: true,
|
||||
clientX: 110,
|
||||
clientY: 110
|
||||
})
|
||||
pointerHandlers.onPointerup(upEvent)
|
||||
|
||||
// When dragging: toggle handler should NOT be called
|
||||
expect(toggleNodeSelectionAfterPointerUpMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('selects node when shift drag starts without multi selection', async () => {
|
||||
selectedItemsState.items = []
|
||||
const mockNodeData = createMockVueNodeData()
|
||||
const mockOnNodeSelect = vi.fn()
|
||||
|
||||
const { pointerHandlers } = useNodePointerInteractions(
|
||||
ref(mockNodeData),
|
||||
mockOnNodeSelect
|
||||
)
|
||||
|
||||
const pointerDownEvent = createPointerEvent('pointerdown', {
|
||||
clientX: 0,
|
||||
clientY: 0,
|
||||
shiftKey: true
|
||||
})
|
||||
|
||||
pointerHandlers.onPointerdown(pointerDownEvent)
|
||||
|
||||
const pointerMoveEvent = createPointerEvent('pointermove', {
|
||||
clientX: 10,
|
||||
clientY: 10,
|
||||
shiftKey: true
|
||||
})
|
||||
|
||||
pointerHandlers.onPointermove(pointerMoveEvent)
|
||||
|
||||
expect(ensureNodeSelectedForShiftDragMock).toHaveBeenCalledWith(
|
||||
pointerMoveEvent,
|
||||
mockNodeData,
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('still ensures selection when shift drag starts with existing multi select', async () => {
|
||||
selectedItemsState.items = [{ id: 'a' }, { id: 'b' }]
|
||||
const mockNodeData = createMockVueNodeData()
|
||||
const mockOnNodeSelect = vi.fn()
|
||||
|
||||
const { pointerHandlers } = useNodePointerInteractions(
|
||||
ref(mockNodeData),
|
||||
mockOnNodeSelect
|
||||
)
|
||||
|
||||
const pointerDownEvent = createPointerEvent('pointerdown', {
|
||||
clientX: 0,
|
||||
clientY: 0,
|
||||
shiftKey: true
|
||||
})
|
||||
|
||||
pointerHandlers.onPointerdown(pointerDownEvent)
|
||||
|
||||
const pointerMoveEvent = createPointerEvent('pointermove', {
|
||||
clientX: 10,
|
||||
clientY: 10,
|
||||
shiftKey: true
|
||||
})
|
||||
|
||||
pointerHandlers.onPointermove(pointerMoveEvent)
|
||||
|
||||
expect(ensureNodeSelectedForShiftDragMock).toHaveBeenCalledWith(
|
||||
pointerMoveEvent,
|
||||
mockNodeData,
|
||||
false
|
||||
expect(toggleNodeSelectionAfterPointerUp).toHaveBeenCalledWith(
|
||||
'test-node-123',
|
||||
true
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,37 +1,22 @@
|
||||
import { computed, onUnmounted, ref, toValue } from 'vue'
|
||||
import { onScopeDispose, ref, toValue } from 'vue'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
|
||||
import { isMiddlePointerInput } from '@/base/pointerUtils'
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
|
||||
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { useNodeLayout } from '@/renderer/extensions/vueNodes/layout/useNodeLayout'
|
||||
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
|
||||
import { isMultiSelectKey } from '@/renderer/extensions/vueNodes/utils/selectionUtils'
|
||||
import { useNodeDrag } from '@/renderer/extensions/vueNodes/layout/useNodeDrag'
|
||||
|
||||
export function useNodePointerInteractions(
|
||||
nodeDataMaybe: MaybeRefOrGetter<VueNodeData | null>,
|
||||
onNodeSelect: (event: PointerEvent, nodeData: VueNodeData) => void
|
||||
nodeIdRef: MaybeRefOrGetter<string>
|
||||
) {
|
||||
const nodeData = computed(() => {
|
||||
const value = toValue(nodeDataMaybe)
|
||||
if (!value) {
|
||||
console.warn(
|
||||
'useNodePointerInteractions: nodeDataMaybe resolved to null/undefined'
|
||||
)
|
||||
return null
|
||||
}
|
||||
return value
|
||||
})
|
||||
|
||||
// Avoid potential null access during component initialization
|
||||
const nodeIdComputed = computed(() => nodeData.value?.id ?? '')
|
||||
const { startDrag, endDrag, handleDrag } = useNodeLayout(nodeIdComputed)
|
||||
const { startDrag, endDrag, handleDrag } = useNodeDrag()
|
||||
// Use canvas interactions for proper wheel event handling and pointer event capture control
|
||||
const { forwardEventToCanvas, shouldHandleNodePointerEvents } =
|
||||
useCanvasInteractions()
|
||||
const { toggleNodeSelectionAfterPointerUp, ensureNodeSelectedForShiftDrag } =
|
||||
const { handleNodeSelect, toggleNodeSelectionAfterPointerUp } =
|
||||
useNodeEventHandlers()
|
||||
const { nodeManager } = useVueNodeLifecycle()
|
||||
|
||||
@@ -41,33 +26,15 @@ export function useNodePointerInteractions(
|
||||
return true
|
||||
}
|
||||
|
||||
// Drag state for styling
|
||||
const isDragging = ref(false)
|
||||
const isPointerDown = ref(false)
|
||||
const wasSelectedAtPointerDown = ref(false) // Track if node was selected when pointer down occurred
|
||||
const dragStyle = computed(() => {
|
||||
if (nodeData.value?.flags?.pinned) {
|
||||
return { cursor: 'default' }
|
||||
}
|
||||
return { cursor: isDragging.value ? 'grabbing' : 'grab' }
|
||||
})
|
||||
const startPosition = ref({ x: 0, y: 0 })
|
||||
|
||||
const DRAG_THRESHOLD = 3 // pixels
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (!nodeData.value) {
|
||||
console.warn(
|
||||
'LGraphNode: nodeData is null/undefined in handlePointerDown'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
function onPointerdown(event: PointerEvent) {
|
||||
if (forwardMiddlePointerIfNeeded(event)) return
|
||||
|
||||
// Only start drag on left-click (button 0)
|
||||
if (event.button !== 0) {
|
||||
return
|
||||
}
|
||||
if (event.button !== 0) return
|
||||
|
||||
// Don't handle pointer events when canvas is in panning mode - forward to canvas instead
|
||||
if (!shouldHandleNodePointerEvents.value) {
|
||||
@@ -75,69 +42,67 @@ export function useNodePointerInteractions(
|
||||
return
|
||||
}
|
||||
|
||||
// Track if node was selected before this pointer down
|
||||
// IMPORTANT: Read from actual LGraphNode, not nodeData, to get correct state
|
||||
const lgNode = nodeManager.value?.getNode(nodeData.value.id)
|
||||
wasSelectedAtPointerDown.value = lgNode?.selected ?? false
|
||||
|
||||
onNodeSelect(event, nodeData.value)
|
||||
|
||||
if (nodeData.value.flags?.pinned) {
|
||||
const nodeId = toValue(nodeIdRef)
|
||||
if (!nodeId) {
|
||||
console.warn(
|
||||
'LGraphNode: nodeData is null/undefined in handlePointerDown'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Record position for drag threshold calculation
|
||||
startPosition.value = { x: event.clientX, y: event.clientY }
|
||||
isPointerDown.value = true
|
||||
// IMPORTANT: Read from actual LGraphNode to get correct state
|
||||
if (nodeManager.value?.getNode(nodeId)?.flags?.pinned) {
|
||||
return
|
||||
}
|
||||
|
||||
// Don't start drag yet - wait for pointer move to exceed threshold
|
||||
startDrag(event)
|
||||
startPosition.value = { x: event.clientX, y: event.clientY }
|
||||
|
||||
startDrag(event, nodeId)
|
||||
}
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
function onPointermove(event: PointerEvent) {
|
||||
if (forwardMiddlePointerIfNeeded(event)) return
|
||||
|
||||
const nodeId = toValue(nodeIdRef)
|
||||
|
||||
if (nodeManager.value?.getNode(nodeId)?.flags?.pinned) {
|
||||
return
|
||||
}
|
||||
|
||||
const multiSelect = isMultiSelectKey(event)
|
||||
|
||||
const lmbDown = event.buttons & 1
|
||||
if (lmbDown && multiSelect && !layoutStore.isDraggingVueNodes.value) {
|
||||
layoutStore.isDraggingVueNodes.value = true
|
||||
handleNodeSelect(event, nodeId)
|
||||
startDrag(event, nodeId)
|
||||
return
|
||||
}
|
||||
// Check if we should start dragging (pointer moved beyond threshold)
|
||||
if (isPointerDown.value && !isDragging.value) {
|
||||
if (lmbDown && !layoutStore.isDraggingVueNodes.value) {
|
||||
const dx = event.clientX - startPosition.value.x
|
||||
const dy = event.clientY - startPosition.value.y
|
||||
const distance = Math.sqrt(dx * dx + dy * dy)
|
||||
|
||||
if (distance > DRAG_THRESHOLD && nodeData.value) {
|
||||
// Start drag
|
||||
isDragging.value = true
|
||||
if (distance > DRAG_THRESHOLD) {
|
||||
layoutStore.isDraggingVueNodes.value = true
|
||||
ensureNodeSelectedForShiftDrag(
|
||||
event,
|
||||
nodeData.value,
|
||||
wasSelectedAtPointerDown.value
|
||||
)
|
||||
handleNodeSelect(event, nodeId)
|
||||
}
|
||||
}
|
||||
|
||||
if (isDragging.value) {
|
||||
void handleDrag(event)
|
||||
if (layoutStore.isDraggingVueNodes.value) {
|
||||
handleDrag(event, nodeId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Centralized cleanup function for drag state
|
||||
* Ensures consistent cleanup across all drag termination scenarios
|
||||
*/
|
||||
const cleanupDragState = () => {
|
||||
isDragging.value = false
|
||||
isPointerDown.value = false
|
||||
wasSelectedAtPointerDown.value = false
|
||||
function cleanupDragState() {
|
||||
layoutStore.isDraggingVueNodes.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely ends drag operation with proper error handling
|
||||
* @param event - PointerEvent to end the drag with
|
||||
*/
|
||||
const safeDragEnd = async (event: PointerEvent): Promise<void> => {
|
||||
function safeDragEnd(event: PointerEvent) {
|
||||
try {
|
||||
await endDrag(event)
|
||||
const nodeId = toValue(nodeIdRef)
|
||||
endDrag(event, nodeId)
|
||||
} catch (error) {
|
||||
console.error('Error during endDrag:', error)
|
||||
} finally {
|
||||
@@ -145,61 +110,39 @@ export function useNodePointerInteractions(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Common drag termination handler with fallback cleanup
|
||||
*/
|
||||
const handleDragTermination = (event: PointerEvent, errorContext: string) => {
|
||||
safeDragEnd(event).catch((error) => {
|
||||
console.error(`Failed to complete ${errorContext}:`, error)
|
||||
cleanupDragState() // Fallback cleanup
|
||||
})
|
||||
}
|
||||
|
||||
const handlePointerUp = (event: PointerEvent) => {
|
||||
function onPointerup(event: PointerEvent) {
|
||||
if (forwardMiddlePointerIfNeeded(event)) return
|
||||
|
||||
const wasDragging = isDragging.value
|
||||
const multiSelect = isMultiSelectKey(event)
|
||||
const canHandlePointer = shouldHandleNodePointerEvents.value
|
||||
|
||||
if (wasDragging) {
|
||||
handleDragTermination(event, 'drag end')
|
||||
} else {
|
||||
// Clean up pointer state even if not dragging
|
||||
isPointerDown.value = false
|
||||
const wasSelected = wasSelectedAtPointerDown.value
|
||||
wasSelectedAtPointerDown.value = false
|
||||
|
||||
if (nodeData.value && canHandlePointer) {
|
||||
toggleNodeSelectionAfterPointerUp(nodeData.value.id, {
|
||||
wasSelectedAtPointerDown: wasSelected,
|
||||
multiSelect
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Don't handle pointer events when canvas is in panning mode - forward to canvas instead
|
||||
const canHandlePointer = shouldHandleNodePointerEvents.value
|
||||
if (!canHandlePointer) {
|
||||
forwardEventToCanvas(event)
|
||||
return
|
||||
}
|
||||
const wasDragging = layoutStore.isDraggingVueNodes.value
|
||||
|
||||
if (wasDragging) {
|
||||
safeDragEnd(event)
|
||||
return
|
||||
}
|
||||
const multiSelect = isMultiSelectKey(event)
|
||||
|
||||
const nodeId = toValue(nodeIdRef)
|
||||
if (nodeId) {
|
||||
toggleNodeSelectionAfterPointerUp(nodeId, multiSelect)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles pointer cancellation events (e.g., touch cancelled by browser)
|
||||
* Ensures drag state is properly cleaned up when pointer interaction is interrupted
|
||||
*/
|
||||
const handlePointerCancel = (event: PointerEvent) => {
|
||||
if (!isDragging.value) return
|
||||
handleDragTermination(event, 'drag cancellation')
|
||||
function onPointercancel(event: PointerEvent) {
|
||||
if (!layoutStore.isDraggingVueNodes.value) return
|
||||
safeDragEnd(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles right-click during drag operations
|
||||
* Cancels the current drag to prevent context menu from appearing while dragging
|
||||
*/
|
||||
const handleContextMenu = (event: MouseEvent) => {
|
||||
if (!isDragging.value) return
|
||||
function onContextmenu(event: MouseEvent) {
|
||||
if (!layoutStore.isDraggingVueNodes.value) return
|
||||
|
||||
event.preventDefault()
|
||||
// Simply cleanup state without calling endDrag to avoid synthetic event creation
|
||||
@@ -207,22 +150,19 @@ export function useNodePointerInteractions(
|
||||
}
|
||||
|
||||
// Cleanup on unmount to prevent resource leaks
|
||||
onUnmounted(() => {
|
||||
if (!isDragging.value) return
|
||||
onScopeDispose(() => {
|
||||
cleanupDragState()
|
||||
})
|
||||
|
||||
const pointerHandlers = {
|
||||
onPointerdown: handlePointerDown,
|
||||
onPointermove: handlePointerMove,
|
||||
onPointerup: handlePointerUp,
|
||||
onPointercancel: handlePointerCancel,
|
||||
onContextmenu: handleContextMenu
|
||||
}
|
||||
onPointerdown,
|
||||
onPointermove,
|
||||
onPointerup,
|
||||
onPointercancel,
|
||||
onContextmenu
|
||||
} as const
|
||||
|
||||
return {
|
||||
isDragging,
|
||||
dragStyle,
|
||||
pointerHandlers
|
||||
}
|
||||
}
|
||||
|
||||
@@ -384,6 +384,8 @@ export function useSlotLinkInteraction({
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
if (!pointerSession.matches(event)) return
|
||||
event.stopPropagation()
|
||||
|
||||
dragContext.pendingPointerMove = {
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
@@ -507,6 +509,7 @@ export function useSlotLinkInteraction({
|
||||
}
|
||||
|
||||
const handlePointerUp = (event: PointerEvent) => {
|
||||
event.stopPropagation()
|
||||
finishInteraction(event)
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { MaybeRefOrGetter } from 'vue'
|
||||
import { useSharedCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import type { Bounds, NodeId } from '@/renderer/core/layout/types'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
|
||||
@@ -60,6 +61,7 @@ const trackingConfigs: Map<string, ElementTrackingConfig> = new Map([
|
||||
|
||||
// Single ResizeObserver instance for all Vue elements
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
if (useCanvasStore().linearMode) return
|
||||
// Canvas is ready when this code runs; no defensive guards needed.
|
||||
const conv = useSharedCanvasPositionConversion()
|
||||
// Group updates by type, then flush via each config's handler
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { TransformState } from '@/renderer/core/layout/injectionKeys'
|
||||
import type { Point, Size } from '@/renderer/core/layout/types'
|
||||
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 type { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
|
||||
|
||||
interface UseNodeResizeOptions {
|
||||
/** Transform state for coordinate conversion */
|
||||
transformState: TransformState
|
||||
transformState: ReturnType<typeof useTransformState>
|
||||
}
|
||||
|
||||
interface ResizeCallbackPayload {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
|
||||
import type { LGraph, rendererType } from '@/lib/litegraph/src/LGraph'
|
||||
import type { LGraph, RendererType } from '@/lib/litegraph/src/LGraph'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { createBounds } from '@/lib/litegraph/src/measure'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
@@ -13,135 +13,108 @@ import type { SubgraphOutputNode } from '@/lib/litegraph/src/subgraph/SubgraphOu
|
||||
const SCALE_FACTOR = 1.2
|
||||
|
||||
export function ensureCorrectLayoutScale(
|
||||
renderer?: rendererType,
|
||||
renderer: RendererType = 'LG',
|
||||
targetGraph?: LGraph
|
||||
) {
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const autoScaleLayoutSetting = settingStore.get(
|
||||
const autoScaleLayoutSetting = useSettingStore().get(
|
||||
'Comfy.VueNodes.AutoScaleLayout'
|
||||
)
|
||||
|
||||
if (autoScaleLayoutSetting === false) {
|
||||
return
|
||||
}
|
||||
|
||||
const { shouldRenderVueNodes } = useVueFeatureFlags()
|
||||
if (!autoScaleLayoutSetting) return
|
||||
|
||||
const canvas = comfyApp.canvas
|
||||
const graph = targetGraph ?? canvas?.graph
|
||||
|
||||
if (!graph || !graph.nodes) return
|
||||
if (!graph?.nodes) return
|
||||
|
||||
// Use renderer from graph, default to 'LG' for the check (but don't modify graph yet)
|
||||
if (!renderer) {
|
||||
// Always assume legacy LG format when unknown (pre-dates this feature)
|
||||
renderer = 'LG'
|
||||
}
|
||||
const { shouldRenderVueNodes } = useVueFeatureFlags()
|
||||
|
||||
const doesntNeedScale =
|
||||
(renderer === 'LG' && shouldRenderVueNodes.value === false) ||
|
||||
(renderer === 'Vue' && shouldRenderVueNodes.value === true)
|
||||
const needsUpscale = renderer === 'LG' && shouldRenderVueNodes.value
|
||||
const needsDownscale = renderer === 'Vue' && !shouldRenderVueNodes.value
|
||||
|
||||
if (doesntNeedScale) {
|
||||
if (!needsUpscale && !needsDownscale) {
|
||||
// Don't scale, but ensure workflowRendererVersion is set for future checks
|
||||
if (!graph.extra.workflowRendererVersion) {
|
||||
graph.extra.workflowRendererVersion = renderer
|
||||
}
|
||||
graph.extra.workflowRendererVersion ??= renderer
|
||||
return
|
||||
}
|
||||
|
||||
const needsUpscale = renderer === 'LG' && shouldRenderVueNodes.value === true
|
||||
const needsDownscale =
|
||||
renderer === 'Vue' && shouldRenderVueNodes.value === false
|
||||
|
||||
const lgBounds = createBounds(graph.nodes)
|
||||
|
||||
if (!lgBounds) return
|
||||
|
||||
const originX = lgBounds[0]
|
||||
const originY = lgBounds[1]
|
||||
const [originX, originY] = lgBounds
|
||||
|
||||
const lgNodesById = new Map(graph.nodes.map((node) => [node.id, node]))
|
||||
|
||||
const yjsMoveNodeUpdates: NodeBoundsUpdate[] = []
|
||||
|
||||
const scaleFactor = needsUpscale
|
||||
? SCALE_FACTOR
|
||||
: needsDownscale
|
||||
? 1 / SCALE_FACTOR
|
||||
: 1
|
||||
const scaleFactor = needsUpscale ? SCALE_FACTOR : 1 / SCALE_FACTOR
|
||||
|
||||
const onActiveGraph = !targetGraph || targetGraph === canvas?.graph
|
||||
|
||||
//TODO: once we remove the need for LiteGraph.NODE_TITLE_HEIGHT in vue nodes we nned to remove everything here.
|
||||
for (const node of graph.nodes) {
|
||||
const lgNode = lgNodesById.get(node.id)
|
||||
if (!lgNode) continue
|
||||
|
||||
const lgBodyY = lgNode.pos[1]
|
||||
const [oldX, oldY] = lgNode.pos
|
||||
|
||||
const adjustedY = needsDownscale
|
||||
? lgBodyY - LiteGraph.NODE_TITLE_HEIGHT / 2
|
||||
: lgBodyY
|
||||
const adjustedY = oldY - (needsUpscale ? LiteGraph.NODE_TITLE_HEIGHT : 0)
|
||||
|
||||
const relativeX = lgNode.pos[0] - originX
|
||||
const relativeX = oldX - originX
|
||||
const relativeY = adjustedY - originY
|
||||
const newX = originX + relativeX * scaleFactor
|
||||
const scaledY = originY + relativeY * scaleFactor
|
||||
const newWidth = lgNode.width * scaleFactor
|
||||
const newHeight = lgNode.height * scaleFactor
|
||||
|
||||
const finalY = needsUpscale
|
||||
? scaledY + LiteGraph.NODE_TITLE_HEIGHT / 2
|
||||
: scaledY
|
||||
const scaledX = originX + relativeX * scaleFactor
|
||||
const scaledY = originY + relativeY * scaleFactor
|
||||
|
||||
const scaledWidth = lgNode.width * scaleFactor
|
||||
const scaledHeight =
|
||||
lgNode.height * scaleFactor -
|
||||
(needsUpscale ? 0 : LiteGraph.NODE_TITLE_HEIGHT)
|
||||
|
||||
const finalY = scaledY + (needsUpscale ? 0 : LiteGraph.NODE_TITLE_HEIGHT) // Litegraph Position further down
|
||||
|
||||
// Directly update LiteGraph node to ensure immediate consistency
|
||||
// Dont need to reference vue directly because the pos and dims are already in yjs
|
||||
lgNode.pos[0] = newX
|
||||
lgNode.pos[0] = scaledX
|
||||
lgNode.pos[1] = finalY
|
||||
lgNode.size[0] = newWidth
|
||||
lgNode.size[1] =
|
||||
newHeight - (needsDownscale ? LiteGraph.NODE_TITLE_HEIGHT : 0)
|
||||
lgNode.size[0] = scaledWidth
|
||||
lgNode.size[1] = scaledHeight
|
||||
|
||||
// Track updates for layout store (only if this is the active graph)
|
||||
if (!targetGraph || targetGraph === canvas?.graph) {
|
||||
if (onActiveGraph) {
|
||||
yjsMoveNodeUpdates.push({
|
||||
nodeId: String(lgNode.id),
|
||||
bounds: {
|
||||
x: newX,
|
||||
x: scaledX,
|
||||
y: finalY,
|
||||
width: newWidth,
|
||||
height: newHeight - (needsDownscale ? LiteGraph.NODE_TITLE_HEIGHT : 0)
|
||||
width: scaledWidth,
|
||||
height: scaledHeight
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(!targetGraph || targetGraph === canvas?.graph) &&
|
||||
yjsMoveNodeUpdates.length > 0
|
||||
) {
|
||||
if (onActiveGraph && yjsMoveNodeUpdates.length > 0) {
|
||||
layoutStore.batchUpdateNodeBounds(yjsMoveNodeUpdates)
|
||||
}
|
||||
|
||||
for (const reroute of graph.reroutes.values()) {
|
||||
const oldX = reroute.pos[0]
|
||||
const oldY = reroute.pos[1]
|
||||
const [oldX, oldY] = reroute.pos
|
||||
|
||||
const relativeX = oldX - originX
|
||||
const relativeY = oldY - originY
|
||||
const newX = originX + relativeX * scaleFactor
|
||||
const newY = originY + relativeY * scaleFactor
|
||||
|
||||
reroute.pos = [newX, newY]
|
||||
const scaledX = (originX + relativeX) * scaleFactor
|
||||
const scaledY = (originY + relativeY) * scaleFactor
|
||||
|
||||
if (
|
||||
(!targetGraph || targetGraph === canvas?.graph) &&
|
||||
shouldRenderVueNodes.value
|
||||
) {
|
||||
reroute.pos = [scaledX, scaledY]
|
||||
|
||||
if (onActiveGraph && shouldRenderVueNodes.value) {
|
||||
const layoutMutations = useLayoutMutations()
|
||||
layoutMutations.moveReroute(
|
||||
reroute.id,
|
||||
{ x: newX, y: newY },
|
||||
{ x: scaledX, y: scaledY },
|
||||
{ x: oldX, y: oldY }
|
||||
)
|
||||
}
|
||||
@@ -153,60 +126,48 @@ export function ensureCorrectLayoutScale(
|
||||
graph.outputNode as SubgraphOutputNode
|
||||
]
|
||||
for (const ioNode of ioNodes) {
|
||||
const oldX = ioNode.pos[0]
|
||||
const oldY = ioNode.pos[1]
|
||||
const oldWidth = ioNode.size[0]
|
||||
const oldHeight = ioNode.size[1]
|
||||
const [oldX, oldY] = ioNode.pos
|
||||
const [oldWidth, oldHeight] = ioNode.size
|
||||
|
||||
const relativeX = oldX - originX
|
||||
const relativeY = oldY - originY
|
||||
const newX = originX + relativeX * scaleFactor
|
||||
const newY = originY + relativeY * scaleFactor
|
||||
const newWidth = oldWidth * scaleFactor
|
||||
const newHeight = oldHeight * scaleFactor
|
||||
|
||||
ioNode.pos = [newX, newY]
|
||||
ioNode.size = [newWidth, newHeight]
|
||||
const scaledX = originX + relativeX * scaleFactor
|
||||
const scaledY = originY + relativeY * scaleFactor
|
||||
|
||||
const scaledWidth = oldWidth * scaleFactor
|
||||
const scaledHeight = oldHeight * scaleFactor
|
||||
|
||||
ioNode.pos = [scaledX, scaledY]
|
||||
ioNode.size = [scaledWidth, scaledHeight]
|
||||
}
|
||||
}
|
||||
|
||||
graph.groups.forEach((group) => {
|
||||
const originalPosX = group.pos[0]
|
||||
const originalPosY = group.pos[1]
|
||||
const originalWidth = group.size[0]
|
||||
const originalHeight = group.size[1]
|
||||
const [oldX, oldY] = group.pos
|
||||
const [oldWidth, oldHeight] = group.size
|
||||
|
||||
const adjustedY = needsDownscale
|
||||
? originalPosY - LiteGraph.NODE_TITLE_HEIGHT
|
||||
: originalPosY
|
||||
const adjustedY = oldY - (needsUpscale ? LiteGraph.NODE_TITLE_HEIGHT : 0)
|
||||
|
||||
const relativeX = originalPosX - originX
|
||||
const relativeX = oldX - originX
|
||||
const relativeY = adjustedY - originY
|
||||
|
||||
const newWidth = originalWidth * scaleFactor
|
||||
const newHeight = originalHeight * scaleFactor
|
||||
|
||||
const scaledX = originX + relativeX * scaleFactor
|
||||
const scaledY = originY + relativeY * scaleFactor
|
||||
|
||||
const finalY = needsUpscale
|
||||
? scaledY + LiteGraph.NODE_TITLE_HEIGHT
|
||||
: scaledY
|
||||
const scaledWidth = oldWidth * scaleFactor
|
||||
const scaledHeight = oldHeight * scaleFactor
|
||||
|
||||
const finalY = scaledY + (needsUpscale ? 0 : LiteGraph.NODE_TITLE_HEIGHT)
|
||||
|
||||
group.pos = [scaledX, finalY]
|
||||
group.size = [newWidth, newHeight]
|
||||
group.size = [scaledWidth, scaledHeight]
|
||||
})
|
||||
|
||||
if ((!targetGraph || targetGraph === canvas?.graph) && canvas) {
|
||||
if (onActiveGraph && canvas) {
|
||||
const originScreen = canvas.ds.convertOffsetToCanvas([originX, originY])
|
||||
canvas.ds.changeScale(canvas.ds.scale / scaleFactor, originScreen)
|
||||
}
|
||||
|
||||
if (needsUpscale) {
|
||||
graph.extra.workflowRendererVersion = 'Vue'
|
||||
}
|
||||
|
||||
if (needsDownscale) {
|
||||
graph.extra.workflowRendererVersion = 'LG'
|
||||
}
|
||||
graph.extra.workflowRendererVersion = needsUpscale ? 'Vue' : 'LG'
|
||||
}
|
||||
|
||||
215
src/renderer/extensions/vueNodes/layout/useNodeDrag.ts
Normal file
215
src/renderer/extensions/vueNodes/layout/useNodeDrag.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { toValue } from 'vue'
|
||||
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import type {
|
||||
NodeBoundsUpdate,
|
||||
NodeId,
|
||||
Point
|
||||
} from '@/renderer/core/layout/types'
|
||||
import { useNodeSnap } from '@/renderer/extensions/vueNodes/composables/useNodeSnap'
|
||||
import { useShiftKeySync } from '@/renderer/extensions/vueNodes/composables/useShiftKeySync'
|
||||
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
|
||||
export const useNodeDrag = createSharedComposable(useNodeDragIndividual)
|
||||
|
||||
function useNodeDragIndividual() {
|
||||
const mutations = useLayoutMutations()
|
||||
const { selectedNodeIds } = storeToRefs(useCanvasStore())
|
||||
|
||||
// Get transform utilities from TransformPane if available
|
||||
const transformState = useTransformState()
|
||||
|
||||
// Snap-to-grid functionality
|
||||
const { shouldSnap, applySnapToPosition } = useNodeSnap()
|
||||
|
||||
// Shift key sync for LiteGraph canvas preview
|
||||
const { trackShiftKey } = useShiftKeySync()
|
||||
|
||||
// Drag state
|
||||
let dragStartPos: Point | null = null
|
||||
let dragStartMouse: Point | null = null
|
||||
let otherSelectedNodesStartPositions: Map<string, Point> | null = null
|
||||
let rafId: number | null = null
|
||||
let stopShiftSync: (() => void) | null = null
|
||||
|
||||
function startDrag(event: PointerEvent, nodeId: NodeId) {
|
||||
const layout = toValue(layoutStore.getNodeLayoutRef(nodeId))
|
||||
if (!layout) return
|
||||
const position = layout.position ?? { x: 0, y: 0 }
|
||||
|
||||
// Track shift key state and sync to canvas for snap preview
|
||||
stopShiftSync = trackShiftKey(event)
|
||||
|
||||
dragStartPos = { ...position }
|
||||
dragStartMouse = { x: event.clientX, y: event.clientY }
|
||||
|
||||
const selectedNodes = toValue(selectedNodeIds)
|
||||
|
||||
// capture the starting positions of all other selected nodes
|
||||
if (selectedNodes?.has(nodeId) && selectedNodes.size > 1) {
|
||||
otherSelectedNodesStartPositions = new Map()
|
||||
|
||||
for (const id of selectedNodes) {
|
||||
// Skip the current node being dragged
|
||||
if (id === nodeId) continue
|
||||
|
||||
const nodeLayout = layoutStore.getNodeLayoutRef(id).value
|
||||
if (nodeLayout) {
|
||||
otherSelectedNodesStartPositions.set(id, { ...nodeLayout.position })
|
||||
}
|
||||
}
|
||||
} else {
|
||||
otherSelectedNodesStartPositions = null
|
||||
}
|
||||
|
||||
mutations.setSource(LayoutSource.Vue)
|
||||
}
|
||||
|
||||
function handleDrag(event: PointerEvent, nodeId: NodeId) {
|
||||
if (!dragStartPos || !dragStartMouse) {
|
||||
return
|
||||
}
|
||||
|
||||
// Throttle position updates using requestAnimationFrame for better performance
|
||||
if (rafId !== null) return // Skip if frame already scheduled
|
||||
|
||||
const { target, pointerId } = event
|
||||
if (target instanceof HTMLElement && !target.hasPointerCapture(pointerId)) {
|
||||
// Delay capture to drag to allow for the Node cloning
|
||||
target.setPointerCapture(pointerId)
|
||||
}
|
||||
rafId = requestAnimationFrame(() => {
|
||||
rafId = null
|
||||
|
||||
if (!dragStartPos || !dragStartMouse) return
|
||||
|
||||
// Calculate mouse delta in screen coordinates
|
||||
const mouseDelta = {
|
||||
x: event.clientX - dragStartMouse.x,
|
||||
y: event.clientY - dragStartMouse.y
|
||||
}
|
||||
|
||||
// Convert to canvas coordinates
|
||||
const canvasOrigin = transformState.screenToCanvas({ x: 0, y: 0 })
|
||||
const canvasWithDelta = transformState.screenToCanvas(mouseDelta)
|
||||
const canvasDelta = {
|
||||
x: canvasWithDelta.x - canvasOrigin.x,
|
||||
y: canvasWithDelta.y - canvasOrigin.y
|
||||
}
|
||||
|
||||
// Calculate new position for the current node
|
||||
const newPosition = {
|
||||
x: dragStartPos.x + canvasDelta.x,
|
||||
y: dragStartPos.y + canvasDelta.y
|
||||
}
|
||||
|
||||
// Apply mutation through the layout system (Vue batches DOM updates automatically)
|
||||
mutations.moveNode(nodeId, newPosition)
|
||||
|
||||
// If we're dragging multiple selected nodes, move them all together
|
||||
if (
|
||||
otherSelectedNodesStartPositions &&
|
||||
otherSelectedNodesStartPositions.size > 0
|
||||
) {
|
||||
for (const [
|
||||
otherNodeId,
|
||||
startPos
|
||||
] of otherSelectedNodesStartPositions) {
|
||||
const newOtherPosition = {
|
||||
x: startPos.x + canvasDelta.x,
|
||||
y: startPos.y + canvasDelta.y
|
||||
}
|
||||
mutations.moveNode(otherNodeId, newOtherPosition)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function endDrag(event: PointerEvent, nodeId: NodeId | undefined) {
|
||||
// Apply snap to final position if snap was active (matches LiteGraph behavior)
|
||||
if (shouldSnap(event) && nodeId) {
|
||||
const boundsUpdates: NodeBoundsUpdate[] = []
|
||||
|
||||
// Snap main node
|
||||
const currentLayout = toValue(layoutStore.getNodeLayoutRef(nodeId))
|
||||
if (currentLayout) {
|
||||
const currentPos = currentLayout.position
|
||||
const snappedPos = applySnapToPosition({ ...currentPos })
|
||||
|
||||
// Only add update if position actually changed
|
||||
if (snappedPos.x !== currentPos.x || snappedPos.y !== currentPos.y) {
|
||||
boundsUpdates.push({
|
||||
nodeId,
|
||||
bounds: {
|
||||
x: snappedPos.x,
|
||||
y: snappedPos.y,
|
||||
width: currentLayout.size.width,
|
||||
height: currentLayout.size.height
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Also snap other selected nodes
|
||||
// Capture all positions at the start to ensure consistent state
|
||||
if (
|
||||
otherSelectedNodesStartPositions &&
|
||||
otherSelectedNodesStartPositions.size > 0
|
||||
) {
|
||||
for (const otherNodeId of otherSelectedNodesStartPositions.keys()) {
|
||||
const nodeLayout = layoutStore.getNodeLayoutRef(otherNodeId).value
|
||||
if (nodeLayout) {
|
||||
const currentPos = { ...nodeLayout.position }
|
||||
const snappedPos = applySnapToPosition(currentPos)
|
||||
|
||||
// Only add update if position actually changed
|
||||
if (
|
||||
snappedPos.x !== currentPos.x ||
|
||||
snappedPos.y !== currentPos.y
|
||||
) {
|
||||
boundsUpdates.push({
|
||||
nodeId: otherNodeId,
|
||||
bounds: {
|
||||
x: snappedPos.x,
|
||||
y: snappedPos.y,
|
||||
width: nodeLayout.size.width,
|
||||
height: nodeLayout.size.height
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply all snap updates in a single batched transaction
|
||||
if (boundsUpdates.length > 0) {
|
||||
layoutStore.batchUpdateNodeBounds(boundsUpdates)
|
||||
}
|
||||
}
|
||||
|
||||
dragStartPos = null
|
||||
dragStartMouse = null
|
||||
otherSelectedNodesStartPositions = null
|
||||
|
||||
// Stop tracking shift key state
|
||||
stopShiftSync?.()
|
||||
stopShiftSync = null
|
||||
|
||||
// Cancel any pending animation frame
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId)
|
||||
rafId = null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
startDrag,
|
||||
handleDrag,
|
||||
endDrag
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,10 @@
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, inject, ref, toValue } from 'vue'
|
||||
import type { CSSProperties, MaybeRefOrGetter } from 'vue'
|
||||
import { computed, toValue } from 'vue'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { TransformStateKey } from '@/renderer/core/layout/injectionKeys'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import type { NodeBoundsUpdate, Point } from '@/renderer/core/layout/types'
|
||||
import { useNodeSnap } from '@/renderer/extensions/vueNodes/composables/useNodeSnap'
|
||||
import { useShiftKeySync } from '@/renderer/extensions/vueNodes/composables/useShiftKeySync'
|
||||
import type { Point } from '@/renderer/core/layout/types'
|
||||
|
||||
/**
|
||||
* Composable for individual Vue node components
|
||||
@@ -18,16 +13,6 @@ import { useShiftKeySync } from '@/renderer/extensions/vueNodes/composables/useS
|
||||
export function useNodeLayout(nodeIdMaybe: MaybeRefOrGetter<string>) {
|
||||
const nodeId = toValue(nodeIdMaybe)
|
||||
const mutations = useLayoutMutations()
|
||||
const { selectedNodeIds } = storeToRefs(useCanvasStore())
|
||||
|
||||
// Get transform utilities from TransformPane if available
|
||||
const transformState = inject(TransformStateKey)
|
||||
|
||||
// Snap-to-grid functionality
|
||||
const { shouldSnap, applySnapToPosition } = useNodeSnap()
|
||||
|
||||
// Shift key sync for LiteGraph canvas preview
|
||||
const { trackShiftKey } = useShiftKeySync()
|
||||
|
||||
// Get the customRef for this node (shared write access)
|
||||
const layoutRef = layoutStore.getNodeLayoutRef(nodeId)
|
||||
@@ -41,215 +26,9 @@ export function useNodeLayout(nodeIdMaybe: MaybeRefOrGetter<string>) {
|
||||
const size = computed(
|
||||
() => layoutRef.value?.size ?? { width: 200, height: 100 }
|
||||
)
|
||||
const bounds = computed(
|
||||
() =>
|
||||
layoutRef.value?.bounds ?? {
|
||||
x: position.value.x,
|
||||
y: position.value.y,
|
||||
width: size.value.width,
|
||||
height: size.value.height
|
||||
}
|
||||
)
|
||||
const isVisible = computed(() => layoutRef.value?.visible ?? true)
|
||||
|
||||
const zIndex = computed(() => layoutRef.value?.zIndex ?? 0)
|
||||
|
||||
// Drag state
|
||||
const isDragging = ref(false)
|
||||
let dragStartPos: Point | null = null
|
||||
let dragStartMouse: Point | null = null
|
||||
let otherSelectedNodesStartPositions: Map<string, Point> | null = null
|
||||
let rafId: number | null = null
|
||||
let stopShiftSync: (() => void) | null = null
|
||||
|
||||
/**
|
||||
* Start dragging the node
|
||||
*/
|
||||
function startDrag(event: PointerEvent) {
|
||||
if (!layoutRef.value || !transformState) return
|
||||
|
||||
// Track shift key state and sync to canvas for snap preview
|
||||
stopShiftSync = trackShiftKey(event)
|
||||
|
||||
isDragging.value = true
|
||||
dragStartPos = { ...position.value }
|
||||
dragStartMouse = { x: event.clientX, y: event.clientY }
|
||||
|
||||
// capture the starting positions of all other selected nodes
|
||||
if (selectedNodeIds?.value?.has(nodeId) && selectedNodeIds.value.size > 1) {
|
||||
otherSelectedNodesStartPositions = new Map()
|
||||
|
||||
// Iterate through all selected node IDs
|
||||
for (const id of selectedNodeIds.value) {
|
||||
// Skip the current node being dragged
|
||||
if (id === nodeId) continue
|
||||
|
||||
const nodeLayout = layoutStore.getNodeLayoutRef(id).value
|
||||
if (nodeLayout) {
|
||||
otherSelectedNodesStartPositions.set(id, { ...nodeLayout.position })
|
||||
}
|
||||
}
|
||||
} else {
|
||||
otherSelectedNodesStartPositions = null
|
||||
}
|
||||
|
||||
// Set mutation source
|
||||
mutations.setSource(LayoutSource.Vue)
|
||||
|
||||
// Capture pointer
|
||||
if (!(event.target instanceof HTMLElement)) return
|
||||
event.target.setPointerCapture(event.pointerId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle drag movement
|
||||
*/
|
||||
const handleDrag = (event: PointerEvent) => {
|
||||
if (
|
||||
!isDragging.value ||
|
||||
!dragStartPos ||
|
||||
!dragStartMouse ||
|
||||
!transformState
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// Throttle position updates using requestAnimationFrame for better performance
|
||||
if (rafId !== null) return // Skip if frame already scheduled
|
||||
|
||||
rafId = requestAnimationFrame(() => {
|
||||
rafId = null
|
||||
|
||||
if (!dragStartPos || !dragStartMouse || !transformState) return
|
||||
|
||||
// Calculate mouse delta in screen coordinates
|
||||
const mouseDelta = {
|
||||
x: event.clientX - dragStartMouse.x,
|
||||
y: event.clientY - dragStartMouse.y
|
||||
}
|
||||
|
||||
// Convert to canvas coordinates
|
||||
const canvasOrigin = transformState.screenToCanvas({ x: 0, y: 0 })
|
||||
const canvasWithDelta = transformState.screenToCanvas(mouseDelta)
|
||||
const canvasDelta = {
|
||||
x: canvasWithDelta.x - canvasOrigin.x,
|
||||
y: canvasWithDelta.y - canvasOrigin.y
|
||||
}
|
||||
|
||||
// Calculate new position for the current node
|
||||
const newPosition = {
|
||||
x: dragStartPos.x + canvasDelta.x,
|
||||
y: dragStartPos.y + canvasDelta.y
|
||||
}
|
||||
|
||||
// Apply mutation through the layout system (Vue batches DOM updates automatically)
|
||||
mutations.moveNode(nodeId, newPosition)
|
||||
|
||||
// If we're dragging multiple selected nodes, move them all together
|
||||
if (
|
||||
otherSelectedNodesStartPositions &&
|
||||
otherSelectedNodesStartPositions.size > 0
|
||||
) {
|
||||
for (const [
|
||||
otherNodeId,
|
||||
startPos
|
||||
] of otherSelectedNodesStartPositions) {
|
||||
const newOtherPosition = {
|
||||
x: startPos.x + canvasDelta.x,
|
||||
y: startPos.y + canvasDelta.y
|
||||
}
|
||||
mutations.moveNode(otherNodeId, newOtherPosition)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* End dragging
|
||||
*/
|
||||
function endDrag(event: PointerEvent) {
|
||||
if (!isDragging.value) return
|
||||
|
||||
// Apply snap to final position if snap was active (matches LiteGraph behavior)
|
||||
if (shouldSnap(event)) {
|
||||
const boundsUpdates: NodeBoundsUpdate[] = []
|
||||
|
||||
// Snap main node
|
||||
const currentLayout = layoutStore.getNodeLayoutRef(nodeId).value
|
||||
if (currentLayout) {
|
||||
const currentPos = currentLayout.position
|
||||
const snappedPos = applySnapToPosition({ ...currentPos })
|
||||
|
||||
// Only add update if position actually changed
|
||||
if (snappedPos.x !== currentPos.x || snappedPos.y !== currentPos.y) {
|
||||
boundsUpdates.push({
|
||||
nodeId,
|
||||
bounds: {
|
||||
x: snappedPos.x,
|
||||
y: snappedPos.y,
|
||||
width: currentLayout.size.width,
|
||||
height: currentLayout.size.height
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Also snap other selected nodes
|
||||
// Capture all positions at the start to ensure consistent state
|
||||
if (
|
||||
otherSelectedNodesStartPositions &&
|
||||
otherSelectedNodesStartPositions.size > 0
|
||||
) {
|
||||
for (const otherNodeId of otherSelectedNodesStartPositions.keys()) {
|
||||
const nodeLayout = layoutStore.getNodeLayoutRef(otherNodeId).value
|
||||
if (nodeLayout) {
|
||||
const currentPos = { ...nodeLayout.position }
|
||||
const snappedPos = applySnapToPosition(currentPos)
|
||||
|
||||
// Only add update if position actually changed
|
||||
if (
|
||||
snappedPos.x !== currentPos.x ||
|
||||
snappedPos.y !== currentPos.y
|
||||
) {
|
||||
boundsUpdates.push({
|
||||
nodeId: otherNodeId,
|
||||
bounds: {
|
||||
x: snappedPos.x,
|
||||
y: snappedPos.y,
|
||||
width: nodeLayout.size.width,
|
||||
height: nodeLayout.size.height
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply all snap updates in a single batched transaction
|
||||
if (boundsUpdates.length > 0) {
|
||||
layoutStore.batchUpdateNodeBounds(boundsUpdates)
|
||||
}
|
||||
}
|
||||
|
||||
isDragging.value = false
|
||||
dragStartPos = null
|
||||
dragStartMouse = null
|
||||
otherSelectedNodesStartPositions = null
|
||||
|
||||
// Stop tracking shift key state
|
||||
stopShiftSync?.()
|
||||
stopShiftSync = null
|
||||
|
||||
// Cancel any pending animation frame
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId)
|
||||
rafId = null
|
||||
}
|
||||
|
||||
// Release pointer
|
||||
if (!(event.target instanceof HTMLElement)) return
|
||||
event.target.releasePointerCapture(event.pointerId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update node position directly (without drag)
|
||||
*/
|
||||
@@ -260,33 +39,11 @@ export function useNodeLayout(nodeIdMaybe: MaybeRefOrGetter<string>) {
|
||||
|
||||
return {
|
||||
// Reactive state (via customRef)
|
||||
layoutRef,
|
||||
position,
|
||||
size,
|
||||
bounds,
|
||||
isVisible,
|
||||
zIndex,
|
||||
isDragging,
|
||||
|
||||
// Mutations
|
||||
moveNodeTo,
|
||||
|
||||
// Drag handlers
|
||||
startDrag,
|
||||
handleDrag,
|
||||
endDrag,
|
||||
|
||||
// Computed styles for Vue templates
|
||||
nodeStyle: computed(
|
||||
(): CSSProperties => ({
|
||||
position: 'absolute' as const,
|
||||
left: `${position.value.x}px`,
|
||||
top: `${position.value.y}px`,
|
||||
width: `${size.value.width}px`,
|
||||
height: `${size.value.height}px`,
|
||||
zIndex: zIndex.value,
|
||||
cursor: isDragging.value ? 'grabbing' : 'grab'
|
||||
})
|
||||
)
|
||||
moveNodeTo
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ const renderedHtml = computed(() => {
|
||||
|
||||
// Methods
|
||||
const startEditing = async () => {
|
||||
if (isEditing.value) return
|
||||
if (isEditing.value || widget.options?.read_only) return
|
||||
|
||||
isEditing.value = true
|
||||
await nextTick()
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
:disabled="widget.options?.read_only"
|
||||
fluid
|
||||
data-capture-wheel="true"
|
||||
@pointerdown.capture.stop
|
||||
/>
|
||||
<LODFallback />
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
|
||||
import type { ComponentWidgetStandardProps } from '@/scripts/domWidget'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
type TextPreviewCustomProps = Omit<
|
||||
InstanceType<typeof TextPreviewWidget>['$props'],
|
||||
@@ -14,15 +15,15 @@ type TextPreviewCustomProps = Omit<
|
||||
|
||||
const PADDING = 16
|
||||
|
||||
export const useTextPreviewWidget = (
|
||||
export function useTextPreviewWidget(
|
||||
options: {
|
||||
minHeight?: number
|
||||
} = {}
|
||||
) => {
|
||||
const widgetConstructor: ComfyWidgetConstructorV2 = (
|
||||
): ComfyWidgetConstructorV2 {
|
||||
function widgetConstructor(
|
||||
node: LGraphNode,
|
||||
inputSpec: InputSpec
|
||||
) => {
|
||||
): IBaseWidget {
|
||||
const widgetValue = ref<string>('')
|
||||
const widget = new ComponentWidgetImpl<
|
||||
string | object,
|
||||
@@ -41,8 +42,10 @@ export const useTextPreviewWidget = (
|
||||
widgetValue.value = typeof value === 'string' ? value : String(value)
|
||||
},
|
||||
getMinHeight: () => options.minHeight ?? 42 + PADDING,
|
||||
serialize: false
|
||||
}
|
||||
serialize: false,
|
||||
read_only: true
|
||||
},
|
||||
type: inputSpec.type
|
||||
})
|
||||
addWidget(node, widget)
|
||||
return widget
|
||||
|
||||
@@ -134,7 +134,11 @@ const coreWidgetDefinitions: Array<[string, WidgetDefinition]> = [
|
||||
],
|
||||
[
|
||||
'markdown',
|
||||
{ component: WidgetMarkdown, aliases: ['MARKDOWN'], essential: false }
|
||||
{
|
||||
component: WidgetMarkdown,
|
||||
aliases: ['MARKDOWN', 'progressText'],
|
||||
essential: false
|
||||
}
|
||||
],
|
||||
['legacy', { component: WidgetLegacy, aliases: [], essential: true }],
|
||||
[
|
||||
|
||||
@@ -230,6 +230,18 @@ export const zComfyNodeDef = z.object({
|
||||
input_order: z.record(z.array(z.string())).optional()
|
||||
})
|
||||
|
||||
export const zDynamicComboInputSpec = z.tuple([
|
||||
z.literal('COMFY_DYNAMICCOMBO_V3'),
|
||||
zComboInputOptions.extend({
|
||||
options: z.array(
|
||||
z.object({
|
||||
inputs: zComfyInputsSpec,
|
||||
key: z.string()
|
||||
})
|
||||
)
|
||||
})
|
||||
])
|
||||
|
||||
// `/object_info`
|
||||
export type ComfyInputsSpec = z.infer<typeof zComfyInputsSpec>
|
||||
export type ComfyOutputTypesSpec = z.infer<typeof zComfyOutputTypesSpec>
|
||||
|
||||
@@ -303,10 +303,11 @@ export class ComponentWidgetImpl<
|
||||
inputSpec: InputSpec
|
||||
props?: P
|
||||
options: DOMWidgetOptions<V>
|
||||
type?: string
|
||||
}) {
|
||||
super({
|
||||
...obj,
|
||||
type: 'custom'
|
||||
type: 'custom',
|
||||
...obj
|
||||
})
|
||||
this.component = obj.component
|
||||
this.inputSpec = obj.inputSpec
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
IStringWidget
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { dynamicWidgets } from '@/core/graph/widgets/dynamicWidgets'
|
||||
import { useBooleanWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useBooleanWidget'
|
||||
import { useChartWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useChartWidget'
|
||||
import { useColorWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useColorWidget'
|
||||
@@ -296,5 +297,6 @@ export const ComfyWidgets: Record<string, ComfyWidgetConstructor> = {
|
||||
IMAGECOMPARE: transformWidgetConstructorV2ToV1(useImageCompareWidget()),
|
||||
CHART: transformWidgetConstructorV2ToV1(useChartWidget()),
|
||||
GALLERIA: transformWidgetConstructorV2ToV1(useGalleriaWidget()),
|
||||
TEXTAREA: transformWidgetConstructorV2ToV1(useTextareaWidget())
|
||||
TEXTAREA: transformWidgetConstructorV2ToV1(useTextareaWidget()),
|
||||
...dynamicWidgets
|
||||
}
|
||||
|
||||
@@ -304,11 +304,13 @@ export const useDialogService = () => {
|
||||
async function prompt({
|
||||
title,
|
||||
message,
|
||||
defaultValue = ''
|
||||
defaultValue = '',
|
||||
placeholder
|
||||
}: {
|
||||
title: string
|
||||
message: string
|
||||
defaultValue?: string
|
||||
placeholder?: string
|
||||
}): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
dialogStore.showDialog({
|
||||
@@ -320,7 +322,8 @@ export const useDialogService = () => {
|
||||
defaultValue,
|
||||
onConfirm: (value: string) => {
|
||||
resolve(value)
|
||||
}
|
||||
},
|
||||
placeholder
|
||||
},
|
||||
dialogComponentProps: {
|
||||
onClose: () => {
|
||||
|
||||
@@ -60,6 +60,10 @@ import { getOrderedInputSpecs } from '@/workbench/utils/nodeDefOrderingUtil'
|
||||
|
||||
import { useExtensionService } from './extensionService'
|
||||
|
||||
export interface HasInitialMinSize {
|
||||
_initialMinSize: { width: number; height: number }
|
||||
}
|
||||
|
||||
export const CONFIG = Symbol()
|
||||
export const GET_CONFIG = Symbol()
|
||||
|
||||
@@ -73,28 +77,184 @@ export const useLitegraphService = () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
const { toggleSelectedNodesMode } = useSelectedLiteGraphItems()
|
||||
|
||||
// TODO: Dedupe `registerNodeDef`; this should remain synchronous.
|
||||
/**
|
||||
* @internal The key for the node definition in the i18n file.
|
||||
*/
|
||||
function nodeKey(node: LGraphNode): string {
|
||||
return `nodeDefs.${normalizeI18nKey(node.constructor.nodeData!.name)}`
|
||||
}
|
||||
/**
|
||||
* @internal Add input sockets to the node. (No widget)
|
||||
*/
|
||||
function addInputSocket(node: LGraphNode, inputSpec: InputSpec) {
|
||||
const inputName = inputSpec.name
|
||||
const nameKey = `${nodeKey(node)}.inputs.${normalizeI18nKey(inputName)}.name`
|
||||
const widgetConstructor = widgetStore.widgets.get(
|
||||
inputSpec.widgetType ?? inputSpec.type
|
||||
)
|
||||
if (widgetConstructor && !inputSpec.forceInput) return
|
||||
|
||||
node.addInput(inputName, inputSpec.type, {
|
||||
shape: inputSpec.isOptional ? RenderShape.HollowCircle : undefined,
|
||||
localized_name: st(nameKey, inputName)
|
||||
})
|
||||
}
|
||||
/**
|
||||
* @internal Setup stroke styles for the node under various conditions.
|
||||
*/
|
||||
function setupStrokeStyles(node: LGraphNode) {
|
||||
node.strokeStyles['running'] = function (this: LGraphNode) {
|
||||
const nodeId = String(this.id)
|
||||
const nodeLocatorId = useWorkflowStore().nodeIdToNodeLocatorId(nodeId)
|
||||
const state =
|
||||
useExecutionStore().nodeLocationProgressStates[nodeLocatorId]?.state
|
||||
if (state === 'running') {
|
||||
return { color: '#0f0' }
|
||||
}
|
||||
}
|
||||
node.strokeStyles['nodeError'] = function (this: LGraphNode) {
|
||||
if (app.lastNodeErrors?.[this.id]?.errors) {
|
||||
return { color: 'red' }
|
||||
}
|
||||
}
|
||||
node.strokeStyles['dragOver'] = function (this: LGraphNode) {
|
||||
if (app.dragOverNode?.id == this.id) {
|
||||
return { color: 'dodgerblue' }
|
||||
}
|
||||
}
|
||||
node.strokeStyles['executionError'] = function (this: LGraphNode) {
|
||||
if (app.lastExecutionError?.node_id == this.id) {
|
||||
return { color: '#f0f', lineWidth: 2 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function. Implemented for use with dynamic widgets
|
||||
*/
|
||||
function addNodeInput(node: LGraphNode, inputSpec: InputSpec) {
|
||||
addInputSocket(node, inputSpec)
|
||||
addInputWidget(node, inputSpec)
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Add a widget to the node. For both primitive types and custom widgets
|
||||
* (unless `socketless`), an input socket is also added.
|
||||
*/
|
||||
function addInputWidget(node: LGraphNode, inputSpec: InputSpec) {
|
||||
const widgetInputSpec = { ...inputSpec }
|
||||
if (inputSpec.widgetType) {
|
||||
widgetInputSpec.type = inputSpec.widgetType
|
||||
}
|
||||
const inputName = inputSpec.name
|
||||
const nameKey = `${nodeKey(node)}.inputs.${normalizeI18nKey(inputName)}.name`
|
||||
const widgetConstructor = widgetStore.widgets.get(widgetInputSpec.type)
|
||||
if (!widgetConstructor || inputSpec.forceInput) return
|
||||
|
||||
const {
|
||||
widget,
|
||||
minWidth = 1,
|
||||
minHeight = 1
|
||||
} = widgetConstructor(
|
||||
node,
|
||||
inputName,
|
||||
transformInputSpecV2ToV1(widgetInputSpec),
|
||||
app
|
||||
) ?? {}
|
||||
|
||||
if (widget) {
|
||||
widget.label = st(nameKey, widget.label ?? inputName)
|
||||
widget.options ??= {}
|
||||
Object.assign(widget.options, {
|
||||
advanced: inputSpec.advanced,
|
||||
hidden: inputSpec.hidden
|
||||
})
|
||||
}
|
||||
|
||||
if (!widget?.options?.socketless) {
|
||||
const inputSpecV1 = transformInputSpecV2ToV1(widgetInputSpec)
|
||||
node.addInput(inputName, inputSpec.type, {
|
||||
shape: inputSpec.isOptional ? RenderShape.HollowCircle : undefined,
|
||||
localized_name: st(nameKey, inputName),
|
||||
widget: { name: inputName, [GET_CONFIG]: () => inputSpecV1 }
|
||||
})
|
||||
}
|
||||
const castedNode = node as LGraphNode & HasInitialMinSize
|
||||
castedNode._initialMinSize.width = Math.max(
|
||||
castedNode._initialMinSize.width,
|
||||
minWidth
|
||||
)
|
||||
castedNode._initialMinSize.height = Math.max(
|
||||
castedNode._initialMinSize.height,
|
||||
minHeight
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Add inputs to the node.
|
||||
*/
|
||||
function addInputs(node: LGraphNode, inputs: Record<string, InputSpec>) {
|
||||
// Use input_order if available to ensure consistent widget ordering
|
||||
//@ts-expect-error was ComfyNode.nodeData as ComfyNodeDefImpl
|
||||
const nodeDefImpl = node.constructor.nodeData as ComfyNodeDefImpl
|
||||
const orderedInputSpecs = getOrderedInputSpecs(nodeDefImpl, inputs)
|
||||
|
||||
// Create sockets and widgets in the determined order
|
||||
for (const inputSpec of orderedInputSpecs) addInputSocket(node, inputSpec)
|
||||
for (const inputSpec of orderedInputSpecs) addInputWidget(node, inputSpec)
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Add outputs to the node.
|
||||
*/
|
||||
function addOutputs(node: LGraphNode, outputs: OutputSpec[]) {
|
||||
for (const output of outputs) {
|
||||
const { name, type, is_list } = output
|
||||
const shapeOptions = is_list ? { shape: LiteGraph.GRID_SHAPE } : {}
|
||||
const nameKey = `${nodeKey(node)}.outputs.${output.index}.name`
|
||||
const typeKey = `dataTypes.${normalizeI18nKey(type)}`
|
||||
const outputOptions = {
|
||||
...shapeOptions,
|
||||
// If the output name is different from the output type, use the output name.
|
||||
// e.g.
|
||||
// - type ("INT"); name ("Positive") => translate name
|
||||
// - type ("FLOAT"); name ("FLOAT") => translate type
|
||||
localized_name: type !== name ? st(nameKey, name) : st(typeKey, name)
|
||||
}
|
||||
node.addOutput(name, type, outputOptions)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Set the initial size of the node.
|
||||
*/
|
||||
function setInitialSize(node: LGraphNode) {
|
||||
const s = node.computeSize()
|
||||
// Expand the width a little to fit widget values on screen.
|
||||
const pad =
|
||||
node.widgets?.length &&
|
||||
!useSettingStore().get('LiteGraph.Node.DefaultPadding')
|
||||
const castedNode = node as LGraphNode & HasInitialMinSize
|
||||
s[0] = Math.max(castedNode._initialMinSize.width, s[0] + (pad ? 60 : 0))
|
||||
s[1] = Math.max(castedNode._initialMinSize.height, s[1])
|
||||
node.setSize(s)
|
||||
}
|
||||
|
||||
function registerSubgraphNodeDef(
|
||||
nodeDefV1: ComfyNodeDefV1,
|
||||
subgraph: Subgraph,
|
||||
instanceData: ExportedSubgraphInstance
|
||||
) {
|
||||
const node = class ComfyNode extends SubgraphNode {
|
||||
const node = class ComfyNode
|
||||
extends SubgraphNode
|
||||
implements HasInitialMinSize
|
||||
{
|
||||
static comfyClass: string
|
||||
static override title: string
|
||||
static override category: string
|
||||
static nodeData: ComfyNodeDefV1 & ComfyNodeDefV2
|
||||
|
||||
/**
|
||||
* @internal The initial minimum size of the node.
|
||||
*/
|
||||
#initialMinSize = { width: 1, height: 1 }
|
||||
/**
|
||||
* @internal The key for the node definition in the i18n file.
|
||||
*/
|
||||
get #nodeKey(): string {
|
||||
return `nodeDefs.${normalizeI18nKey(ComfyNode.nodeData.name)}`
|
||||
}
|
||||
_initialMinSize = { width: 1, height: 1 }
|
||||
|
||||
constructor() {
|
||||
super(app.graph, subgraph, instanceData)
|
||||
@@ -130,165 +290,14 @@ export const useLitegraphService = () => {
|
||||
}
|
||||
})
|
||||
|
||||
this.#setupStrokeStyles()
|
||||
this.#addInputs(ComfyNode.nodeData.inputs)
|
||||
this.#addOutputs(ComfyNode.nodeData.outputs)
|
||||
this.#setInitialSize()
|
||||
setupStrokeStyles(this)
|
||||
addInputs(this, ComfyNode.nodeData.inputs)
|
||||
addOutputs(this, ComfyNode.nodeData.outputs)
|
||||
setInitialSize(this)
|
||||
this.serialize_widgets = true
|
||||
void extensionService.invokeExtensionsAsync('nodeCreated', this)
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Setup stroke styles for the node under various conditions.
|
||||
*/
|
||||
#setupStrokeStyles() {
|
||||
this.strokeStyles['running'] = function (this: LGraphNode) {
|
||||
const nodeId = String(this.id)
|
||||
const nodeLocatorId = useWorkflowStore().nodeIdToNodeLocatorId(nodeId)
|
||||
const state =
|
||||
useExecutionStore().nodeLocationProgressStates[nodeLocatorId]?.state
|
||||
if (state === 'running') {
|
||||
return { color: '#0f0' }
|
||||
}
|
||||
}
|
||||
this.strokeStyles['nodeError'] = function (this: LGraphNode) {
|
||||
if (app.lastNodeErrors?.[this.id]?.errors) {
|
||||
return { color: 'red' }
|
||||
}
|
||||
}
|
||||
this.strokeStyles['dragOver'] = function (this: LGraphNode) {
|
||||
if (app.dragOverNode?.id == this.id) {
|
||||
return { color: 'dodgerblue' }
|
||||
}
|
||||
}
|
||||
this.strokeStyles['executionError'] = function (this: LGraphNode) {
|
||||
if (app.lastExecutionError?.node_id == this.id) {
|
||||
return { color: '#f0f', lineWidth: 2 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Add input sockets to the node. (No widget)
|
||||
*/
|
||||
#addInputSocket(inputSpec: InputSpec) {
|
||||
const inputName = inputSpec.name
|
||||
const nameKey = `${this.#nodeKey}.inputs.${normalizeI18nKey(inputName)}.name`
|
||||
const widgetConstructor = widgetStore.widgets.get(
|
||||
inputSpec.widgetType ?? inputSpec.type
|
||||
)
|
||||
if (widgetConstructor && !inputSpec.forceInput) return
|
||||
|
||||
this.addInput(inputName, inputSpec.type, {
|
||||
shape: inputSpec.isOptional ? RenderShape.HollowCircle : undefined,
|
||||
localized_name: st(nameKey, inputName)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Add a widget to the node. For both primitive types and custom widgets
|
||||
* (unless `socketless`), an input socket is also added.
|
||||
*/
|
||||
#addInputWidget(inputSpec: InputSpec) {
|
||||
const widgetInputSpec = { ...inputSpec }
|
||||
if (inputSpec.widgetType) {
|
||||
widgetInputSpec.type = inputSpec.widgetType
|
||||
}
|
||||
const inputName = inputSpec.name
|
||||
const nameKey = `${this.#nodeKey}.inputs.${normalizeI18nKey(inputName)}.name`
|
||||
const widgetConstructor = widgetStore.widgets.get(widgetInputSpec.type)
|
||||
if (!widgetConstructor || inputSpec.forceInput) return
|
||||
|
||||
const {
|
||||
widget,
|
||||
minWidth = 1,
|
||||
minHeight = 1
|
||||
} = widgetConstructor(
|
||||
this,
|
||||
inputName,
|
||||
transformInputSpecV2ToV1(widgetInputSpec),
|
||||
app
|
||||
) ?? {}
|
||||
|
||||
if (widget) {
|
||||
widget.label = st(nameKey, widget.label ?? inputName)
|
||||
widget.options ??= {}
|
||||
Object.assign(widget.options, {
|
||||
advanced: inputSpec.advanced,
|
||||
hidden: inputSpec.hidden
|
||||
})
|
||||
}
|
||||
|
||||
if (!widget?.options?.socketless) {
|
||||
const inputSpecV1 = transformInputSpecV2ToV1(widgetInputSpec)
|
||||
this.addInput(inputName, inputSpec.type, {
|
||||
shape: inputSpec.isOptional ? RenderShape.HollowCircle : undefined,
|
||||
localized_name: st(nameKey, inputName),
|
||||
widget: { name: inputName, [GET_CONFIG]: () => inputSpecV1 }
|
||||
})
|
||||
}
|
||||
|
||||
this.#initialMinSize.width = Math.max(
|
||||
this.#initialMinSize.width,
|
||||
minWidth
|
||||
)
|
||||
this.#initialMinSize.height = Math.max(
|
||||
this.#initialMinSize.height,
|
||||
minHeight
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Add inputs to the node.
|
||||
*/
|
||||
#addInputs(inputs: Record<string, InputSpec>) {
|
||||
// Use input_order if available to ensure consistent widget ordering
|
||||
const nodeDefImpl = ComfyNode.nodeData as ComfyNodeDefImpl
|
||||
const orderedInputSpecs = getOrderedInputSpecs(nodeDefImpl, inputs)
|
||||
|
||||
// Create sockets and widgets in the determined order
|
||||
for (const inputSpec of orderedInputSpecs)
|
||||
this.#addInputSocket(inputSpec)
|
||||
for (const inputSpec of orderedInputSpecs)
|
||||
this.#addInputWidget(inputSpec)
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Add outputs to the node.
|
||||
*/
|
||||
#addOutputs(outputs: OutputSpec[]) {
|
||||
for (const output of outputs) {
|
||||
const { name, type, is_list } = output
|
||||
const shapeOptions = is_list ? { shape: LiteGraph.GRID_SHAPE } : {}
|
||||
const nameKey = `${this.#nodeKey}.outputs.${output.index}.name`
|
||||
const typeKey = `dataTypes.${normalizeI18nKey(type)}`
|
||||
const outputOptions = {
|
||||
...shapeOptions,
|
||||
// If the output name is different from the output type, use the output name.
|
||||
// e.g.
|
||||
// - type ("INT"); name ("Positive") => translate name
|
||||
// - type ("FLOAT"); name ("FLOAT") => translate type
|
||||
localized_name:
|
||||
type !== name ? st(nameKey, name) : st(typeKey, name)
|
||||
}
|
||||
this.addOutput(name, type, outputOptions)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Set the initial size of the node.
|
||||
*/
|
||||
#setInitialSize() {
|
||||
const s = this.computeSize()
|
||||
// Expand the width a little to fit widget values on screen.
|
||||
const pad =
|
||||
this.widgets?.length &&
|
||||
!useSettingStore().get('LiteGraph.Node.DefaultPadding')
|
||||
s[0] = Math.max(this.#initialMinSize.width, s[0] + (pad ? 60 : 0))
|
||||
s[1] = Math.max(this.#initialMinSize.height, s[1])
|
||||
this.setSize(s)
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the node from a serialised node. Keep 'name', 'type', 'shape',
|
||||
* and 'localized_name' information from the original node definition.
|
||||
@@ -369,29 +378,23 @@ export const useLitegraphService = () => {
|
||||
}
|
||||
|
||||
async function registerNodeDef(nodeId: string, nodeDefV1: ComfyNodeDefV1) {
|
||||
const node = class ComfyNode extends LGraphNode {
|
||||
const node = class ComfyNode
|
||||
extends LGraphNode
|
||||
implements HasInitialMinSize
|
||||
{
|
||||
static comfyClass: string
|
||||
static override title: string
|
||||
static override category: string
|
||||
static nodeData: ComfyNodeDefV1 & ComfyNodeDefV2
|
||||
|
||||
/**
|
||||
* @internal The initial minimum size of the node.
|
||||
*/
|
||||
#initialMinSize = { width: 1, height: 1 }
|
||||
/**
|
||||
* @internal The key for the node definition in the i18n file.
|
||||
*/
|
||||
get #nodeKey(): string {
|
||||
return `nodeDefs.${normalizeI18nKey(ComfyNode.nodeData.name)}`
|
||||
}
|
||||
_initialMinSize = { width: 1, height: 1 }
|
||||
|
||||
constructor(title: string) {
|
||||
super(title)
|
||||
this.#setupStrokeStyles()
|
||||
this.#addInputs(ComfyNode.nodeData.inputs)
|
||||
this.#addOutputs(ComfyNode.nodeData.outputs)
|
||||
this.#setInitialSize()
|
||||
setupStrokeStyles(this)
|
||||
addInputs(this, ComfyNode.nodeData.inputs)
|
||||
addOutputs(this, ComfyNode.nodeData.outputs)
|
||||
setInitialSize(this)
|
||||
this.serialize_widgets = true
|
||||
|
||||
// Mark API Nodes yellow by default to distinguish with other nodes.
|
||||
@@ -403,168 +406,6 @@ export const useLitegraphService = () => {
|
||||
void extensionService.invokeExtensionsAsync('nodeCreated', this)
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Setup stroke styles for the node under various conditions.
|
||||
*/
|
||||
#setupStrokeStyles() {
|
||||
this.strokeStyles['running'] = function (this: LGraphNode) {
|
||||
const nodeId = String(this.id)
|
||||
const nodeLocatorId = useWorkflowStore().nodeIdToNodeLocatorId(nodeId)
|
||||
const state =
|
||||
useExecutionStore().nodeLocationProgressStates[nodeLocatorId]?.state
|
||||
if (state === 'running') {
|
||||
return { color: '#0f0' }
|
||||
}
|
||||
}
|
||||
this.strokeStyles['nodeError'] = function (this: LGraphNode) {
|
||||
if (app.lastNodeErrors?.[this.id]?.errors) {
|
||||
return { color: 'red' }
|
||||
}
|
||||
}
|
||||
this.strokeStyles['dragOver'] = function (this: LGraphNode) {
|
||||
if (app.dragOverNode?.id == this.id) {
|
||||
return { color: 'dodgerblue' }
|
||||
}
|
||||
}
|
||||
this.strokeStyles['executionError'] = function (this: LGraphNode) {
|
||||
if (app.lastExecutionError?.node_id == this.id) {
|
||||
return { color: '#f0f', lineWidth: 2 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Add input sockets to the node. (No widget)
|
||||
*/
|
||||
#addInputSocket(inputSpec: InputSpec) {
|
||||
const inputName = inputSpec.name
|
||||
const nameKey = `${this.#nodeKey}.inputs.${normalizeI18nKey(inputName)}.name`
|
||||
const widgetConstructor = widgetStore.widgets.get(
|
||||
inputSpec.widgetType ?? inputSpec.type
|
||||
)
|
||||
if (widgetConstructor && !inputSpec.forceInput) return
|
||||
|
||||
this.addInput(inputName, inputSpec.type, {
|
||||
shape: inputSpec.isOptional ? RenderShape.HollowCircle : undefined,
|
||||
localized_name: st(nameKey, inputName)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Add a widget to the node. For both primitive types and custom widgets
|
||||
* (unless `socketless`), an input socket is also added.
|
||||
*/
|
||||
#addInputWidget(inputSpec: InputSpec) {
|
||||
const widgetInputSpec = { ...inputSpec }
|
||||
if (inputSpec.widgetType) {
|
||||
widgetInputSpec.type = inputSpec.widgetType
|
||||
}
|
||||
const inputName = inputSpec.name
|
||||
const nameKey = `${this.#nodeKey}.inputs.${normalizeI18nKey(inputName)}.name`
|
||||
const widgetConstructor = widgetStore.widgets.get(widgetInputSpec.type)
|
||||
if (!widgetConstructor || inputSpec.forceInput) return
|
||||
|
||||
const {
|
||||
widget,
|
||||
minWidth = 1,
|
||||
minHeight = 1
|
||||
} = widgetConstructor(
|
||||
this,
|
||||
inputName,
|
||||
transformInputSpecV2ToV1(widgetInputSpec),
|
||||
app
|
||||
) ?? {}
|
||||
|
||||
if (widget) {
|
||||
// Check if this is an Asset Browser button widget
|
||||
const isAssetBrowserButton =
|
||||
widget.type === 'button' && widget.value === 'Select model'
|
||||
|
||||
if (isAssetBrowserButton) {
|
||||
// Preserve Asset Browser button label (don't translate)
|
||||
widget.label = String(widget.value)
|
||||
} else {
|
||||
// Apply normal translation for other widgets
|
||||
widget.label = st(nameKey, widget.label ?? inputName)
|
||||
}
|
||||
|
||||
widget.options ??= {}
|
||||
Object.assign(widget.options, {
|
||||
advanced: inputSpec.advanced,
|
||||
hidden: inputSpec.hidden
|
||||
})
|
||||
}
|
||||
|
||||
if (!widget?.options?.socketless) {
|
||||
const inputSpecV1 = transformInputSpecV2ToV1(widgetInputSpec)
|
||||
this.addInput(inputName, inputSpec.type, {
|
||||
shape: inputSpec.isOptional ? RenderShape.HollowCircle : undefined,
|
||||
localized_name: st(nameKey, inputName),
|
||||
widget: { name: inputName, [GET_CONFIG]: () => inputSpecV1 }
|
||||
})
|
||||
}
|
||||
|
||||
this.#initialMinSize.width = Math.max(
|
||||
this.#initialMinSize.width,
|
||||
minWidth
|
||||
)
|
||||
this.#initialMinSize.height = Math.max(
|
||||
this.#initialMinSize.height,
|
||||
minHeight
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Add inputs to the node.
|
||||
*/
|
||||
#addInputs(inputs: Record<string, InputSpec>) {
|
||||
// Use input_order if available to ensure consistent widget ordering
|
||||
const nodeDefImpl = ComfyNode.nodeData as ComfyNodeDefImpl
|
||||
const orderedInputSpecs = getOrderedInputSpecs(nodeDefImpl, inputs)
|
||||
|
||||
// Create sockets and widgets in the determined order
|
||||
for (const inputSpec of orderedInputSpecs)
|
||||
this.#addInputSocket(inputSpec)
|
||||
for (const inputSpec of orderedInputSpecs)
|
||||
this.#addInputWidget(inputSpec)
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Add outputs to the node.
|
||||
*/
|
||||
#addOutputs(outputs: OutputSpec[]) {
|
||||
for (const output of outputs) {
|
||||
const { name, type, is_list } = output
|
||||
const shapeOptions = is_list ? { shape: LiteGraph.GRID_SHAPE } : {}
|
||||
const nameKey = `${this.#nodeKey}.outputs.${output.index}.name`
|
||||
const typeKey = `dataTypes.${normalizeI18nKey(type)}`
|
||||
const outputOptions = {
|
||||
...shapeOptions,
|
||||
// If the output name is different from the output type, use the output name.
|
||||
// e.g.
|
||||
// - type ("INT"); name ("Positive") => translate name
|
||||
// - type ("FLOAT"); name ("FLOAT") => translate type
|
||||
localized_name:
|
||||
type !== name ? st(nameKey, name) : st(typeKey, name)
|
||||
}
|
||||
this.addOutput(name, type, outputOptions)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Set the initial size of the node.
|
||||
*/
|
||||
#setInitialSize() {
|
||||
const s = this.computeSize()
|
||||
// Expand the width a little to fit widget values on screen.
|
||||
const pad =
|
||||
this.widgets?.length &&
|
||||
!useSettingStore().get('LiteGraph.Node.DefaultPadding')
|
||||
s[0] = Math.max(this.#initialMinSize.width, s[0] + (pad ? 60 : 0))
|
||||
s[1] = Math.max(this.#initialMinSize.height, s[1])
|
||||
this.setSize(s)
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the node from a serialised node. Keep 'name', 'type', 'shape',
|
||||
* and 'localized_name' information from the original node definition.
|
||||
@@ -836,11 +677,12 @@ export const useLitegraphService = () => {
|
||||
const newLabel = await useDialogService().prompt({
|
||||
title: t('g.rename'),
|
||||
message: t('g.enterNewName') + ':',
|
||||
defaultValue: overWidget.label ?? overWidget.name
|
||||
defaultValue: overWidget.label,
|
||||
placeholder: overWidget.name
|
||||
})
|
||||
if (!newLabel) return
|
||||
overWidget.label = newLabel
|
||||
input.label = newLabel
|
||||
if (newLabel === null) return
|
||||
overWidget.label = newLabel || undefined
|
||||
input.label = newLabel || undefined
|
||||
useCanvasStore().canvas?.setDirty(true)
|
||||
}
|
||||
})
|
||||
@@ -1049,6 +891,7 @@ export const useLitegraphService = () => {
|
||||
registerNodeDef,
|
||||
registerSubgraphNodeDef,
|
||||
addNodeOnGraph,
|
||||
addNodeInput,
|
||||
getCanvasCenter,
|
||||
goToNode,
|
||||
resetView,
|
||||
|
||||
@@ -114,7 +114,10 @@ export const useWorkflowPacks = (options: UseNodePacksOptions = {}) => {
|
||||
const getWorkflowPacks = async () => {
|
||||
if (!app.graph) return []
|
||||
const allNodes = collectAllNodes(app.graph)
|
||||
if (!allNodes.length) return []
|
||||
if (!allNodes.length) {
|
||||
workflowPacks.value = []
|
||||
return []
|
||||
}
|
||||
const packs = await Promise.all(allNodes.map(workflowNodeToPack))
|
||||
workflowPacks.value = packs.filter((pack) => pack !== undefined)
|
||||
}
|
||||
|
||||
161
tests-ui/LGraph.type.test.ts
Normal file
161
tests-ui/LGraph.type.test.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import type { RendererType, LGraphExtra } from '../src/lib/litegraph/src/LGraph'
|
||||
|
||||
/**
|
||||
* Tests for the RendererType (formerly rendererType) type definition
|
||||
*
|
||||
* This tests the type rename from 'rendererType' to 'RendererType' to follow
|
||||
* TypeScript naming conventions where types should use PascalCase.
|
||||
*/
|
||||
describe('RendererType', () => {
|
||||
describe('type definition', () => {
|
||||
it('should accept "LG" as a valid value', () => {
|
||||
const renderer: RendererType = 'LG'
|
||||
expect(renderer).toBe('LG')
|
||||
})
|
||||
|
||||
it('should accept "Vue" as a valid value', () => {
|
||||
const renderer: RendererType = 'Vue'
|
||||
expect(renderer).toBe('Vue')
|
||||
})
|
||||
|
||||
it('should be assignable to LGraphExtra workflowRendererVersion property', () => {
|
||||
const extra: LGraphExtra = {
|
||||
workflowRendererVersion: 'LG'
|
||||
}
|
||||
expect(extra.workflowRendererVersion).toBe('LG')
|
||||
|
||||
extra.workflowRendererVersion = 'Vue'
|
||||
expect(extra.workflowRendererVersion).toBe('Vue')
|
||||
})
|
||||
|
||||
it('should allow undefined for workflowRendererVersion', () => {
|
||||
const extra: LGraphExtra = {
|
||||
workflowRendererVersion: undefined
|
||||
}
|
||||
expect(extra.workflowRendererVersion).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should work with optional chaining', () => {
|
||||
const extra: LGraphExtra = {}
|
||||
const version = extra.workflowRendererVersion
|
||||
expect(version).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('type safety', () => {
|
||||
it('should prevent assignment of invalid string values at compile time', () => {
|
||||
// This test validates TypeScript compiler behavior
|
||||
// The following would cause a compile error:
|
||||
// const invalid: RendererType = 'Invalid'
|
||||
|
||||
// We can test runtime validation if needed
|
||||
const validValues: RendererType[] = ['LG', 'Vue']
|
||||
validValues.forEach((value) => {
|
||||
expect(['LG', 'Vue']).toContain(value)
|
||||
})
|
||||
})
|
||||
|
||||
it('should work with type guards', () => {
|
||||
const isValidRendererType = (value: string): value is RendererType => {
|
||||
return value === 'LG' || value === 'Vue'
|
||||
}
|
||||
|
||||
expect(isValidRendererType('LG')).toBe(true)
|
||||
expect(isValidRendererType('Vue')).toBe(true)
|
||||
expect(isValidRendererType('Invalid')).toBe(false)
|
||||
})
|
||||
|
||||
it('should work in switch statements', () => {
|
||||
const testSwitch = (renderer: RendererType): string => {
|
||||
switch (renderer) {
|
||||
case 'LG':
|
||||
return 'Legacy LiteGraph'
|
||||
case 'Vue':
|
||||
return 'Vue Renderer'
|
||||
default:
|
||||
// TypeScript should ensure this is unreachable
|
||||
const _exhaustive: never = renderer
|
||||
return _exhaustive
|
||||
}
|
||||
}
|
||||
|
||||
expect(testSwitch('LG')).toBe('Legacy LiteGraph')
|
||||
expect(testSwitch('Vue')).toBe('Vue Renderer')
|
||||
})
|
||||
})
|
||||
|
||||
describe('usage in graph serialization', () => {
|
||||
it('should serialize and deserialize RendererType correctly', () => {
|
||||
const extra: LGraphExtra = {
|
||||
workflowRendererVersion: 'Vue'
|
||||
}
|
||||
|
||||
const serialized = JSON.stringify(extra)
|
||||
const deserialized: LGraphExtra = JSON.parse(serialized)
|
||||
|
||||
expect(deserialized.workflowRendererVersion).toBe('Vue')
|
||||
})
|
||||
|
||||
it('should handle missing workflowRendererVersion in deserialization', () => {
|
||||
const json = '{}'
|
||||
const extra: LGraphExtra = JSON.parse(json)
|
||||
|
||||
expect(extra.workflowRendererVersion).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should handle null workflowRendererVersion', () => {
|
||||
const extra: LGraphExtra = {
|
||||
workflowRendererVersion: undefined
|
||||
}
|
||||
|
||||
expect(extra.workflowRendererVersion).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('backward compatibility considerations', () => {
|
||||
it('should work with both old and new renderer formats', () => {
|
||||
// Even though the type is renamed, the values remain the same
|
||||
const lgRenderer: RendererType = 'LG'
|
||||
const vueRenderer: RendererType = 'Vue'
|
||||
|
||||
expect(lgRenderer).toBe('LG')
|
||||
expect(vueRenderer).toBe('Vue')
|
||||
})
|
||||
|
||||
it('should support migration scenarios', () => {
|
||||
// Simulating reading from an old format
|
||||
const oldFormat = { workflowRendererVersion: 'LG' as RendererType }
|
||||
|
||||
// Converting to new format (type is already compatible)
|
||||
const newFormat: LGraphExtra = {
|
||||
workflowRendererVersion: oldFormat.workflowRendererVersion
|
||||
}
|
||||
|
||||
expect(newFormat.workflowRendererVersion).toBe('LG')
|
||||
})
|
||||
})
|
||||
|
||||
describe('default values and initialization', () => {
|
||||
it('should handle default initialization', () => {
|
||||
const extra: LGraphExtra = {}
|
||||
|
||||
// workflowRendererVersion should be undefined by default
|
||||
expect(extra.workflowRendererVersion).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should support nullish coalescing', () => {
|
||||
const extra: LGraphExtra = {}
|
||||
const renderer = extra.workflowRendererVersion ?? 'LG'
|
||||
|
||||
expect(renderer).toBe('LG')
|
||||
})
|
||||
|
||||
it('should support optional chaining with default', () => {
|
||||
let extra: LGraphExtra | undefined = undefined
|
||||
const renderer = extra?.workflowRendererVersion ?? 'LG'
|
||||
|
||||
expect(renderer).toBe('LG')
|
||||
})
|
||||
})
|
||||
})
|
||||
1115
tests-ui/ensureCorrectLayoutScale.test.ts
Normal file
1115
tests-ui/ensureCorrectLayoutScale.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
|
||||
// Create a mock canvas context for transform testing
|
||||
function createMockCanvasContext() {
|
||||
@@ -27,10 +28,12 @@ function createMockCanvasContext() {
|
||||
}
|
||||
|
||||
describe('useTransformState', () => {
|
||||
let transformState: ReturnType<typeof useTransformState>
|
||||
const transformState = useTransformState()
|
||||
|
||||
beforeEach(() => {
|
||||
transformState = useTransformState()
|
||||
transformState.syncWithCanvas({
|
||||
ds: { offset: [0, 0] }
|
||||
} as unknown as LGraphCanvas)
|
||||
})
|
||||
|
||||
describe('initial state', () => {
|
||||
@@ -179,8 +182,8 @@ describe('useTransformState', () => {
|
||||
it('should calculate correct screen bounds for a node', () => {
|
||||
const { getNodeScreenBounds } = transformState
|
||||
|
||||
const nodePos = [10, 20]
|
||||
const nodeSize = [200, 100]
|
||||
const nodePos: [number, number] = [10, 20]
|
||||
const nodeSize: [number, number] = [200, 100]
|
||||
const bounds = getNodeScreenBounds(nodePos, nodeSize)
|
||||
|
||||
// Top-left: canvasToScreen(10, 20) = (220, 140)
|
||||
@@ -206,8 +209,8 @@ describe('useTransformState', () => {
|
||||
it('should return true for nodes inside viewport', () => {
|
||||
const { isNodeInViewport } = transformState
|
||||
|
||||
const nodePos = [100, 100]
|
||||
const nodeSize = [200, 100]
|
||||
const nodePos: [number, number] = [100, 100]
|
||||
const nodeSize: [number, number] = [200, 100]
|
||||
|
||||
expect(isNodeInViewport(nodePos, nodeSize, viewport)).toBe(true)
|
||||
})
|
||||
@@ -232,8 +235,8 @@ describe('useTransformState', () => {
|
||||
const { isNodeInViewport } = transformState
|
||||
|
||||
// Node slightly outside but within margin
|
||||
const nodePos = [-50, -50]
|
||||
const nodeSize = [100, 100]
|
||||
const nodePos: [number, number] = [-50, -50]
|
||||
const nodeSize: [number, number] = [100, 100]
|
||||
|
||||
expect(isNodeInViewport(nodePos, nodeSize, viewport, 0.2)).toBe(true)
|
||||
})
|
||||
@@ -242,8 +245,8 @@ describe('useTransformState', () => {
|
||||
const { isNodeInViewport } = transformState
|
||||
|
||||
// Node is in viewport but too small
|
||||
const nodePos = [100, 100]
|
||||
const nodeSize = [3, 3] // Less than 4 pixels
|
||||
const nodePos: [number, number] = [100, 100]
|
||||
const nodeSize: [number, number] = [3, 3] // Less than 4 pixels
|
||||
|
||||
expect(isNodeInViewport(nodePos, nodeSize, viewport)).toBe(false)
|
||||
})
|
||||
|
||||
@@ -1763,7 +1763,7 @@ describe('useNodePricing', () => {
|
||||
const node = createMockNode('GeminiImageNode')
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.03 per 1K tokens')
|
||||
expect(price).toBe('~$0.039/Image (1K)')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -277,6 +277,32 @@ describe('useMissingNodes', () => {
|
||||
// Should update missing packs (2 missing since pack-3 is installed)
|
||||
expect(missingNodePacks.value).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('clears missing nodes when switching to empty workflow', async () => {
|
||||
const workflowPacksRef = ref(mockWorkflowPacks)
|
||||
mockUseWorkflowPacks.mockReturnValue({
|
||||
workflowPacks: workflowPacksRef,
|
||||
isLoading: ref(false),
|
||||
error: ref(null),
|
||||
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
|
||||
isReady: ref(true),
|
||||
filterWorkflowPack: vi.fn()
|
||||
})
|
||||
|
||||
const { hasMissingNodes, missingNodePacks } = useMissingNodes()
|
||||
|
||||
// Should have missing nodes initially (2 missing since pack-3 is installed)
|
||||
expect(missingNodePacks.value).toHaveLength(2)
|
||||
expect(hasMissingNodes.value).toBe(true)
|
||||
|
||||
// Switch to empty workflow (simulates creating a new empty workflow)
|
||||
workflowPacksRef.value = []
|
||||
await nextTick()
|
||||
|
||||
// Should clear missing nodes
|
||||
expect(missingNodePacks.value).toHaveLength(0)
|
||||
expect(hasMissingNodes.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('missing core nodes detection', () => {
|
||||
|
||||
@@ -1,483 +0,0 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
|
||||
|
||||
// Mock canvas context for testing
|
||||
const createMockCanvasContext = () => ({
|
||||
ds: {
|
||||
offset: [0, 0] as [number, number],
|
||||
scale: 1
|
||||
}
|
||||
})
|
||||
|
||||
// Skip this entire suite on CI to avoid flaky performance timing
|
||||
const isCI = Boolean(process.env.CI)
|
||||
const describeIfNotCI = isCI ? describe.skip : describe
|
||||
|
||||
describeIfNotCI.skip('Transform Performance', () => {
|
||||
let transformState: ReturnType<typeof useTransformState>
|
||||
let mockCanvas: any
|
||||
|
||||
beforeEach(() => {
|
||||
transformState = useTransformState()
|
||||
mockCanvas = createMockCanvasContext()
|
||||
})
|
||||
|
||||
describe('coordinate conversion performance', () => {
|
||||
it('should handle large batches of coordinate conversions efficiently', () => {
|
||||
// Set up a realistic transform state
|
||||
mockCanvas.ds.offset = [500, 300]
|
||||
mockCanvas.ds.scale = 1.5
|
||||
transformState.syncWithCanvas(mockCanvas)
|
||||
|
||||
const conversionCount = 10000
|
||||
const points = Array.from({ length: conversionCount }, () => ({
|
||||
x: Math.random() * 5000,
|
||||
y: Math.random() * 3000
|
||||
}))
|
||||
|
||||
// Benchmark canvas to screen conversions
|
||||
const canvasToScreenStart = performance.now()
|
||||
const screenPoints = points.map((point) =>
|
||||
transformState.canvasToScreen(point)
|
||||
)
|
||||
const canvasToScreenTime = performance.now() - canvasToScreenStart
|
||||
|
||||
// Benchmark screen to canvas conversions
|
||||
const screenToCanvasStart = performance.now()
|
||||
const backToCanvas = screenPoints.map((point) =>
|
||||
transformState.screenToCanvas(point)
|
||||
)
|
||||
const screenToCanvasTime = performance.now() - screenToCanvasStart
|
||||
|
||||
// Performance expectations
|
||||
expect(canvasToScreenTime).toBeLessThan(20) // 10k conversions in under 20ms
|
||||
expect(screenToCanvasTime).toBeLessThan(20) // 10k conversions in under 20ms
|
||||
|
||||
// Verify accuracy of round-trip conversion
|
||||
const maxError = points.reduce((max, original, i) => {
|
||||
const converted = backToCanvas[i]
|
||||
const errorX = Math.abs(original.x - converted.x)
|
||||
const errorY = Math.abs(original.y - converted.y)
|
||||
return Math.max(max, errorX, errorY)
|
||||
}, 0)
|
||||
|
||||
expect(maxError).toBeLessThan(0.001) // Sub-pixel accuracy
|
||||
})
|
||||
|
||||
it('should maintain performance across different zoom levels', () => {
|
||||
const zoomLevels = [0.1, 0.5, 1.0, 2.0, 5.0, 10.0]
|
||||
const conversionCount = 1000
|
||||
const testPoints = Array.from({ length: conversionCount }, () => ({
|
||||
x: Math.random() * 2000,
|
||||
y: Math.random() * 1500
|
||||
}))
|
||||
|
||||
const performanceResults: number[] = []
|
||||
|
||||
zoomLevels.forEach((scale) => {
|
||||
mockCanvas.ds.scale = scale
|
||||
transformState.syncWithCanvas(mockCanvas)
|
||||
|
||||
const startTime = performance.now()
|
||||
testPoints.forEach((point) => {
|
||||
const screen = transformState.canvasToScreen(point)
|
||||
transformState.screenToCanvas(screen)
|
||||
})
|
||||
const duration = performance.now() - startTime
|
||||
|
||||
performanceResults.push(duration)
|
||||
})
|
||||
|
||||
// Performance should be consistent across zoom levels
|
||||
const maxTime = Math.max(...performanceResults)
|
||||
const minTime = Math.min(...performanceResults)
|
||||
const variance = (maxTime - minTime) / minTime
|
||||
|
||||
expect(maxTime).toBeLessThan(20) // All zoom levels under 20ms
|
||||
expect(variance).toBeLessThan(3.0) // Less than 300% variance between zoom levels
|
||||
})
|
||||
|
||||
it('should handle extreme coordinate values efficiently', () => {
|
||||
// Test with very large coordinate values
|
||||
const extremePoints = [
|
||||
{ x: -100000, y: -100000 },
|
||||
{ x: 100000, y: 100000 },
|
||||
{ x: 0, y: 0 },
|
||||
{ x: -50000, y: 50000 },
|
||||
{ x: 1e6, y: -1e6 }
|
||||
]
|
||||
|
||||
// Test at extreme zoom levels
|
||||
const extremeScales = [0.001, 1000]
|
||||
|
||||
extremeScales.forEach((scale) => {
|
||||
mockCanvas.ds.scale = scale
|
||||
mockCanvas.ds.offset = [1000, 500]
|
||||
transformState.syncWithCanvas(mockCanvas)
|
||||
|
||||
const startTime = performance.now()
|
||||
|
||||
// Convert each point 100 times
|
||||
extremePoints.forEach((point) => {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const screen = transformState.canvasToScreen(point)
|
||||
transformState.screenToCanvas(screen)
|
||||
}
|
||||
})
|
||||
|
||||
const duration = performance.now() - startTime
|
||||
|
||||
expect(duration).toBeLessThan(5) // Should handle extremes efficiently
|
||||
expect(
|
||||
Number.isFinite(transformState.canvasToScreen(extremePoints[0]).x)
|
||||
).toBe(true)
|
||||
expect(
|
||||
Number.isFinite(transformState.canvasToScreen(extremePoints[0]).y)
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('viewport culling performance', () => {
|
||||
it('should efficiently determine node visibility for large numbers of nodes', () => {
|
||||
// Set up realistic viewport
|
||||
const viewport = { width: 1920, height: 1080 }
|
||||
|
||||
// Generate many node positions
|
||||
const nodeCount = 1000
|
||||
const nodes = Array.from({ length: nodeCount }, () => ({
|
||||
pos: [Math.random() * 10000, Math.random() * 6000] as ArrayLike<number>,
|
||||
size: [
|
||||
150 + Math.random() * 100,
|
||||
100 + Math.random() * 50
|
||||
] as ArrayLike<number>
|
||||
}))
|
||||
|
||||
// Test at different zoom levels and positions
|
||||
const testConfigs = [
|
||||
{ scale: 0.5, offset: [0, 0] },
|
||||
{ scale: 1.0, offset: [2000, 1000] },
|
||||
{ scale: 2.0, offset: [-1000, -500] }
|
||||
]
|
||||
|
||||
testConfigs.forEach((config) => {
|
||||
mockCanvas.ds.scale = config.scale
|
||||
mockCanvas.ds.offset = config.offset
|
||||
transformState.syncWithCanvas(mockCanvas)
|
||||
|
||||
const startTime = performance.now()
|
||||
|
||||
// Test viewport culling for all nodes
|
||||
const visibleNodes = nodes.filter((node) =>
|
||||
transformState.isNodeInViewport(node.pos, node.size, viewport)
|
||||
)
|
||||
|
||||
const cullTime = performance.now() - startTime
|
||||
|
||||
expect(cullTime).toBeLessThan(10) // 1000 nodes culled in under 10ms
|
||||
expect(visibleNodes.length).toBeLessThan(nodeCount) // Some culling should occur
|
||||
expect(visibleNodes.length).toBeGreaterThanOrEqual(0) // Sanity check
|
||||
})
|
||||
})
|
||||
|
||||
it('should optimize culling with adaptive margins', () => {
|
||||
const viewport = { width: 1280, height: 720 }
|
||||
const testNode = {
|
||||
pos: [1300, 100] as ArrayLike<number>, // Just outside viewport
|
||||
size: [200, 100] as ArrayLike<number>
|
||||
}
|
||||
|
||||
// Test margin adaptation at different zoom levels
|
||||
const zoomTests = [
|
||||
{ scale: 0.05, expectedVisible: true }, // Low zoom, larger margin
|
||||
{ scale: 1.0, expectedVisible: true }, // Normal zoom, standard margin
|
||||
{ scale: 4.0, expectedVisible: false } // High zoom, tighter margin
|
||||
]
|
||||
|
||||
const marginTests: boolean[] = []
|
||||
const timings: number[] = []
|
||||
|
||||
zoomTests.forEach((test) => {
|
||||
mockCanvas.ds.scale = test.scale
|
||||
mockCanvas.ds.offset = [0, 0]
|
||||
transformState.syncWithCanvas(mockCanvas)
|
||||
|
||||
const startTime = performance.now()
|
||||
const isVisible = transformState.isNodeInViewport(
|
||||
testNode.pos,
|
||||
testNode.size,
|
||||
viewport,
|
||||
0.2 // 20% margin
|
||||
)
|
||||
const duration = performance.now() - startTime
|
||||
|
||||
marginTests.push(isVisible)
|
||||
timings.push(duration)
|
||||
})
|
||||
|
||||
// All culling operations should be very fast
|
||||
timings.forEach((time) => {
|
||||
expect(time).toBeLessThan(0.1) // Individual culling under 0.1ms
|
||||
})
|
||||
|
||||
// Verify adaptive behavior (margins should work as expected)
|
||||
expect(marginTests[0]).toBe(zoomTests[0].expectedVisible)
|
||||
expect(marginTests[2]).toBe(zoomTests[2].expectedVisible)
|
||||
})
|
||||
|
||||
it('should handle size-based culling efficiently', () => {
|
||||
// Test nodes of various sizes
|
||||
const nodeSizes = [
|
||||
[1, 1], // Tiny node
|
||||
[5, 5], // Small node
|
||||
[50, 50], // Medium node
|
||||
[200, 100], // Large node
|
||||
[500, 300] // Very large node
|
||||
]
|
||||
|
||||
const viewport = { width: 1920, height: 1080 }
|
||||
|
||||
// Position all nodes in viewport center
|
||||
const centerPos = [960, 540] as ArrayLike<number>
|
||||
|
||||
nodeSizes.forEach((size) => {
|
||||
// Test at very low zoom where size culling should activate
|
||||
mockCanvas.ds.scale = 0.01 // Very low zoom
|
||||
transformState.syncWithCanvas(mockCanvas)
|
||||
|
||||
const startTime = performance.now()
|
||||
const isVisible = transformState.isNodeInViewport(
|
||||
centerPos,
|
||||
size as ArrayLike<number>,
|
||||
viewport
|
||||
)
|
||||
const cullTime = performance.now() - startTime
|
||||
|
||||
expect(cullTime).toBeLessThan(0.1) // Size culling under 0.1ms
|
||||
|
||||
// At 0.01 zoom, nodes need to be 400+ pixels to show as 4+ screen pixels
|
||||
const screenSize = Math.max(size[0], size[1]) * 0.01
|
||||
if (screenSize < 4) {
|
||||
expect(isVisible).toBe(false)
|
||||
} else {
|
||||
expect(isVisible).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('transform state synchronization', () => {
|
||||
it('should efficiently sync with canvas state changes', () => {
|
||||
const syncCount = 1000
|
||||
const transformUpdates = Array.from({ length: syncCount }, (_, i) => ({
|
||||
offset: [Math.sin(i * 0.1) * 1000, Math.cos(i * 0.1) * 500],
|
||||
scale: 0.5 + Math.sin(i * 0.05) * 0.4 // Scale between 0.1 and 0.9
|
||||
}))
|
||||
|
||||
const startTime = performance.now()
|
||||
|
||||
transformUpdates.forEach((update) => {
|
||||
mockCanvas.ds.offset = update.offset
|
||||
mockCanvas.ds.scale = update.scale
|
||||
transformState.syncWithCanvas(mockCanvas)
|
||||
})
|
||||
|
||||
const syncTime = performance.now() - startTime
|
||||
|
||||
expect(syncTime).toBeLessThan(15) // 1000 syncs in under 15ms
|
||||
|
||||
// Verify final state is correct
|
||||
const lastUpdate = transformUpdates[transformUpdates.length - 1]
|
||||
expect(transformState.camera.x).toBe(lastUpdate.offset[0])
|
||||
expect(transformState.camera.y).toBe(lastUpdate.offset[1])
|
||||
expect(transformState.camera.z).toBe(lastUpdate.scale)
|
||||
})
|
||||
|
||||
it('should generate CSS transform strings efficiently', () => {
|
||||
const transformCount = 10000
|
||||
|
||||
// Set up varying transform states
|
||||
const transforms = Array.from({ length: transformCount }, (_, i) => {
|
||||
mockCanvas.ds.offset = [i * 10, i * 5]
|
||||
mockCanvas.ds.scale = 0.5 + (i % 100) / 100
|
||||
transformState.syncWithCanvas(mockCanvas)
|
||||
return transformState.transformStyle.value
|
||||
})
|
||||
|
||||
const startTime = performance.now()
|
||||
|
||||
// Access transform styles (triggers computed property)
|
||||
transforms.forEach((style) => {
|
||||
expect(style.transform).toContain('scale(')
|
||||
expect(style.transform).toContain('translate(')
|
||||
expect(style.transformOrigin).toBe('0 0')
|
||||
})
|
||||
|
||||
const accessTime = performance.now() - startTime
|
||||
|
||||
expect(accessTime).toBeLessThan(200) // 10k style accesses in under 200ms
|
||||
})
|
||||
})
|
||||
|
||||
describe('bounds calculation performance', () => {
|
||||
it('should calculate node screen bounds efficiently', () => {
|
||||
// Set up realistic transform
|
||||
mockCanvas.ds.offset = [200, 100]
|
||||
mockCanvas.ds.scale = 1.5
|
||||
transformState.syncWithCanvas(mockCanvas)
|
||||
|
||||
const nodeCount = 1000
|
||||
const nodes = Array.from({ length: nodeCount }, () => ({
|
||||
pos: [Math.random() * 5000, Math.random() * 3000] as ArrayLike<number>,
|
||||
size: [
|
||||
100 + Math.random() * 200,
|
||||
80 + Math.random() * 120
|
||||
] as ArrayLike<number>
|
||||
}))
|
||||
|
||||
const startTime = performance.now()
|
||||
|
||||
const bounds = nodes.map((node) =>
|
||||
transformState.getNodeScreenBounds(node.pos, node.size)
|
||||
)
|
||||
|
||||
const calcTime = performance.now() - startTime
|
||||
|
||||
expect(calcTime).toBeLessThan(15) // 1000 bounds calculations in under 15ms
|
||||
expect(bounds).toHaveLength(nodeCount)
|
||||
|
||||
// Verify bounds are reasonable
|
||||
bounds.forEach((bound) => {
|
||||
expect(bound.width).toBeGreaterThan(0)
|
||||
expect(bound.height).toBeGreaterThan(0)
|
||||
expect(Number.isFinite(bound.x)).toBe(true)
|
||||
expect(Number.isFinite(bound.y)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('should calculate viewport bounds efficiently', () => {
|
||||
const viewportSizes = [
|
||||
{ width: 800, height: 600 },
|
||||
{ width: 1920, height: 1080 },
|
||||
{ width: 3840, height: 2160 },
|
||||
{ width: 1280, height: 720 }
|
||||
]
|
||||
|
||||
const margins = [0, 0.1, 0.2, 0.5]
|
||||
|
||||
const combinations = viewportSizes.flatMap((viewport) =>
|
||||
margins.map((margin) => ({ viewport, margin }))
|
||||
)
|
||||
|
||||
const startTime = performance.now()
|
||||
|
||||
const allBounds = combinations.map(({ viewport, margin }) => {
|
||||
mockCanvas.ds.offset = [Math.random() * 1000, Math.random() * 500]
|
||||
mockCanvas.ds.scale = 0.5 + Math.random() * 2
|
||||
transformState.syncWithCanvas(mockCanvas)
|
||||
|
||||
return transformState.getViewportBounds(viewport, margin)
|
||||
})
|
||||
|
||||
const calcTime = performance.now() - startTime
|
||||
|
||||
expect(calcTime).toBeLessThan(5) // All viewport calculations in under 5ms
|
||||
expect(allBounds).toHaveLength(combinations.length)
|
||||
|
||||
// Verify bounds are reasonable
|
||||
allBounds.forEach((bounds) => {
|
||||
expect(bounds.width).toBeGreaterThan(0)
|
||||
expect(bounds.height).toBeGreaterThan(0)
|
||||
expect(Number.isFinite(bounds.x)).toBe(true)
|
||||
expect(Number.isFinite(bounds.y)).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('real-world performance scenarios', () => {
|
||||
it('should handle smooth panning performance', () => {
|
||||
// Simulate smooth 60fps panning for 2 seconds
|
||||
const frameCount = 120 // 2 seconds at 60fps
|
||||
const panDistance = 2000 // Pan 2000 pixels
|
||||
|
||||
const frames: number[] = []
|
||||
|
||||
for (let frame = 0; frame < frameCount; frame++) {
|
||||
const progress = frame / (frameCount - 1)
|
||||
const x = progress * panDistance
|
||||
const y = Math.sin(progress * Math.PI * 2) * 200 // Slight vertical wave
|
||||
|
||||
mockCanvas.ds.offset = [x, y]
|
||||
|
||||
const frameStart = performance.now()
|
||||
|
||||
// Typical operations during panning
|
||||
transformState.syncWithCanvas(mockCanvas)
|
||||
const style = transformState.transformStyle.value // Access transform style
|
||||
expect(style.transform).toContain('translate') // Verify style is valid
|
||||
|
||||
// Simulate some coordinate conversions (mouse tracking, etc.)
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const screen = transformState.canvasToScreen({
|
||||
x: x + i * 100,
|
||||
y: y + i * 50
|
||||
})
|
||||
transformState.screenToCanvas(screen)
|
||||
}
|
||||
|
||||
const frameTime = performance.now() - frameStart
|
||||
frames.push(frameTime)
|
||||
|
||||
// Each frame should be well under 16.67ms for 60fps
|
||||
expect(frameTime).toBeLessThan(1) // Conservative: under 1ms per frame
|
||||
}
|
||||
|
||||
const totalTime = frames.reduce((sum, time) => sum + time, 0)
|
||||
const avgFrameTime = totalTime / frameCount
|
||||
|
||||
expect(avgFrameTime).toBeLessThan(0.5) // Average frame time under 0.5ms
|
||||
expect(totalTime).toBeLessThan(60) // Total panning overhead under 60ms
|
||||
})
|
||||
|
||||
it('should handle zoom performance with viewport updates', () => {
|
||||
// Simulate smooth zoom from 0.1x to 10x
|
||||
const zoomSteps = 100
|
||||
const viewport = { width: 1920, height: 1080 }
|
||||
|
||||
const zoomTimes: number[] = []
|
||||
|
||||
for (let step = 0; step < zoomSteps; step++) {
|
||||
const zoomLevel = Math.pow(10, (step / (zoomSteps - 1)) * 2 - 1) // 0.1 to 10
|
||||
mockCanvas.ds.scale = zoomLevel
|
||||
|
||||
const stepStart = performance.now()
|
||||
|
||||
// Operations during zoom
|
||||
transformState.syncWithCanvas(mockCanvas)
|
||||
|
||||
// Viewport bounds calculation (for culling)
|
||||
transformState.getViewportBounds(viewport, 0.2)
|
||||
|
||||
// Test a few nodes for visibility
|
||||
for (let i = 0; i < 10; i++) {
|
||||
transformState.isNodeInViewport(
|
||||
[i * 200, i * 150],
|
||||
[200, 100],
|
||||
viewport
|
||||
)
|
||||
}
|
||||
|
||||
const stepTime = performance.now() - stepStart
|
||||
zoomTimes.push(stepTime)
|
||||
}
|
||||
|
||||
const maxZoomTime = Math.max(...zoomTimes)
|
||||
const avgZoomTime =
|
||||
zoomTimes.reduce((sum, time) => sum + time, 0) / zoomSteps
|
||||
|
||||
expect(maxZoomTime).toBeLessThan(2) // No zoom step over 2ms
|
||||
expect(avgZoomTime).toBeLessThan(1) // Average zoom step under 1ms
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -6,7 +6,6 @@ import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { TransformStateKey } from '@/renderer/core/layout/injectionKeys'
|
||||
import LGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
|
||||
import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking'
|
||||
|
||||
@@ -15,6 +14,17 @@ const mockData = vi.hoisted(() => ({
|
||||
mockExecuting: false
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/layout/transform/useTransformState', () => {
|
||||
return {
|
||||
useTransformState: () => ({
|
||||
screenToCanvas: vi.fn(),
|
||||
canvasToScreen: vi.fn(),
|
||||
camera: { z: 1 },
|
||||
isNodeInViewport: vi.fn()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => {
|
||||
const getCanvas = vi.fn()
|
||||
const useCanvasStore = () => ({
|
||||
@@ -105,14 +115,7 @@ function mountLGraphNode(props: ComponentProps<typeof LGraphNode>) {
|
||||
}),
|
||||
i18n
|
||||
],
|
||||
provide: {
|
||||
[TransformStateKey as symbol]: {
|
||||
screenToCanvas: vi.fn(),
|
||||
canvasToScreen: vi.fn(),
|
||||
camera: { z: 1 },
|
||||
isNodeInViewport: vi.fn()
|
||||
}
|
||||
},
|
||||
|
||||
stubs: {
|
||||
NodeHeader: true,
|
||||
NodeSlots: true,
|
||||
@@ -172,14 +175,6 @@ describe('LGraphNode', () => {
|
||||
}),
|
||||
i18n
|
||||
],
|
||||
provide: {
|
||||
[TransformStateKey as symbol]: {
|
||||
screenToCanvas: vi.fn(),
|
||||
canvasToScreen: vi.fn(),
|
||||
camera: { z: 1 },
|
||||
isNodeInViewport: vi.fn()
|
||||
}
|
||||
},
|
||||
stubs: {
|
||||
NodeSlots: true,
|
||||
NodeWidgets: true,
|
||||
|
||||
@@ -2,10 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, shallowRef } from 'vue'
|
||||
|
||||
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
|
||||
import type {
|
||||
GraphNodeManager,
|
||||
VueNodeData
|
||||
} from '@/composables/graph/useGraphNodeManager'
|
||||
import type { GraphNodeManager } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
|
||||
import type {
|
||||
LGraph,
|
||||
@@ -81,18 +78,10 @@ describe('useNodeEventHandlers', () => {
|
||||
const mockNode = mockNodeManager.value!.getNode('fake_id')
|
||||
const mockLayoutMutations = useLayoutMutations()
|
||||
|
||||
const testNodeData: VueNodeData = {
|
||||
id: 'node-1',
|
||||
title: 'Test Node',
|
||||
type: 'test',
|
||||
mode: 0,
|
||||
selected: false,
|
||||
executing: false
|
||||
}
|
||||
const testNodeId = 'node-1'
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.restoreAllMocks()
|
||||
vi.clearAllMocks()
|
||||
vi.resetAllMocks()
|
||||
canvasSelectedItems.length = 0
|
||||
})
|
||||
|
||||
@@ -107,7 +96,7 @@ describe('useNodeEventHandlers', () => {
|
||||
metaKey: false
|
||||
})
|
||||
|
||||
handleNodeSelect(event, testNodeData)
|
||||
handleNodeSelect(event, testNodeId)
|
||||
|
||||
expect(canvas?.deselectAll).toHaveBeenCalledOnce()
|
||||
expect(canvas?.select).toHaveBeenCalledWith(mockNode)
|
||||
@@ -126,7 +115,7 @@ describe('useNodeEventHandlers', () => {
|
||||
metaKey: false
|
||||
})
|
||||
|
||||
handleNodeSelect(ctrlClickEvent, testNodeData)
|
||||
handleNodeSelect(ctrlClickEvent, testNodeId)
|
||||
|
||||
// On pointer down with multi-select: bring to front
|
||||
expect(mockLayoutMutations.bringNodeToFront).toHaveBeenCalledWith(
|
||||
@@ -152,7 +141,7 @@ describe('useNodeEventHandlers', () => {
|
||||
metaKey: false
|
||||
})
|
||||
|
||||
handleNodeSelect(ctrlClickEvent, testNodeData)
|
||||
handleNodeSelect(ctrlClickEvent, testNodeId)
|
||||
|
||||
// On pointer down: bring to front
|
||||
expect(mockLayoutMutations.bringNodeToFront).toHaveBeenCalledWith(
|
||||
@@ -177,7 +166,7 @@ describe('useNodeEventHandlers', () => {
|
||||
metaKey: true
|
||||
})
|
||||
|
||||
handleNodeSelect(metaClickEvent, testNodeData)
|
||||
handleNodeSelect(metaClickEvent, testNodeId)
|
||||
|
||||
// On pointer down with meta key: bring to front
|
||||
expect(mockLayoutMutations.bringNodeToFront).toHaveBeenCalledWith(
|
||||
@@ -202,7 +191,7 @@ describe('useNodeEventHandlers', () => {
|
||||
shiftKey: true
|
||||
})
|
||||
|
||||
handleNodeSelect(shiftClickEvent, testNodeData)
|
||||
handleNodeSelect(shiftClickEvent, testNodeId)
|
||||
|
||||
// On pointer down with shift: bring to front
|
||||
expect(mockLayoutMutations.bringNodeToFront).toHaveBeenCalledWith(
|
||||
@@ -228,7 +217,7 @@ describe('useNodeEventHandlers', () => {
|
||||
metaKey: false
|
||||
})
|
||||
|
||||
handleNodeSelect(event, testNodeData)
|
||||
handleNodeSelect(event, testNodeId)
|
||||
|
||||
expect(canvas?.deselectAll).not.toHaveBeenCalled()
|
||||
expect(canvas?.select).not.toHaveBeenCalled()
|
||||
@@ -240,7 +229,7 @@ describe('useNodeEventHandlers', () => {
|
||||
mockNode!.flags.pinned = false
|
||||
|
||||
const event = new PointerEvent('pointerdown')
|
||||
handleNodeSelect(event, testNodeData)
|
||||
handleNodeSelect(event, testNodeId)
|
||||
|
||||
expect(mockLayoutMutations.bringNodeToFront).toHaveBeenCalledWith(
|
||||
'node-1'
|
||||
@@ -253,7 +242,7 @@ describe('useNodeEventHandlers', () => {
|
||||
mockNode!.flags.pinned = true
|
||||
|
||||
const event = new PointerEvent('pointerdown')
|
||||
handleNodeSelect(event, testNodeData)
|
||||
handleNodeSelect(event, testNodeId)
|
||||
|
||||
expect(mockLayoutMutations.bringNodeToFront).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -266,10 +255,7 @@ describe('useNodeEventHandlers', () => {
|
||||
|
||||
mockNode!.selected = true
|
||||
|
||||
toggleNodeSelectionAfterPointerUp('node-1', {
|
||||
wasSelectedAtPointerDown: true,
|
||||
multiSelect: true
|
||||
})
|
||||
toggleNodeSelectionAfterPointerUp('node-1', true)
|
||||
|
||||
expect(canvas?.deselect).toHaveBeenCalledWith(mockNode)
|
||||
expect(updateSelectedItems).toHaveBeenCalledOnce()
|
||||
@@ -281,13 +267,10 @@ describe('useNodeEventHandlers', () => {
|
||||
|
||||
mockNode!.selected = true
|
||||
|
||||
toggleNodeSelectionAfterPointerUp('node-1', {
|
||||
wasSelectedAtPointerDown: false,
|
||||
multiSelect: true
|
||||
})
|
||||
toggleNodeSelectionAfterPointerUp('node-1', true)
|
||||
|
||||
expect(canvas?.select).not.toHaveBeenCalled()
|
||||
expect(updateSelectedItems).not.toHaveBeenCalled()
|
||||
expect(updateSelectedItems).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('on pointer up without multi-select: collapses multi-selection to clicked node', () => {
|
||||
@@ -297,10 +280,7 @@ describe('useNodeEventHandlers', () => {
|
||||
mockNode!.selected = true
|
||||
canvasSelectedItems.push({ id: 'node-1' }, { id: 'node-2' })
|
||||
|
||||
toggleNodeSelectionAfterPointerUp('node-1', {
|
||||
wasSelectedAtPointerDown: true,
|
||||
multiSelect: false
|
||||
})
|
||||
toggleNodeSelectionAfterPointerUp('node-1', false)
|
||||
|
||||
expect(canvas?.deselectAll).toHaveBeenCalledOnce()
|
||||
expect(canvas?.select).toHaveBeenCalledWith(mockNode)
|
||||
@@ -314,88 +294,10 @@ describe('useNodeEventHandlers', () => {
|
||||
mockNode!.selected = true
|
||||
canvasSelectedItems.push({ id: 'node-1' })
|
||||
|
||||
toggleNodeSelectionAfterPointerUp('node-1', {
|
||||
wasSelectedAtPointerDown: true,
|
||||
multiSelect: false
|
||||
})
|
||||
toggleNodeSelectionAfterPointerUp('node-1', false)
|
||||
|
||||
expect(canvas?.deselectAll).not.toHaveBeenCalled()
|
||||
expect(canvas?.select).not.toHaveBeenCalled()
|
||||
expect(updateSelectedItems).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ensureNodeSelectedForShiftDrag', () => {
|
||||
it('does nothing when multi-select key is not pressed', () => {
|
||||
const { ensureNodeSelectedForShiftDrag } = useNodeEventHandlers()
|
||||
const { canvas } = useCanvasStore()
|
||||
|
||||
const event = new PointerEvent('pointermove', { shiftKey: false })
|
||||
|
||||
ensureNodeSelectedForShiftDrag(event, testNodeData, false)
|
||||
|
||||
expect(canvas?.select).not.toHaveBeenCalled()
|
||||
expect(canvas?.deselectAll).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('selects node and clears existing selection when shift-dragging with no other selections', () => {
|
||||
const { ensureNodeSelectedForShiftDrag } = useNodeEventHandlers()
|
||||
const { canvas } = useCanvasStore()
|
||||
|
||||
mockNode!.selected = false
|
||||
|
||||
const event = new PointerEvent('pointermove', { shiftKey: true })
|
||||
|
||||
ensureNodeSelectedForShiftDrag(event, testNodeData, false)
|
||||
|
||||
expect(canvas?.deselectAll).toHaveBeenCalledOnce()
|
||||
expect(canvas?.select).toHaveBeenCalledWith(mockNode)
|
||||
})
|
||||
|
||||
it('adds node to existing multi-selection without clearing other nodes', () => {
|
||||
const { ensureNodeSelectedForShiftDrag } = useNodeEventHandlers()
|
||||
const { canvas, selectedItems } = useCanvasStore()
|
||||
|
||||
// Create mock Positionable objects for existing selection
|
||||
const mockExisting1 = {
|
||||
id: 'existing-1',
|
||||
pos: [0, 0] as [number, number],
|
||||
move: vi.fn(),
|
||||
snapToGrid: vi.fn(),
|
||||
boundingRect: vi.fn(() => [0, 0, 100, 100] as const)
|
||||
} as unknown as LGraphNode
|
||||
const mockExisting2 = {
|
||||
id: 'existing-2',
|
||||
pos: [0, 0] as [number, number],
|
||||
move: vi.fn(),
|
||||
snapToGrid: vi.fn(),
|
||||
boundingRect: vi.fn(() => [0, 0, 100, 100] as const)
|
||||
} as unknown as LGraphNode
|
||||
selectedItems.push(mockExisting1, mockExisting2)
|
||||
mockNode!.selected = false
|
||||
if (canvas?.select) vi.mocked(canvas.select).mockClear()
|
||||
if (canvas?.deselectAll) vi.mocked(canvas.deselectAll).mockClear()
|
||||
|
||||
const event = new PointerEvent('pointermove', { shiftKey: true })
|
||||
|
||||
ensureNodeSelectedForShiftDrag(event, testNodeData, false)
|
||||
|
||||
expect(canvas?.deselectAll).not.toHaveBeenCalled()
|
||||
expect(canvas?.select).toHaveBeenCalledWith(mockNode)
|
||||
})
|
||||
|
||||
it('does nothing if node is already selected (selection happened on pointer down)', () => {
|
||||
const { ensureNodeSelectedForShiftDrag } = useNodeEventHandlers()
|
||||
const { canvas } = useCanvasStore()
|
||||
|
||||
mockNode!.selected = true
|
||||
|
||||
const event = new PointerEvent('pointermove', { shiftKey: true })
|
||||
|
||||
ensureNodeSelectedForShiftDrag(event, testNodeData, false)
|
||||
|
||||
expect(canvas?.select).not.toHaveBeenCalled()
|
||||
expect(canvas?.deselectAll).not.toHaveBeenCalled()
|
||||
expect(updateSelectedItems).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
90
tests-ui/tests/widgets/dynamicCombo.test.ts
Normal file
90
tests-ui/tests/widgets/dynamicCombo.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
|
||||
import type { InputSpec } from '@/schemas/nodeDefSchema'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import type { HasInitialMinSize } from '@/services/litegraphService'
|
||||
|
||||
setActivePinia(createTestingPinia())
|
||||
type DynamicInputs = ('INT' | 'STRING' | 'IMAGE' | DynamicInputs)[][]
|
||||
|
||||
const { addNodeInput } = useLitegraphService()
|
||||
|
||||
function addDynamicCombo(node: LGraphNode, inputs: DynamicInputs) {
|
||||
const namePrefix = `${node.widgets?.length ?? 0}`
|
||||
function getSpec(
|
||||
inputs: DynamicInputs,
|
||||
depth: number = 0
|
||||
): { key: string; inputs: object }[] {
|
||||
return inputs.map((group, groupIndex) => {
|
||||
const inputs = group.map((input, inputIndex) => [
|
||||
`${namePrefix}.${depth}.${inputIndex}`,
|
||||
Array.isArray(input)
|
||||
? ['COMFY_DYNAMICCOMBO_V3', { options: getSpec(input, depth + 1) }]
|
||||
: [input, {}]
|
||||
])
|
||||
return {
|
||||
key: `${groupIndex}`,
|
||||
inputs: { required: Object.fromEntries(inputs) }
|
||||
}
|
||||
})
|
||||
}
|
||||
const inputSpec: Required<InputSpec> = [
|
||||
'COMFY_DYNAMICCOMBO_V3',
|
||||
{ options: getSpec(inputs) }
|
||||
]
|
||||
addNodeInput(
|
||||
node,
|
||||
transformInputSpecV1ToV2(inputSpec, { name: namePrefix, isOptional: false })
|
||||
)
|
||||
}
|
||||
function testNode() {
|
||||
const node: LGraphNode & Partial<HasInitialMinSize> = new LGraphNode('test')
|
||||
node.widgets = []
|
||||
node._initialMinSize = { width: 1, height: 1 }
|
||||
node.constructor.nodeData = {
|
||||
name: 'testnode'
|
||||
} as typeof node.constructor.nodeData
|
||||
return node as LGraphNode & Required<Pick<LGraphNode, 'widgets'>>
|
||||
}
|
||||
|
||||
describe('Dynamic Combos', () => {
|
||||
test('Can add widget on selection', () => {
|
||||
const node = testNode()
|
||||
addDynamicCombo(node, [['INT'], ['INT', 'STRING']])
|
||||
expect(node.widgets.length).toBe(2)
|
||||
node.widgets[0].value = '1'
|
||||
expect(node.widgets.length).toBe(3)
|
||||
})
|
||||
test('Can add nested widgets', () => {
|
||||
const node = testNode()
|
||||
addDynamicCombo(node, [['INT'], [[[], ['STRING']]]])
|
||||
expect(node.widgets.length).toBe(2)
|
||||
node.widgets[0].value = '1'
|
||||
expect(node.widgets.length).toBe(2)
|
||||
node.widgets[1].value = '1'
|
||||
expect(node.widgets.length).toBe(3)
|
||||
})
|
||||
test('Can add input', () => {
|
||||
const node = testNode()
|
||||
addDynamicCombo(node, [['INT'], ['IMAGE']])
|
||||
expect(node.widgets.length).toBe(2)
|
||||
node.widgets[0].value = '1'
|
||||
expect(node.widgets.length).toBe(1)
|
||||
expect(node.inputs.length).toBe(2)
|
||||
expect(node.inputs[1].type).toBe('IMAGE')
|
||||
})
|
||||
test('Dynamically added inputs are well ordered', () => {
|
||||
const node = testNode()
|
||||
addDynamicCombo(node, [['INT'], ['IMAGE']])
|
||||
addDynamicCombo(node, [['INT'], ['IMAGE']])
|
||||
node.widgets[2].value = '1'
|
||||
node.widgets[0].value = '1'
|
||||
expect(node.widgets.length).toBe(2)
|
||||
expect(node.inputs.length).toBe(4)
|
||||
expect(node.inputs[1].name).toBe('0.0.0')
|
||||
expect(node.inputs[3].name).toBe('2.0.0')
|
||||
})
|
||||
})
|
||||
@@ -6,6 +6,7 @@
|
||||
"lib": [
|
||||
"ES2023",
|
||||
"ES2023.Array",
|
||||
"ESNext.Iterator",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user