Compare commits
41 Commits
drjkl/just
...
bl/posthog
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c29c85afd1 | ||
|
|
7abaf91e0b | ||
|
|
f4653b7365 | ||
|
|
d99f08e4ea | ||
|
|
e16a0bfe82 | ||
|
|
3a8ddfb6f1 | ||
|
|
5a53df8d79 | ||
|
|
9e32b7db51 | ||
|
|
9813eee22f | ||
|
|
c7238dd395 | ||
|
|
cedb4e6761 | ||
|
|
13e67561cf | ||
|
|
a0411d9beb | ||
|
|
e97c4b6ab9 | ||
|
|
f830314429 | ||
|
|
fb58a76a53 | ||
|
|
dda9822a93 | ||
|
|
b7990f7645 | ||
|
|
79f2904937 | ||
|
|
c57944f315 | ||
|
|
26dfa5c547 | ||
|
|
07d7b0c84f | ||
|
|
d86483a6af | ||
|
|
671e0cecdf | ||
|
|
e02ee17d3d | ||
|
|
dc1bc4c9f8 | ||
|
|
767bd17077 | ||
|
|
0d0231453a | ||
|
|
cc29a3d72d | ||
|
|
62430d6311 | ||
|
|
dc8471c6d3 | ||
|
|
c070df72d4 | ||
|
|
c3dc7f45d4 | ||
|
|
c2ef961834 | ||
|
|
78c16368d7 | ||
|
|
8206022982 | ||
|
|
5f2b2f2e87 | ||
|
|
a931acadd3 | ||
|
|
db6b7a315c | ||
|
|
b89940134f | ||
|
|
c957913c71 |
24
.github/workflows/detect-unreviewed-merge.yml
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
name: Detect Unreviewed Merge
|
||||
|
||||
# SOC 2 compliance — reusable workflow lives in Comfy-Org/github-workflows,
|
||||
# tracking issues are filed in Comfy-Org/unreviewed-merges.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
|
||||
concurrency:
|
||||
group: detect-unreviewed-merge-${{ github.sha }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
detect:
|
||||
uses: Comfy-Org/github-workflows/.github/workflows/detect-unreviewed-merge.yml@4d9cb6b87f953bb7cd69954280e1465fb9bd2040 # v1
|
||||
with:
|
||||
approval-mode: latest-per-reviewer
|
||||
secrets:
|
||||
UNREVIEWED_MERGES_TOKEN: ${{ secrets.UNREVIEWED_MERGES_TOKEN }}
|
||||
BIN
apps/website/public/favicon-96x96.png
Normal file
|
After Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 15 KiB |
4
apps/website/public/favicon.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48">
|
||||
<rect width="48" height="48" rx="12" fill="#211927"/>
|
||||
<path fill="#F2FF59" d="M31.0126 30.48C31.0576 30.3278 31.0822 30.1674 31.0822 29.9987C31.0822 29.0651 30.3294 28.3083 29.4006 28.3083H21.8643C21.4592 28.3124 21.1278 27.9834 21.1278 27.5762C21.1278 27.5022 21.1401 27.4323 21.1565 27.3665L23.1858 20.2593C23.2718 19.9467 23.5581 19.7164 23.8936 19.7164L31.4586 19.7082C33.0542 19.7082 34.4001 18.6264 34.8054 17.1499L35.9429 13.1891C35.9794 13.0493 36 12.8971 36 12.7449C36 11.8154 35.2513 11.0627 34.3268 11.0627H25.1742C23.5868 11.0627 22.2448 12.1362 21.8316 13.5963L21.0624 16.2985C20.9724 16.607 20.6901 16.8332 20.3546 16.8332H18.1575C16.5823 16.8332 15.2526 17.8861 14.8271 19.3298L12.0614 29.0404C12.0205 29.1844 12 29.3407 12 29.4969C12 30.4306 12.7528 31.1874 13.6816 31.1874H15.8418C16.2469 31.1874 16.5783 31.5164 16.5783 31.9277C16.5783 31.9976 16.5701 32.0675 16.5496 32.1334L15.7845 34.8109C15.7477 34.9549 15.7231 35.1029 15.7231 35.255C15.7231 36.1846 16.4719 36.9374 17.3965 36.9374L26.553 36.929C28.1446 36.929 29.4865 35.8513 29.8957 34.3833L31.0085 30.4841L31.0126 30.48Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
23
apps/website/public/site.webmanifest
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "Comfy",
|
||||
"short_name": "Comfy",
|
||||
"id": "/",
|
||||
"start_url": "/",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
}
|
||||
],
|
||||
"theme_color": "#211927",
|
||||
"background_color": "#211927",
|
||||
"display": "standalone"
|
||||
}
|
||||
BIN
apps/website/public/web-app-manifest-192x192.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
apps/website/public/web-app-manifest-512x512.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
@@ -87,8 +87,8 @@ function scrollToDepartment(deptKey: string) {
|
||||
<template>
|
||||
<section class="px-6 py-20 md:px-20 md:py-32" data-testid="careers-roles">
|
||||
<div class="mx-auto max-w-6xl">
|
||||
<div class="flex flex-col gap-12 md:flex-row md:gap-20">
|
||||
<div class="shrink-0 md:w-48">
|
||||
<div class="flex flex-col gap-12 lg:flex-row lg:gap-20">
|
||||
<div class="shrink-0 lg:min-w-48">
|
||||
<div
|
||||
class="bg-primary-comfy-ink sticky top-20 z-10 py-4 md:top-28 md:py-0"
|
||||
>
|
||||
@@ -133,30 +133,41 @@ function scrollToDepartment(deptKey: string) {
|
||||
:href="role.jobUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="border-primary-warm-gray/20 group flex items-center justify-between border-b py-5"
|
||||
class="border-primary-warm-gray/20 hover:border-primary-comfy-canvas group flex items-center gap-4 border-b py-5 transition-colors duration-200"
|
||||
data-testid="careers-role-link"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div
|
||||
class="flex min-w-0 flex-1 flex-col md:flex-row md:items-baseline md:gap-x-4"
|
||||
>
|
||||
<span
|
||||
class="text-primary-comfy-canvas text-base font-medium md:text-lg"
|
||||
>
|
||||
{{ role.title }}
|
||||
</span>
|
||||
<span class="text-primary-warm-gray ml-3 text-sm">
|
||||
{{ role.department }}
|
||||
</span>
|
||||
<div
|
||||
class="text-primary-warm-gray mt-1 flex flex-wrap gap-x-4 gap-y-1 text-sm md:mt-0 md:contents"
|
||||
>
|
||||
<span>{{ role.department }}</span>
|
||||
<span class="md:hidden">{{ role.location }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-4 flex shrink-0 items-center gap-3">
|
||||
<span class="text-primary-warm-gray text-sm">
|
||||
{{ role.location }}
|
||||
</span>
|
||||
<img
|
||||
src="/icons/arrow-up-right.svg"
|
||||
alt=""
|
||||
class="size-5"
|
||||
<span
|
||||
class="text-primary-warm-gray hidden shrink-0 text-sm md:inline"
|
||||
>
|
||||
{{ role.location }}
|
||||
</span>
|
||||
<span
|
||||
class="bg-primary-comfy-yellow/0 group-hover:bg-primary-comfy-yellow relative grid size-7 shrink-0 place-items-center rounded-sm transition-colors duration-300 ease-out"
|
||||
>
|
||||
<span
|
||||
class="bg-primary-comfy-yellow group-hover:bg-primary-comfy-ink size-5 transition-colors duration-300 ease-out"
|
||||
style="
|
||||
mask: url('/icons/arrow-up-right.svg') center / contain
|
||||
no-repeat;
|
||||
"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,7 @@ const emit = defineEmits<{
|
||||
|
||||
<template>
|
||||
<nav
|
||||
class="scrollbar-none flex items-center gap-3 overflow-x-auto lg:flex-col lg:overflow-x-hidden"
|
||||
class="flex w-full scrollbar-none items-center gap-3 overflow-x-auto lg:flex-col lg:overflow-x-hidden"
|
||||
aria-label="Category filter"
|
||||
>
|
||||
<button
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
export type NavDropdownItem = {
|
||||
type NavDropdownItem = {
|
||||
label: string
|
||||
href: string
|
||||
badge?: string
|
||||
|
||||
@@ -37,7 +37,7 @@ const allCards: (ReturnType<typeof cardDef> & { product: Product })[] = [
|
||||
cardDef('local', routes.download, 'bg-primary-warm-gray'),
|
||||
cardDef('cloud', routes.cloud, 'bg-secondary-mauve'),
|
||||
cardDef('api', routes.api, 'bg-primary-comfy-plum'),
|
||||
cardDef('enterprise', routes.cloudEnterprise, 'bg-illustration-forest')
|
||||
cardDef('enterprise', routes.cloudEnterprise, 'bg-secondary-cool-gray')
|
||||
]
|
||||
|
||||
const cards = excludeProduct
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { GalleryItem } from '../../data/gallery'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import type { GalleryItem } from './GallerySection.vue'
|
||||
|
||||
import GalleryItemAttribution from './GalleryItemAttribution.vue'
|
||||
|
||||
const {
|
||||
|
||||
@@ -10,13 +10,13 @@ import {
|
||||
watch
|
||||
} from 'vue'
|
||||
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
import { lockScroll, unlockScroll } from '../../composables/scrollLock'
|
||||
import { prefersReducedMotion } from '../../composables/useReducedMotion'
|
||||
import type { GalleryItem } from '../../data/gallery'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
import GalleryItemAttribution from './GalleryItemAttribution.vue'
|
||||
import type { GalleryItem } from './GallerySection.vue'
|
||||
|
||||
const {
|
||||
items,
|
||||
@@ -251,7 +251,7 @@ onUnmounted(() => {
|
||||
|
||||
<!-- Thumbnail strip -->
|
||||
<div
|
||||
class="scrollbar-none mx-auto mt-6 h-16 max-w-full overflow-x-auto px-6 lg:h-30"
|
||||
class="mx-auto mt-6 h-16 max-w-full scrollbar-none overflow-x-auto px-6 lg:h-30"
|
||||
>
|
||||
<div class="flex items-end gap-3">
|
||||
<button
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { GalleryItem } from '../../data/gallery'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import type { GalleryItem } from './GallerySection.vue'
|
||||
|
||||
const {
|
||||
item,
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { visibleGalleryItems as items } from '../../data/gallery'
|
||||
import type { GalleryItem } from '../../data/gallery'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import GalleryCard from './GalleryCard.vue'
|
||||
import GalleryDetailModal from './GalleryDetailModal.vue'
|
||||
@@ -16,166 +18,6 @@ function openDetail(index: number) {
|
||||
modalOpen.value = true
|
||||
}
|
||||
|
||||
export interface GalleryItem {
|
||||
image?: string
|
||||
video?: string
|
||||
title: string
|
||||
userAlias: string
|
||||
teamAlias: string
|
||||
tool: string
|
||||
href?: string
|
||||
}
|
||||
|
||||
const items: GalleryItem[] = [
|
||||
{
|
||||
video: 'https://media.comfy.org/videos/compressed_512/eye.webm',
|
||||
title: 'Until Our Eye Interlink harajuku',
|
||||
userAlias: 'ShaneF Motion Design',
|
||||
teamAlias: 'ThinkDiffusion',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://www.thinkdiffusion.com/studio#success-stories-anta'
|
||||
},
|
||||
{
|
||||
video: 'https://media.comfy.org/videos/compressed_512/kyrie.webm',
|
||||
title: 'Origins - Kyrie Irving',
|
||||
userAlias: 'ShaneF Motion Design',
|
||||
teamAlias: 'ThinkDiffusion',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://vimeo.com/1021360563'
|
||||
},
|
||||
{
|
||||
video: 'https://media.comfy.org/videos/compressed_512/arcade.webm',
|
||||
title: 'Neon Nights',
|
||||
userAlias: 'ShaneF Motion Design',
|
||||
teamAlias: 'DOGSTUDIO/DEPT®',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://www.instagram.com/p/C1kG1oErzUV/'
|
||||
},
|
||||
{
|
||||
video: 'https://media.comfy.org/videos/compressed_512/dusk_mountains.webm',
|
||||
title: 'Untitled',
|
||||
userAlias: 'MidJourney man',
|
||||
teamAlias: 'DOGSTUDIO/DEPT®',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://www.instagram.com/midjourney.man/?hl=fr'
|
||||
},
|
||||
{
|
||||
video: 'https://media.comfy.org/videos/compressed_512/cigarette.webm',
|
||||
title: 'Autopoiesis',
|
||||
userAlias: 'Yogo',
|
||||
teamAlias: 'Visual Frisson',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://www.instagram.com/visualfrisson/?hl=en'
|
||||
},
|
||||
{
|
||||
video:
|
||||
'https://media.comfy.org/videos/compressed_512/Eat%20It%20-%20Dance%20%5BWanAnimate%5D2.webm',
|
||||
title: 'Eat It - Dance',
|
||||
userAlias: 'Johana Lyu',
|
||||
teamAlias: 'Visual Frisson',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://www.joannalyu.com/'
|
||||
},
|
||||
{
|
||||
video: 'https://media.comfy.org/videos/compressed_512/flower.webm',
|
||||
title: 'Fall',
|
||||
userAlias: 'Nathan Shipley',
|
||||
teamAlias: 'Visual Frisson',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://www.instagram.com/p/C3k9t_6vH5F/'
|
||||
},
|
||||
{
|
||||
video: 'https://media.comfy.org/videos/compressed_512/buildings.webm',
|
||||
title: 'Untitled',
|
||||
userAlias: 'Nathan Shipley',
|
||||
teamAlias: '',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://www.instagram.com/p/C6rEuJ4p9xU/'
|
||||
},
|
||||
{
|
||||
video:
|
||||
'https://media.comfy.org/videos/compressed_512/origami_shortened.webm',
|
||||
title: 'Origami world',
|
||||
userAlias: 'Karen X',
|
||||
teamAlias: '',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://www.instagram.com/karenxcheng/'
|
||||
},
|
||||
{
|
||||
video: 'https://media.comfy.org/videos/compressed_512/biking.webm',
|
||||
title: 'Shot on InstaX',
|
||||
userAlias: 'Karen X',
|
||||
teamAlias: '',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://www.instagram.com/karenxcheng/'
|
||||
},
|
||||
{
|
||||
video: 'https://media.comfy.org/videos/compressed_512/clouds.webm',
|
||||
title: "It's gonna be a good good summer",
|
||||
userAlias: 'Paul Trillo',
|
||||
teamAlias: '',
|
||||
tool: 'CogvideoX',
|
||||
href: 'https://vimeo.com/1019685900'
|
||||
},
|
||||
{
|
||||
video: 'https://media.comfy.org/videos/compressed_512/dududu.webm',
|
||||
title: 'DDU-DU DDU-DU',
|
||||
userAlias: 'Purz',
|
||||
teamAlias: 'Andidea',
|
||||
tool: 'Animatediff',
|
||||
href: 'https://vimeo.com/1019924290'
|
||||
},
|
||||
{
|
||||
video: 'https://media.comfy.org/videos/compressed_512/paul_trillo.webm',
|
||||
title: 'Cuco - A Love Letter To LA',
|
||||
userAlias: 'Paul Trillo',
|
||||
teamAlias: 'CoffeeVectors',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://vimeo.com/1062859798'
|
||||
},
|
||||
{
|
||||
video:
|
||||
'https://media.comfy.org/videos/compressed_512/chibi_fish_tank_shortened.webm',
|
||||
title: 'Show you my garden',
|
||||
userAlias: 'Paul Trillo',
|
||||
teamAlias: '',
|
||||
tool: 'CogvideoX',
|
||||
href: 'https://vimeo.com/1019685479'
|
||||
},
|
||||
{
|
||||
video: 'https://media.comfy.org/videos/compressed_512/swings.webm',
|
||||
title: 'Goodbye Beijing',
|
||||
userAlias: 'Rui',
|
||||
teamAlias: 'makeitrad',
|
||||
tool: 'Animatediff',
|
||||
href: 'https://x.com/rui40000'
|
||||
},
|
||||
{
|
||||
video: 'https://media.comfy.org/videos/compressed_512/clouds_statue.webm',
|
||||
title: 'Animation Reel',
|
||||
userAlias: 'Andidea',
|
||||
teamAlias: '',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://www.youtube.com/watch?v=qu3eIQ1uln8'
|
||||
},
|
||||
{
|
||||
image: 'https://media.comfy.org/website/gallery/gallery.webp',
|
||||
title: 'Amber Astronaut',
|
||||
userAlias: 'Yogo',
|
||||
teamAlias: '',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://de.linkedin.com/in/milan-kastenmueller-18778a174'
|
||||
},
|
||||
{
|
||||
image: 'https://media.comfy.org/website/gallery/desert.webp',
|
||||
title: 'Desert Landing',
|
||||
userAlias: 'Yogo',
|
||||
teamAlias: '',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://de.linkedin.com/in/milan-kastenmueller-18778a174'
|
||||
}
|
||||
]
|
||||
|
||||
/**
|
||||
* Desktop layout pattern (repeating):
|
||||
* Row A: full-width (1 item)
|
||||
|
||||
189
apps/website/src/data/gallery.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
export interface GalleryItem {
|
||||
id: string
|
||||
image?: string
|
||||
video?: string
|
||||
title: string
|
||||
userAlias: string
|
||||
teamAlias: string
|
||||
tool: string
|
||||
href?: string
|
||||
/** Defaults to true. Set to false to hide this item from rendered lists. */
|
||||
visible?: boolean
|
||||
}
|
||||
|
||||
const galleryItems: GalleryItem[] = [
|
||||
{
|
||||
id: 'until-our-eye-interlink-harajuku',
|
||||
video: 'https://media.comfy.org/videos/compressed_512/eye.webm',
|
||||
title: 'Until Our Eye Interlink harajuku',
|
||||
userAlias: 'ShaneF Motion Design',
|
||||
teamAlias: 'ThinkDiffusion',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://www.thinkdiffusion.com/studio#success-stories-anta'
|
||||
},
|
||||
{
|
||||
id: 'origins-kyrie-irving',
|
||||
video: 'https://media.comfy.org/videos/compressed_512/kyrie.webm',
|
||||
title: 'Origins - Kyrie Irving',
|
||||
userAlias: 'ShaneF Motion Design',
|
||||
teamAlias: 'ThinkDiffusion',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://vimeo.com/1021360563'
|
||||
},
|
||||
{
|
||||
id: 'neon-nights',
|
||||
video: 'https://media.comfy.org/videos/compressed_512/arcade.webm',
|
||||
title: 'Neon Nights',
|
||||
userAlias: 'ShaneF Motion Design',
|
||||
teamAlias: 'DOGSTUDIO/DEPT®',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://www.instagram.com/p/C1kG1oErzUV/'
|
||||
},
|
||||
{
|
||||
id: 'untitled-dusk-mountains',
|
||||
video: 'https://media.comfy.org/videos/compressed_512/dusk_mountains.webm',
|
||||
title: 'Untitled',
|
||||
userAlias: 'MidJourney man',
|
||||
teamAlias: 'DOGSTUDIO/DEPT®',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://www.instagram.com/midjourney.man/?hl=fr'
|
||||
},
|
||||
{
|
||||
id: 'autopoiesis',
|
||||
video: 'https://media.comfy.org/videos/compressed_512/cigarette.webm',
|
||||
title: 'Autopoiesis',
|
||||
userAlias: 'Yogo',
|
||||
teamAlias: 'Visual Frisson',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://www.instagram.com/visualfrisson/?hl=en'
|
||||
},
|
||||
{
|
||||
id: 'eat-it-dance',
|
||||
video:
|
||||
'https://media.comfy.org/videos/compressed_512/Eat%20It%20-%20Dance%20%5BWanAnimate%5D2.webm',
|
||||
title: 'Eat It - Dance',
|
||||
userAlias: 'Johana Lyu',
|
||||
teamAlias: 'Visual Frisson',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://www.joannalyu.com/'
|
||||
},
|
||||
{
|
||||
id: 'fall',
|
||||
video: 'https://media.comfy.org/videos/compressed_512/flower.webm',
|
||||
title: 'Fall',
|
||||
userAlias: 'Nathan Shipley',
|
||||
teamAlias: 'Visual Frisson',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://www.instagram.com/p/C3k9t_6vH5F/'
|
||||
},
|
||||
{
|
||||
id: 'untitled-buildings',
|
||||
video: 'https://media.comfy.org/videos/compressed_512/buildings.webm',
|
||||
title: 'Untitled',
|
||||
userAlias: 'Nathan Shipley',
|
||||
teamAlias: '',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://www.instagram.com/p/C6rEuJ4p9xU/'
|
||||
},
|
||||
{
|
||||
id: 'origami-world',
|
||||
video:
|
||||
'https://media.comfy.org/videos/compressed_512/origami_shortened.webm',
|
||||
title: 'Origami world',
|
||||
userAlias: 'Karen X',
|
||||
teamAlias: '',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://www.instagram.com/karenxcheng/'
|
||||
},
|
||||
{
|
||||
id: 'shot-on-instax',
|
||||
video: 'https://media.comfy.org/videos/compressed_512/biking.webm',
|
||||
title: 'Shot on InstaX',
|
||||
userAlias: 'Karen X',
|
||||
teamAlias: '',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://www.instagram.com/karenxcheng/'
|
||||
},
|
||||
{
|
||||
id: 'good-good-summer',
|
||||
video: 'https://media.comfy.org/videos/compressed_512/clouds.webm',
|
||||
title: "It's gonna be a good good summer",
|
||||
userAlias: 'Paul Trillo',
|
||||
teamAlias: '',
|
||||
tool: 'CogvideoX',
|
||||
href: 'https://vimeo.com/1019685900'
|
||||
},
|
||||
{
|
||||
id: 'ddu-du-ddu-du',
|
||||
video: 'https://media.comfy.org/videos/compressed_512/dududu.webm',
|
||||
title: 'DDU-DU DDU-DU',
|
||||
userAlias: 'Purz',
|
||||
teamAlias: 'Andidea',
|
||||
tool: 'Animatediff',
|
||||
href: 'https://vimeo.com/1019924290'
|
||||
},
|
||||
{
|
||||
id: 'cuco-love-letter-to-la',
|
||||
video: 'https://media.comfy.org/videos/compressed_512/paul_trillo.webm',
|
||||
title: 'Cuco - A Love Letter To LA',
|
||||
userAlias: 'Paul Trillo',
|
||||
teamAlias: 'CoffeeVectors',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://vimeo.com/1062859798'
|
||||
},
|
||||
{
|
||||
id: 'show-you-my-garden',
|
||||
video:
|
||||
'https://media.comfy.org/videos/compressed_512/chibi_fish_tank_shortened.webm',
|
||||
title: 'Show you my garden',
|
||||
userAlias: 'Paul Trillo',
|
||||
teamAlias: '',
|
||||
tool: 'CogvideoX',
|
||||
href: 'https://vimeo.com/1019685479'
|
||||
},
|
||||
{
|
||||
id: 'goodbye-beijing',
|
||||
video: 'https://media.comfy.org/videos/compressed_512/swings.webm',
|
||||
title: 'Goodbye Beijing',
|
||||
userAlias: 'Rui',
|
||||
teamAlias: 'makeitrad',
|
||||
tool: 'Animatediff',
|
||||
href: 'https://x.com/rui40000'
|
||||
},
|
||||
{
|
||||
id: 'animation-reel',
|
||||
video: 'https://media.comfy.org/videos/compressed_512/clouds_statue.webm',
|
||||
title: 'Animation Reel',
|
||||
userAlias: 'Andidea',
|
||||
teamAlias: '',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://www.youtube.com/watch?v=qu3eIQ1uln8'
|
||||
},
|
||||
{
|
||||
id: 'amber-astronaut',
|
||||
image: 'https://media.comfy.org/website/gallery/gallery.webp',
|
||||
title: 'Amber Astronaut',
|
||||
userAlias: 'Yogo',
|
||||
teamAlias: '',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://de.linkedin.com/in/milan-kastenmueller-18778a174'
|
||||
},
|
||||
{
|
||||
id: 'desert-landing',
|
||||
image: 'https://media.comfy.org/website/gallery/desert.webp',
|
||||
title: 'Desert Landing',
|
||||
userAlias: 'Yogo',
|
||||
teamAlias: '',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://de.linkedin.com/in/milan-kastenmueller-18778a174'
|
||||
}
|
||||
]
|
||||
|
||||
export const visibleGalleryItems: GalleryItem[] = galleryItems.filter(
|
||||
(item) => item.visible !== false
|
||||
)
|
||||
|
||||
/** @knipIgnoreUsedByStackedPR */
|
||||
export function getGalleryItemById(id: string): GalleryItem | undefined {
|
||||
return galleryItems.find((item) => item.id === id)
|
||||
}
|
||||
@@ -1458,9 +1458,9 @@ const translations = {
|
||||
// ContactSection
|
||||
'gallery.contact.label': { en: 'CONTACT', 'zh-CN': '联系' },
|
||||
'gallery.contact.heading': {
|
||||
en: 'Built something cool with ComfyUI? <a href="https://docs.google.com/forms/d/1B6_RPQfhTyKvqHk9OO2bUn8z1Qgh6QIZsF3GNMiCXDw/preview" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">Submit</a> your work to be featured on our website and socials and get seen by the global ComfyUI community.',
|
||||
en: 'Built something cool with ComfyUI?<br> <a href="https://docs.google.com/forms/d/1B6_RPQfhTyKvqHk9OO2bUn8z1Qgh6QIZsF3GNMiCXDw/preview" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">Submit</a> your work to be featured on our website and socials and get seen by the global ComfyUI community.',
|
||||
'zh-CN':
|
||||
'用 ComfyUI 创作了很酷的作品?<a href="https://docs.google.com/forms/d/1B6_RPQfhTyKvqHk9OO2bUn8z1Qgh6QIZsF3GNMiCXDw/preview" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">提交</a>你的作品,展示在我们的网站和社交媒体上,让全球 ComfyUI 社区看到。'
|
||||
'用 ComfyUI 创作了很酷的作品?<br><a href="https://docs.google.com/forms/d/1B6_RPQfhTyKvqHk9OO2bUn8z1Qgh6QIZsF3GNMiCXDw/preview" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">提交</a>你的作品,展示在我们的网站和社交媒体上,让全球 ComfyUI 社区看到。'
|
||||
},
|
||||
|
||||
// AboutHeroSection
|
||||
|
||||
@@ -71,20 +71,12 @@ const websiteJsonLd = {
|
||||
{noindex && <meta name="robots" content="noindex, nofollow" />}
|
||||
<title>{title}</title>
|
||||
|
||||
<link
|
||||
rel="icon"
|
||||
href="/favicon-light.svg"
|
||||
type="image/svg+xml"
|
||||
media="(prefers-color-scheme: light)"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
href="/favicon-dark.svg"
|
||||
type="image/svg+xml"
|
||||
media="(prefers-color-scheme: dark)"
|
||||
/>
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<meta name="theme-color" content="#211927" />
|
||||
<link rel="canonical" href={canonicalURL.href} />
|
||||
<link rel="preconnect" href="https://www.googletagmanager.com" />
|
||||
<link rel="dns-prefetch" href="https://www.googletagmanager.com" />
|
||||
|
||||
@@ -14,7 +14,7 @@ const DEFAULT_BASE_URL = 'https://api.ashbyhq.com'
|
||||
const DEFAULT_TIMEOUT_MS = 10_000
|
||||
const RETRY_DELAYS_MS = [1_000, 2_000, 4_000]
|
||||
|
||||
export interface DroppedRole {
|
||||
interface DroppedRole {
|
||||
title: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ const DEFAULT_BASE_URL = 'https://cloud.comfy.org'
|
||||
const DEFAULT_TIMEOUT_MS = 10_000
|
||||
const RETRY_DELAYS_MS = [1_000, 2_000, 4_000]
|
||||
|
||||
export interface DroppedNode {
|
||||
interface DroppedNode {
|
||||
name: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { fetchGitHubStars, formatStarCount } from './github'
|
||||
import {
|
||||
fetchGitHubStars,
|
||||
formatStarCount,
|
||||
resetGitHubStarsFetcherForTests
|
||||
} from './github'
|
||||
|
||||
describe('fetchGitHubStars', () => {
|
||||
const savedOverride = process.env.WEBSITE_GITHUB_STARS_OVERRIDE
|
||||
|
||||
afterEach(() => {
|
||||
resetGitHubStarsFetcherForTests()
|
||||
vi.restoreAllMocks()
|
||||
if (savedOverride === undefined)
|
||||
delete process.env.WEBSITE_GITHUB_STARS_OVERRIDE
|
||||
@@ -27,6 +32,67 @@ describe('fetchGitHubStars', () => {
|
||||
'WEBSITE_GITHUB_STARS_OVERRIDE must be a non-negative integer'
|
||||
)
|
||||
})
|
||||
|
||||
it('memoizes concurrent fetches for the same repo to one network call', async () => {
|
||||
const fetchImpl = vi.fn(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ stargazers_count: 110000 }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' }
|
||||
})
|
||||
)
|
||||
|
||||
const [a, b, c] = await Promise.all([
|
||||
fetchGitHubStars('Comfy-Org', 'ComfyUI', fetchImpl as typeof fetch),
|
||||
fetchGitHubStars('Comfy-Org', 'ComfyUI', fetchImpl as typeof fetch),
|
||||
fetchGitHubStars('Comfy-Org', 'ComfyUI', fetchImpl as typeof fetch)
|
||||
])
|
||||
|
||||
expect(a).toBe(110000)
|
||||
expect(b).toBe(110000)
|
||||
expect(c).toBe(110000)
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keys the in-flight cache by owner/repo', async () => {
|
||||
const fetchImpl = vi.fn(async (url: string | URL | Request) => {
|
||||
const href = typeof url === 'string' ? url : url.toString()
|
||||
const count = href.includes('other-repo') ? 42 : 110000
|
||||
return new Response(JSON.stringify({ stargazers_count: count }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' }
|
||||
})
|
||||
})
|
||||
|
||||
const [comfy, other] = await Promise.all([
|
||||
fetchGitHubStars('Comfy-Org', 'ComfyUI', fetchImpl as typeof fetch),
|
||||
fetchGitHubStars('Comfy-Org', 'other-repo', fetchImpl as typeof fetch)
|
||||
])
|
||||
|
||||
expect(comfy).toBe(110000)
|
||||
expect(other).toBe(42)
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('returns null when GitHub responds non-2xx', async () => {
|
||||
const fetchImpl = vi.fn(
|
||||
async () => new Response('rate limited', { status: 403 })
|
||||
)
|
||||
|
||||
await expect(
|
||||
fetchGitHubStars('Comfy-Org', 'ComfyUI', fetchImpl as typeof fetch)
|
||||
).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when fetch throws', async () => {
|
||||
const fetchImpl = vi.fn(async () => {
|
||||
throw new Error('network down')
|
||||
})
|
||||
|
||||
await expect(
|
||||
fetchGitHubStars('Comfy-Org', 'ComfyUI', fetchImpl as typeof fetch)
|
||||
).resolves.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatStarCount', () => {
|
||||
|
||||
@@ -1,22 +1,51 @@
|
||||
const inflight = new Map<string, Promise<number | null>>()
|
||||
|
||||
export function resetGitHubStarsFetcherForTests(): void {
|
||||
inflight.clear()
|
||||
}
|
||||
|
||||
export async function fetchGitHubStars(
|
||||
owner: string,
|
||||
repo: string
|
||||
repo: string,
|
||||
fetchImpl: typeof fetch = fetch
|
||||
): Promise<number | null> {
|
||||
const override = readGitHubStarsOverride()
|
||||
if (override !== undefined) return override
|
||||
|
||||
const key = `${owner}/${repo}`
|
||||
const cached = inflight.get(key)
|
||||
if (cached) return cached
|
||||
|
||||
const request = doFetch(owner, repo, fetchImpl)
|
||||
inflight.set(key, request)
|
||||
return request
|
||||
}
|
||||
|
||||
async function doFetch(
|
||||
owner: string,
|
||||
repo: string,
|
||||
fetchImpl: typeof fetch
|
||||
): Promise<number | null> {
|
||||
try {
|
||||
const res = await fetch(`https://api.github.com/repos/${owner}/${repo}`, {
|
||||
headers: { Accept: 'application/vnd.github.v3+json' }
|
||||
})
|
||||
const res = await fetchImpl(
|
||||
`https://api.github.com/repos/${owner}/${repo}`,
|
||||
{ headers: { Accept: 'application/vnd.github.v3+json' } }
|
||||
)
|
||||
if (!res.ok) return null
|
||||
const data = await res.json()
|
||||
return data.stargazers_count ?? null
|
||||
const data: unknown = await res.json()
|
||||
return readStargazerCount(data)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function readStargazerCount(data: unknown): number | null {
|
||||
if (data === null || typeof data !== 'object') return null
|
||||
if (!('stargazers_count' in data)) return null
|
||||
const count = data.stargazers_count
|
||||
return typeof count === 'number' ? count : null
|
||||
}
|
||||
|
||||
export function formatStarCount(count: number): string {
|
||||
if (count >= 1_000_000) {
|
||||
const m = count / 1_000_000
|
||||
|
||||
@@ -66,6 +66,34 @@ export class ComfyMouse implements Omit<Mouse, 'move'> {
|
||||
await this.drop(options)
|
||||
}
|
||||
|
||||
async middleDrag(
|
||||
from: Position,
|
||||
to: Position,
|
||||
options: Omit<DragOptions, 'button'> = {}
|
||||
) {
|
||||
await this.dragAndDrop(from, to, { ...options, button: 'middle' })
|
||||
}
|
||||
|
||||
async middleDragFromCenter(
|
||||
locator: Locator,
|
||||
delta: { x: number; y: number },
|
||||
options: Omit<DragOptions, 'button'> = {}
|
||||
) {
|
||||
await locator.waitFor({ state: 'visible' })
|
||||
const box = await locator.boundingBox()
|
||||
if (!box) throw new Error('middleDragFromCenter: bounding box not found')
|
||||
|
||||
const start = {
|
||||
x: box.x + box.width / 2,
|
||||
y: box.y + box.height / 2
|
||||
}
|
||||
await this.middleDrag(
|
||||
start,
|
||||
{ x: start.x + delta.x, y: start.y + delta.y },
|
||||
options
|
||||
)
|
||||
}
|
||||
|
||||
/** @see {@link Mouse.move} */
|
||||
async move(to: Position, options = ComfyMouse.defaultOptions) {
|
||||
await this.mouse.move(to.x, to.y, options)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import { test as base } from '@playwright/test'
|
||||
|
||||
export class UserSelectPage {
|
||||
class UserSelectPage {
|
||||
public readonly selectionUrl: string
|
||||
public readonly container: Locator
|
||||
public readonly newUserInput: Locator
|
||||
|
||||
@@ -18,7 +18,7 @@ class ShortcutsTab {
|
||||
}
|
||||
}
|
||||
|
||||
export class LogsTab {
|
||||
class LogsTab {
|
||||
readonly tab: Locator
|
||||
readonly terminalRoot: Locator
|
||||
readonly terminalHost: Locator
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
export class ComfyNodeSearchFilterSelectionPanel {
|
||||
class ComfyNodeSearchFilterSelectionPanel {
|
||||
readonly root: Locator
|
||||
readonly header: Locator
|
||||
|
||||
|
||||
@@ -8,8 +8,6 @@ import type { Position } from '@e2e/fixtures/types'
|
||||
|
||||
const { searchBoxV2 } = TestIds
|
||||
|
||||
export type { RootCategoryId }
|
||||
|
||||
export class ComfyNodeSearchBoxV2 {
|
||||
readonly dialog: Locator
|
||||
readonly input: Locator
|
||||
|
||||
@@ -139,6 +139,7 @@ export class WorkflowsSidebarTab extends SidebarTab {
|
||||
public readonly root: Locator
|
||||
public readonly activeWorkflowLabel: Locator
|
||||
public readonly searchInput: Locator
|
||||
public readonly refreshButton: Locator
|
||||
|
||||
constructor(public override readonly page: Page) {
|
||||
super(page, 'workflows')
|
||||
@@ -147,6 +148,9 @@ export class WorkflowsSidebarTab extends SidebarTab {
|
||||
'.comfyui-workflows-open .p-tree-node-selected .node-label'
|
||||
)
|
||||
this.searchInput = this.root.getByRole('combobox').first()
|
||||
this.refreshButton = this.root.getByTestId(
|
||||
TestIds.sidebar.workflowsRefreshButton
|
||||
)
|
||||
}
|
||||
|
||||
async getOpenedWorkflowNames() {
|
||||
|
||||
@@ -2,8 +2,3 @@ export interface Position {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export interface Size {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
@@ -86,46 +86,6 @@ export const STABLE_LORA: Asset = createModelAsset({
|
||||
updated_at: '2025-02-20T14:00:00Z'
|
||||
})
|
||||
|
||||
export const STABLE_LORA_2: Asset = createModelAsset({
|
||||
id: 'test-lora-002',
|
||||
name: 'add_detail_v2.safetensors',
|
||||
size: 226_492_416,
|
||||
tags: ['models', 'loras'],
|
||||
user_metadata: {
|
||||
base_model: 'sd15',
|
||||
description: 'Add Detail LoRA v2'
|
||||
},
|
||||
created_at: '2025-02-25T11:00:00Z',
|
||||
updated_at: '2025-02-25T11:00:00Z'
|
||||
})
|
||||
|
||||
export const STABLE_VAE: Asset = createModelAsset({
|
||||
id: 'test-vae-001',
|
||||
name: 'sdxl_vae.safetensors',
|
||||
size: 334_641_152,
|
||||
tags: ['models', 'vae'],
|
||||
user_metadata: {
|
||||
base_model: 'sdxl',
|
||||
description: 'SDXL VAE'
|
||||
},
|
||||
created_at: '2025-01-18T16:00:00Z',
|
||||
updated_at: '2025-01-18T16:00:00Z'
|
||||
})
|
||||
|
||||
export const STABLE_EMBEDDING: Asset = createModelAsset({
|
||||
id: 'test-embedding-001',
|
||||
name: 'bad_prompt_v2.pt',
|
||||
size: 32_768,
|
||||
mime_type: 'application/x-pytorch',
|
||||
tags: ['models', 'embeddings'],
|
||||
user_metadata: {
|
||||
base_model: 'sd15',
|
||||
description: 'Negative Embedding: Bad Prompt v2'
|
||||
},
|
||||
created_at: '2025-02-01T09:30:00Z',
|
||||
updated_at: '2025-02-01T09:30:00Z'
|
||||
})
|
||||
|
||||
export const STABLE_INPUT_IMAGE: Asset = createInputAsset({
|
||||
id: 'test-input-001',
|
||||
name: 'reference_photo.png',
|
||||
@@ -136,26 +96,6 @@ export const STABLE_INPUT_IMAGE: Asset = createInputAsset({
|
||||
updated_at: '2025-03-01T09:00:00Z'
|
||||
})
|
||||
|
||||
export const STABLE_INPUT_IMAGE_2: Asset = createInputAsset({
|
||||
id: 'test-input-002',
|
||||
name: 'mask_layer.png',
|
||||
size: 1_048_576,
|
||||
mime_type: 'image/png',
|
||||
tags: ['input'],
|
||||
created_at: '2025-03-05T10:00:00Z',
|
||||
updated_at: '2025-03-05T10:00:00Z'
|
||||
})
|
||||
|
||||
export const STABLE_INPUT_VIDEO: Asset = createInputAsset({
|
||||
id: 'test-input-003',
|
||||
name: 'clip_720p.mp4',
|
||||
size: 15_728_640,
|
||||
mime_type: 'video/mp4',
|
||||
tags: ['input'],
|
||||
created_at: '2025-03-08T14:30:00Z',
|
||||
updated_at: '2025-03-08T14:30:00Z'
|
||||
})
|
||||
|
||||
export const STABLE_OUTPUT: Asset = createOutputAsset({
|
||||
id: 'test-output-001',
|
||||
name: 'ComfyUI_00001_.png',
|
||||
@@ -166,31 +106,6 @@ export const STABLE_OUTPUT: Asset = createOutputAsset({
|
||||
updated_at: '2025-03-10T12:00:00Z'
|
||||
})
|
||||
|
||||
export const STABLE_OUTPUT_2: Asset = createOutputAsset({
|
||||
id: 'test-output-002',
|
||||
name: 'ComfyUI_00002_.png',
|
||||
size: 3_670_016,
|
||||
mime_type: 'image/png',
|
||||
tags: ['output'],
|
||||
created_at: '2025-03-10T12:05:00Z',
|
||||
updated_at: '2025-03-10T12:05:00Z'
|
||||
})
|
||||
export const ALL_MODEL_FIXTURES: Asset[] = [
|
||||
STABLE_CHECKPOINT,
|
||||
STABLE_CHECKPOINT_2,
|
||||
STABLE_LORA,
|
||||
STABLE_LORA_2,
|
||||
STABLE_VAE,
|
||||
STABLE_EMBEDDING
|
||||
]
|
||||
|
||||
export const ALL_INPUT_FIXTURES: Asset[] = [
|
||||
STABLE_INPUT_IMAGE,
|
||||
STABLE_INPUT_IMAGE_2,
|
||||
STABLE_INPUT_VIDEO
|
||||
]
|
||||
|
||||
export const ALL_OUTPUT_FIXTURES: Asset[] = [STABLE_OUTPUT, STABLE_OUTPUT_2]
|
||||
const CHECKPOINT_NAMES = [
|
||||
'sd_xl_base_1.0.safetensors',
|
||||
'v1-5-pruned-emaonly.safetensors',
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
|
||||
/**
|
||||
* Base node definitions covering the default workflow.
|
||||
* Use {@link createMockNodeDefinitions} to extend with per-test overrides.
|
||||
*/
|
||||
const baseNodeDefinitions: Record<string, ComfyNodeDef> = {
|
||||
KSampler: {
|
||||
input: {
|
||||
required: {
|
||||
model: ['MODEL', {}],
|
||||
seed: [
|
||||
'INT',
|
||||
{
|
||||
default: 0,
|
||||
min: 0,
|
||||
max: 0xfffffffffffff,
|
||||
control_after_generate: true
|
||||
}
|
||||
],
|
||||
steps: ['INT', { default: 20, min: 1, max: 10000 }],
|
||||
cfg: ['FLOAT', { default: 8.0, min: 0.0, max: 100.0, step: 0.1 }],
|
||||
sampler_name: [['euler', 'euler_ancestral', 'heun', 'dpm_2'], {}],
|
||||
scheduler: [['normal', 'karras', 'exponential', 'simple'], {}],
|
||||
positive: ['CONDITIONING', {}],
|
||||
negative: ['CONDITIONING', {}],
|
||||
latent_image: ['LATENT', {}]
|
||||
},
|
||||
optional: {
|
||||
denoise: ['FLOAT', { default: 1.0, min: 0.0, max: 1.0, step: 0.01 }]
|
||||
}
|
||||
},
|
||||
output: ['LATENT'],
|
||||
output_is_list: [false],
|
||||
output_name: ['LATENT'],
|
||||
name: 'KSampler',
|
||||
display_name: 'KSampler',
|
||||
description: 'Samples latents using the provided model and conditioning.',
|
||||
category: 'sampling',
|
||||
output_node: false,
|
||||
python_module: 'nodes',
|
||||
deprecated: false,
|
||||
experimental: false
|
||||
},
|
||||
|
||||
CheckpointLoaderSimple: {
|
||||
input: {
|
||||
required: {
|
||||
ckpt_name: [
|
||||
['v1-5-pruned.safetensors', 'sd_xl_base_1.0.safetensors'],
|
||||
{}
|
||||
]
|
||||
}
|
||||
},
|
||||
output: ['MODEL', 'CLIP', 'VAE'],
|
||||
output_is_list: [false, false, false],
|
||||
output_name: ['MODEL', 'CLIP', 'VAE'],
|
||||
name: 'CheckpointLoaderSimple',
|
||||
display_name: 'Load Checkpoint',
|
||||
description: 'Loads a diffusion model checkpoint.',
|
||||
category: 'loaders',
|
||||
output_node: false,
|
||||
python_module: 'nodes',
|
||||
deprecated: false,
|
||||
experimental: false
|
||||
},
|
||||
|
||||
CLIPTextEncode: {
|
||||
input: {
|
||||
required: {
|
||||
text: ['STRING', { multiline: true, dynamicPrompts: true }],
|
||||
clip: ['CLIP', {}]
|
||||
}
|
||||
},
|
||||
output: ['CONDITIONING'],
|
||||
output_is_list: [false],
|
||||
output_name: ['CONDITIONING'],
|
||||
name: 'CLIPTextEncode',
|
||||
display_name: 'CLIP Text Encode (Prompt)',
|
||||
description: 'Encodes a text prompt using a CLIP model.',
|
||||
category: 'conditioning',
|
||||
output_node: false,
|
||||
python_module: 'nodes',
|
||||
deprecated: false,
|
||||
experimental: false
|
||||
},
|
||||
|
||||
EmptyLatentImage: {
|
||||
input: {
|
||||
required: {
|
||||
width: ['INT', { default: 512, min: 16, max: 16384, step: 8 }],
|
||||
height: ['INT', { default: 512, min: 16, max: 16384, step: 8 }],
|
||||
batch_size: ['INT', { default: 1, min: 1, max: 4096 }]
|
||||
}
|
||||
},
|
||||
output: ['LATENT'],
|
||||
output_is_list: [false],
|
||||
output_name: ['LATENT'],
|
||||
name: 'EmptyLatentImage',
|
||||
display_name: 'Empty Latent Image',
|
||||
description: 'Creates an empty latent image of the specified dimensions.',
|
||||
category: 'latent',
|
||||
output_node: false,
|
||||
python_module: 'nodes',
|
||||
deprecated: false,
|
||||
experimental: false
|
||||
},
|
||||
|
||||
VAEDecode: {
|
||||
input: {
|
||||
required: {
|
||||
samples: ['LATENT', {}],
|
||||
vae: ['VAE', {}]
|
||||
}
|
||||
},
|
||||
output: ['IMAGE'],
|
||||
output_is_list: [false],
|
||||
output_name: ['IMAGE'],
|
||||
name: 'VAEDecode',
|
||||
display_name: 'VAE Decode',
|
||||
description: 'Decodes latent images back into pixel space.',
|
||||
category: 'latent',
|
||||
output_node: false,
|
||||
python_module: 'nodes',
|
||||
deprecated: false,
|
||||
experimental: false
|
||||
},
|
||||
|
||||
SaveImage: {
|
||||
input: {
|
||||
required: {
|
||||
images: ['IMAGE', {}],
|
||||
filename_prefix: ['STRING', { default: 'ComfyUI' }]
|
||||
}
|
||||
},
|
||||
output: [],
|
||||
output_is_list: [],
|
||||
output_name: [],
|
||||
name: 'SaveImage',
|
||||
display_name: 'Save Image',
|
||||
description: 'Saves images to the output directory.',
|
||||
category: 'image',
|
||||
output_node: true,
|
||||
python_module: 'nodes',
|
||||
deprecated: false,
|
||||
experimental: false
|
||||
}
|
||||
}
|
||||
|
||||
export function createMockNodeDefinitions(
|
||||
overrides?: Record<string, ComfyNodeDef>
|
||||
): Record<string, ComfyNodeDef> {
|
||||
const base = structuredClone(baseNodeDefinitions)
|
||||
return overrides ? { ...base, ...overrides } : base
|
||||
}
|
||||
@@ -2,11 +2,6 @@ import type {
|
||||
TemplateInfo,
|
||||
WorkflowTemplates
|
||||
} from '@/platform/workflow/templates/types/template'
|
||||
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
|
||||
|
||||
const Cloud = TemplateIncludeOnDistributionEnum.Cloud
|
||||
const Desktop = TemplateIncludeOnDistributionEnum.Desktop
|
||||
const Local = TemplateIncludeOnDistributionEnum.Local
|
||||
|
||||
export function makeTemplate(
|
||||
overrides: Partial<TemplateInfo> & Pick<TemplateInfo, 'name'>
|
||||
@@ -31,33 +26,3 @@ export function mockTemplateIndex(
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export const STABLE_CLOUD_TEMPLATE: TemplateInfo = makeTemplate({
|
||||
name: 'cloud-stable',
|
||||
title: 'Cloud Stable',
|
||||
includeOnDistributions: [Cloud]
|
||||
})
|
||||
|
||||
export const STABLE_DESKTOP_TEMPLATE: TemplateInfo = makeTemplate({
|
||||
name: 'desktop-stable',
|
||||
title: 'Desktop Stable',
|
||||
includeOnDistributions: [Desktop]
|
||||
})
|
||||
|
||||
export const STABLE_LOCAL_TEMPLATE: TemplateInfo = makeTemplate({
|
||||
name: 'local-stable',
|
||||
title: 'Local Stable',
|
||||
includeOnDistributions: [Local]
|
||||
})
|
||||
|
||||
export const STABLE_UNRESTRICTED_TEMPLATE: TemplateInfo = makeTemplate({
|
||||
name: 'unrestricted-stable',
|
||||
title: 'Unrestricted Stable'
|
||||
})
|
||||
|
||||
export const ALL_DISTRIBUTION_TEMPLATES: TemplateInfo[] = [
|
||||
STABLE_CLOUD_TEMPLATE,
|
||||
STABLE_DESKTOP_TEMPLATE,
|
||||
STABLE_LOCAL_TEMPLATE,
|
||||
STABLE_UNRESTRICTED_TEMPLATE
|
||||
]
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
generateOutputAssets
|
||||
} from '@e2e/fixtures/data/assetFixtures'
|
||||
|
||||
export interface MutationRecord {
|
||||
interface MutationRecord {
|
||||
endpoint: string
|
||||
method: string
|
||||
url: string
|
||||
@@ -23,7 +23,7 @@ interface PaginationOptions {
|
||||
total: number
|
||||
hasMore: boolean
|
||||
}
|
||||
export interface AssetConfig {
|
||||
interface AssetConfig {
|
||||
readonly assets: ReadonlyMap<string, Asset>
|
||||
readonly pagination: PaginationOptions | null
|
||||
readonly uploadResponse: Record<string, unknown> | null
|
||||
@@ -33,7 +33,7 @@ function emptyConfig(): AssetConfig {
|
||||
return { assets: new Map(), pagination: null, uploadResponse: null }
|
||||
}
|
||||
|
||||
export type AssetOperator = (config: AssetConfig) => AssetConfig
|
||||
type AssetOperator = (config: AssetConfig) => AssetConfig
|
||||
|
||||
function addAssets(config: AssetConfig, newAssets: Asset[]): AssetConfig {
|
||||
const merged = new Map(config.assets)
|
||||
|
||||
@@ -26,7 +26,7 @@ const historyRoutePattern = /\/api\/history$/
|
||||
* The sidebar filter ultimately matches on the filename extension, so the
|
||||
* fixture also picks an extension-appropriate filename for each kind.
|
||||
*/
|
||||
export type MediaKindFixture = 'images' | 'video' | 'audio' | '3D'
|
||||
type MediaKindFixture = 'images' | 'video' | 'audio' | '3D'
|
||||
|
||||
const DEFAULT_EXTENSION: Record<MediaKindFixture, string> = {
|
||||
images: 'png',
|
||||
@@ -134,16 +134,6 @@ export function createJobsWithExecutionTimes(
|
||||
)
|
||||
}
|
||||
|
||||
/** Create mock imported file names with various media types. */
|
||||
export function createMockImportedFiles(count: number): string[] {
|
||||
const extensions = ['png', 'jpg', 'mp4', 'wav', 'glb', 'txt']
|
||||
return Array.from(
|
||||
{ length: count },
|
||||
(_, i) =>
|
||||
`imported_${String(i + 1).padStart(3, '0')}.${extensions[i % extensions.length]}`
|
||||
)
|
||||
}
|
||||
|
||||
function parseLimit(url: URL, total: number): number {
|
||||
const value = Number(url.searchParams.get('limit'))
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
|
||||
@@ -11,6 +11,11 @@ import { createMockJob } from '@e2e/fixtures/helpers/AssetsHelper'
|
||||
|
||||
const PROMPT_ROUTE_PATTERN = /\/api\/prompt$/
|
||||
|
||||
type RunOptions = {
|
||||
nodeErrors?: Record<string, NodeError>
|
||||
onPromptRequest?: (requestBody: unknown) => void | Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `NodeError` describing a single failed input on a KSampler node.
|
||||
* Shared between specs that surface validation rings via 400 responses.
|
||||
@@ -70,8 +75,9 @@ export class ExecutionHelper {
|
||||
* The app receives a valid PromptResponse so storeJob() fires
|
||||
* and registers the job against the active workflow path.
|
||||
*/
|
||||
async run(): Promise<string> {
|
||||
async run(options: RunOptions = {}): Promise<string> {
|
||||
const jobId = `test-job-${++this.jobCounter}`
|
||||
const { nodeErrors = {}, onPromptRequest } = options
|
||||
|
||||
let fulfilled!: () => void
|
||||
const prompted = new Promise<void>((r) => {
|
||||
@@ -81,12 +87,13 @@ export class ExecutionHelper {
|
||||
await this.page.route(
|
||||
PROMPT_ROUTE_PATTERN,
|
||||
async (route) => {
|
||||
await onPromptRequest?.(route.request().postDataJSON())
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
prompt_id: jobId,
|
||||
node_errors: {}
|
||||
node_errors: nodeErrors
|
||||
})
|
||||
})
|
||||
fulfilled()
|
||||
|
||||
@@ -7,7 +7,7 @@ import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
type ReleaseNote = components['schemas']['ReleaseNote']
|
||||
|
||||
export type HelpMenuItemKey =
|
||||
type HelpMenuItemKey =
|
||||
| 'feedback'
|
||||
| 'help'
|
||||
| 'docs'
|
||||
@@ -17,7 +17,7 @@ export type HelpMenuItemKey =
|
||||
| 'update-comfyui'
|
||||
| 'more'
|
||||
|
||||
export class HelpCenterHelper {
|
||||
class HelpCenterHelper {
|
||||
public readonly button: Locator
|
||||
public readonly popup: Locator
|
||||
public readonly backdrop: Locator
|
||||
|
||||
@@ -7,9 +7,9 @@ import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
const MASK_CANVAS_INDEX = 2
|
||||
const RGB_CANVAS_INDEX = 1
|
||||
|
||||
export type BrushSliderLabel = 'thickness'
|
||||
type BrushSliderLabel = 'thickness'
|
||||
|
||||
export class MaskEditorHelper {
|
||||
class MaskEditorHelper {
|
||||
constructor(private comfyPage: ComfyPage) {}
|
||||
|
||||
private get page() {
|
||||
|
||||
@@ -9,7 +9,7 @@ const modelFoldersRoutePattern = /\/api\/experiment\/models$/
|
||||
const modelFilesRoutePattern = /\/api\/experiment\/models\/([^?]+)/
|
||||
const viewMetadataRoutePattern = /\/api\/view_metadata\/([^?]+)/
|
||||
|
||||
export interface MockModelMetadata {
|
||||
interface MockModelMetadata {
|
||||
'modelspec.title'?: string
|
||||
'modelspec.author'?: string
|
||||
'modelspec.architecture'?: string
|
||||
@@ -18,14 +18,11 @@ export interface MockModelMetadata {
|
||||
'modelspec.tags'?: string
|
||||
}
|
||||
|
||||
export function createMockModelFolders(names: string[]): ModelFolderInfo[] {
|
||||
function createMockModelFolders(names: string[]): ModelFolderInfo[] {
|
||||
return names.map((name) => ({ name, folders: [] }))
|
||||
}
|
||||
|
||||
export function createMockModelFiles(
|
||||
filenames: string[],
|
||||
pathIndex = 0
|
||||
): ModelFile[] {
|
||||
function createMockModelFiles(filenames: string[], pathIndex = 0): ModelFile[] {
|
||||
return filenames.map((name) => ({ name, pathIndex }))
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ const DEFAULT_UPLOAD_URL_RESPONSE: HubAssetUploadUrlResponse = {
|
||||
token: 'mock-upload-token'
|
||||
}
|
||||
|
||||
export class PublishApiHelper {
|
||||
class PublishApiHelper {
|
||||
private routeHandlers: Array<{
|
||||
pattern: string
|
||||
handler: (route: Route) => Promise<void>
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Page } from '@playwright/test'
|
||||
|
||||
import { SubgraphBreadcrumbPanel } from '@e2e/fixtures/components/SubgraphBreadcrumbPanel'
|
||||
|
||||
export class SubgraphBreadcrumbHelper {
|
||||
class SubgraphBreadcrumbHelper {
|
||||
readonly panel: SubgraphBreadcrumbPanel
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
|
||||
@@ -4,33 +4,9 @@ import type {
|
||||
TemplateInfo,
|
||||
WorkflowTemplates
|
||||
} from '@/platform/workflow/templates/types/template'
|
||||
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
|
||||
import {
|
||||
makeTemplate,
|
||||
mockTemplateIndex
|
||||
} from '@e2e/fixtures/data/templateFixtures'
|
||||
import { mockTemplateIndex } from '@e2e/fixtures/data/templateFixtures'
|
||||
|
||||
/**
|
||||
* Generate N deterministic templates, optionally restricted to a distribution.
|
||||
*
|
||||
* Lives here (not in `fixtures/data/`) because `fixtures/data/` is reserved
|
||||
* for static test data with no executable fixture logic.
|
||||
*/
|
||||
function generateTemplates(
|
||||
count: number,
|
||||
distribution?: TemplateIncludeOnDistributionEnum
|
||||
): TemplateInfo[] {
|
||||
const slug = distribution ?? 'unrestricted'
|
||||
return Array.from({ length: count }, (_, i) =>
|
||||
makeTemplate({
|
||||
name: `gen-${slug}-${String(i + 1).padStart(3, '0')}`,
|
||||
title: `Generated ${slug} ${i + 1}`,
|
||||
...(distribution ? { includeOnDistributions: [distribution] } : {})
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export interface TemplateConfig {
|
||||
interface TemplateConfig {
|
||||
readonly templates: readonly TemplateInfo[]
|
||||
readonly index: readonly WorkflowTemplates[] | null
|
||||
}
|
||||
@@ -39,7 +15,7 @@ function emptyConfig(): TemplateConfig {
|
||||
return { templates: [], index: null }
|
||||
}
|
||||
|
||||
export type TemplateOperator = (config: TemplateConfig) => TemplateConfig
|
||||
type TemplateOperator = (config: TemplateConfig) => TemplateConfig
|
||||
|
||||
function cloneTemplates(templates: readonly TemplateInfo[]): TemplateInfo[] {
|
||||
return templates.map((t) => structuredClone(t))
|
||||
@@ -62,46 +38,6 @@ export function withTemplates(templates: TemplateInfo[]): TemplateOperator {
|
||||
return (config) => addTemplates(config, templates)
|
||||
}
|
||||
|
||||
export function withTemplate(template: TemplateInfo): TemplateOperator {
|
||||
return (config) => addTemplates(config, [template])
|
||||
}
|
||||
|
||||
export function withCloudTemplates(count: number): TemplateOperator {
|
||||
return (config) =>
|
||||
addTemplates(
|
||||
config,
|
||||
generateTemplates(count, TemplateIncludeOnDistributionEnum.Cloud)
|
||||
)
|
||||
}
|
||||
|
||||
export function withDesktopTemplates(count: number): TemplateOperator {
|
||||
return (config) =>
|
||||
addTemplates(
|
||||
config,
|
||||
generateTemplates(count, TemplateIncludeOnDistributionEnum.Desktop)
|
||||
)
|
||||
}
|
||||
|
||||
export function withLocalTemplates(count: number): TemplateOperator {
|
||||
return (config) =>
|
||||
addTemplates(
|
||||
config,
|
||||
generateTemplates(count, TemplateIncludeOnDistributionEnum.Local)
|
||||
)
|
||||
}
|
||||
|
||||
export function withUnrestrictedTemplates(count: number): TemplateOperator {
|
||||
return (config) => addTemplates(config, generateTemplates(count))
|
||||
}
|
||||
|
||||
/**
|
||||
* Override the index payload entirely. Useful when a test needs a custom
|
||||
* `WorkflowTemplates[]` shape (e.g. multiple modules).
|
||||
*/
|
||||
export function withRawIndex(index: WorkflowTemplates[]): TemplateOperator {
|
||||
return (config) => ({ ...config, index })
|
||||
}
|
||||
|
||||
export class TemplateHelper {
|
||||
private templates: TemplateInfo[]
|
||||
private index: WorkflowTemplates[] | null
|
||||
|
||||
@@ -121,7 +121,7 @@ export function createRouteMockJob({
|
||||
}
|
||||
}
|
||||
|
||||
export class JobsRouteMocker {
|
||||
class JobsRouteMocker {
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
async mockJobsHistory(
|
||||
|
||||
@@ -10,6 +10,7 @@ export const TestIds = {
|
||||
nodeLibrarySearch: 'node-library-search',
|
||||
nodePreviewCard: 'node-preview-card',
|
||||
workflows: 'workflows-sidebar',
|
||||
workflowsRefreshButton: 'workflows-refresh-button',
|
||||
modeToggle: 'mode-toggle'
|
||||
},
|
||||
tree: {
|
||||
@@ -128,7 +129,8 @@ export const TestIds = {
|
||||
pinIndicator: 'node-pin-indicator',
|
||||
innerWrapper: 'node-inner-wrapper',
|
||||
mainImage: 'main-image',
|
||||
slotConnectionDot: 'slot-connection-dot'
|
||||
slotConnectionDot: 'slot-connection-dot',
|
||||
imageGrid: 'image-grid'
|
||||
},
|
||||
selectionToolbox: {
|
||||
root: 'selection-toolbox',
|
||||
@@ -301,12 +303,3 @@ export const TestIds = {
|
||||
typeFilter: (key: 'input' | 'output') => `search-filter-${key}`
|
||||
}
|
||||
} as const
|
||||
|
||||
export type TestId<K extends keyof typeof TestIds> = Exclude<
|
||||
(typeof TestIds)[K][keyof (typeof TestIds)[K]],
|
||||
(...args: never[]) => string
|
||||
>
|
||||
|
||||
export type TestIdValue = {
|
||||
[K in keyof typeof TestIds]: TestId<K>
|
||||
}[keyof typeof TestIds]
|
||||
|
||||
@@ -19,7 +19,7 @@ export const sharedWorkflowImportScenario = {
|
||||
inputFileName: 'shared_imported_image.png'
|
||||
} as const
|
||||
|
||||
export type SharedWorkflowRequestEvent =
|
||||
type SharedWorkflowRequestEvent =
|
||||
| 'import'
|
||||
| 'input-assets-including-public-before-import'
|
||||
| 'input-assets-including-public-after-import'
|
||||
|
||||
@@ -3,9 +3,7 @@ import type { Page } from '@playwright/test'
|
||||
import { SELECTION_BOUNDS_PADDING } from '@/base/common/selectionBounds'
|
||||
import type { CanvasRect } from '@/base/common/selectionBounds'
|
||||
|
||||
export type { CanvasRect }
|
||||
|
||||
export interface MeasureResult {
|
||||
interface MeasureResult {
|
||||
selectionBounds: CanvasRect | null
|
||||
nodeVisualBounds: Record<string, CanvasRect>
|
||||
}
|
||||
|
||||
@@ -202,7 +202,7 @@ class NodeSlotReference {
|
||||
}
|
||||
}
|
||||
|
||||
export class NodeWidgetReference {
|
||||
class NodeWidgetReference {
|
||||
constructor(
|
||||
readonly index: number,
|
||||
readonly node: NodeReference
|
||||
|
||||
@@ -3,7 +3,7 @@ import { join } from 'path'
|
||||
|
||||
import type { PerfMeasurement } from '@e2e/fixtures/helpers/PerformanceHelper'
|
||||
|
||||
export interface PerfReport {
|
||||
interface PerfReport {
|
||||
timestamp: string
|
||||
gitSha: string
|
||||
branch: string
|
||||
|
||||
@@ -20,9 +20,7 @@ function previewExposureToEntry(
|
||||
return [exposure.sourceNodeId, exposure.sourcePreviewName]
|
||||
}
|
||||
|
||||
export function isPromotedWidgetSource(
|
||||
value: unknown
|
||||
): value is PromotedWidgetSource {
|
||||
function isPromotedWidgetSource(value: unknown): value is PromotedWidgetSource {
|
||||
return (
|
||||
!!value &&
|
||||
typeof value === 'object' &&
|
||||
@@ -33,7 +31,7 @@ export function isPromotedWidgetSource(
|
||||
)
|
||||
}
|
||||
|
||||
export function isNodeProperty(value: unknown): value is NodeProperty {
|
||||
function isNodeProperty(value: unknown): value is NodeProperty {
|
||||
if (value === null || value === undefined) return false
|
||||
const t = typeof value
|
||||
return t === 'string' || t === 'number' || t === 'boolean' || t === 'object'
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
export interface SlotMeasurement {
|
||||
interface SlotMeasurement {
|
||||
key: string
|
||||
offsetX: number
|
||||
offsetY: number
|
||||
}
|
||||
|
||||
export interface NodeSlotData {
|
||||
interface NodeSlotData {
|
||||
nodeId: string
|
||||
nodeW: number
|
||||
nodeH: number
|
||||
|
||||
@@ -22,7 +22,9 @@ export class VueNodeFixture {
|
||||
public readonly root: Locator
|
||||
public readonly widgets: Locator
|
||||
public readonly imagePreview: Locator
|
||||
public readonly imageGrid: Locator
|
||||
public readonly content: Locator
|
||||
public readonly resize: { bottomRight: Locator }
|
||||
|
||||
constructor(private readonly locator: Locator) {
|
||||
this.header = locator.locator('[data-testid^="node-header-"]')
|
||||
@@ -35,7 +37,10 @@ export class VueNodeFixture {
|
||||
this.root = locator
|
||||
this.widgets = this.locator.locator('.lg-node-widget')
|
||||
this.imagePreview = locator.locator('.image-preview')
|
||||
this.imageGrid = locator.getByTestId(TestIds.node.imageGrid)
|
||||
this.content = locator.locator('.lg-node-content')
|
||||
const bottomRight = locator.getByRole('button', { name: 'bottom-right' })
|
||||
this.resize = { bottomRight }
|
||||
}
|
||||
|
||||
async getTitle(): Promise<string> {
|
||||
|
||||
68
browser_tests/tests/dialogs/openSharedWorkflowDialog.spec.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
import type { z } from 'zod'
|
||||
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import type { zSharedWorkflowResponse } from '@/platform/workflow/sharing/schemas/shareSchemas'
|
||||
|
||||
type SharedWorkflowResponse = z.input<typeof zSharedWorkflowResponse>
|
||||
|
||||
const shareId = 'fe828-long-name'
|
||||
|
||||
// Unbroken, space-free name (mimics a content-hash workflow name) that cannot
|
||||
// wrap at whitespace and previously forced the dialog to scroll horizontally.
|
||||
const longWorkflowName =
|
||||
'c23df0133afe9cf61a9c0e3b1f5d8a7e6429bd14f0a3c8e2d9b7165430fedcba99887766554433221100ffeeddccbbaa'
|
||||
|
||||
const longNameWorkflowResponse: SharedWorkflowResponse = {
|
||||
share_id: shareId,
|
||||
workflow_id: 'fe828-long-name-workflow',
|
||||
name: longWorkflowName,
|
||||
listed: true,
|
||||
publish_time: '2026-05-01T00:00:00Z',
|
||||
workflow_json: {
|
||||
version: 0.4,
|
||||
last_node_id: 0,
|
||||
last_link_id: 0,
|
||||
nodes: [],
|
||||
links: []
|
||||
},
|
||||
assets: []
|
||||
}
|
||||
|
||||
async function mockLongNameSharedWorkflow(page: Page): Promise<void> {
|
||||
await page.route(`**/workflows/published/${shareId}`, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(longNameWorkflowResponse)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const test = comfyPageFixture
|
||||
|
||||
test.describe('Open shared workflow dialog', { tag: '@cloud' }, () => {
|
||||
test('wraps a long workflow name instead of scrolling horizontally', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
await mockLongNameSharedWorkflow(page)
|
||||
await comfyPage.setup({ clearStorage: false, url: `/?share=${shareId}` })
|
||||
|
||||
const dialog = page.getByTestId(TestIds.dialogs.openSharedWorkflow)
|
||||
await expect(
|
||||
dialog.getByTestId(TestIds.dialogs.openSharedWorkflowTitle)
|
||||
).toBeVisible()
|
||||
|
||||
const heading = dialog.locator('main h2')
|
||||
await expect(heading).toHaveText(longWorkflowName)
|
||||
|
||||
const { scrollWidth, clientWidth } = await dialog.evaluate((el) => ({
|
||||
scrollWidth: el.scrollWidth,
|
||||
clientWidth: el.clientWidth
|
||||
}))
|
||||
expect(scrollWidth).toBeLessThanOrEqual(clientWidth + 1)
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,60 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { mergeTests } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import type { NodeError } from '@/schemas/apiSchema'
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, webSocketFixture)
|
||||
|
||||
const VALIDATION_ERROR_NODE_ID = '1'
|
||||
const VALIDATION_ERROR_MESSAGE = 'Required input is missing: source'
|
||||
const PARTIAL_EXECUTION_ROOT_NODE_IDS = ['1', '4']
|
||||
|
||||
type PromptRequestNode = {
|
||||
class_type?: string
|
||||
}
|
||||
|
||||
type PromptRequestBody = {
|
||||
prompt?: Record<string, PromptRequestNode>
|
||||
}
|
||||
|
||||
function buildPreviewAnyValidationError(): NodeError {
|
||||
return {
|
||||
class_type: 'PreviewAny',
|
||||
dependent_outputs: [VALIDATION_ERROR_NODE_ID],
|
||||
errors: [
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: VALIDATION_ERROR_MESSAGE,
|
||||
details: '',
|
||||
extra_info: { input_name: 'source' }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function expectPartialExecutionRootNodes(requestBody: unknown): void {
|
||||
const prompt = (requestBody as PromptRequestBody).prompt ?? {}
|
||||
|
||||
for (const nodeId of PARTIAL_EXECUTION_ROOT_NODE_IDS) {
|
||||
expect(prompt[nodeId]).toMatchObject({ class_type: 'PreviewAny' })
|
||||
}
|
||||
}
|
||||
|
||||
async function getValidationErrorMessage(comfyPage: ComfyPage) {
|
||||
return await comfyPage.page.evaluate(
|
||||
(nodeId) =>
|
||||
window.app!.extensionManager.lastNodeErrors?.[nodeId]?.errors[0]
|
||||
?.message ?? null,
|
||||
VALIDATION_ERROR_NODE_ID
|
||||
)
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
@@ -74,3 +127,48 @@ test.describe(
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
test.describe('Execution validation errors', { tag: '@workflow' }, () => {
|
||||
test('preserves validation errors when another active root starts execution', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
true
|
||||
)
|
||||
await comfyPage.setup()
|
||||
await comfyPage.workflow.loadWorkflow('execution/partial_execution')
|
||||
|
||||
const ws = await getWebSocket()
|
||||
const exec = new ExecutionHelper(comfyPage, ws)
|
||||
const nodeErrors = {
|
||||
[VALIDATION_ERROR_NODE_ID]: buildPreviewAnyValidationError()
|
||||
}
|
||||
let promptRequestBody: unknown
|
||||
|
||||
const jobId = await exec.run({
|
||||
nodeErrors,
|
||||
onPromptRequest: (requestBody) => {
|
||||
promptRequestBody = requestBody
|
||||
}
|
||||
})
|
||||
expectPartialExecutionRootNodes(promptRequestBody)
|
||||
await expect
|
||||
.poll(() => getValidationErrorMessage(comfyPage))
|
||||
.toBe(VALIDATION_ERROR_MESSAGE)
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
exec.executionStart(jobId)
|
||||
|
||||
await expect
|
||||
.poll(() => getValidationErrorMessage(comfyPage))
|
||||
.toBe(VALIDATION_ERROR_MESSAGE)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -76,6 +76,34 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
await maskEditor.drawStrokeAndExpectPixels(dialog)
|
||||
})
|
||||
|
||||
test(
|
||||
'Middle-click drag should pan the mask editor canvas',
|
||||
{ tag: ['@canvas'] },
|
||||
async ({ comfyPage, comfyMouse, maskEditor }) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
const pointerZone = dialog.getByTestId('pointer-zone')
|
||||
const getCanvasPosition = () =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const container = document.querySelector('#maskEditorCanvasContainer')
|
||||
if (!(container instanceof HTMLElement)) return null
|
||||
|
||||
return {
|
||||
left: container.style.left,
|
||||
top: container.style.top
|
||||
}
|
||||
})
|
||||
const canvasPositionBefore = await getCanvasPosition()
|
||||
|
||||
await comfyMouse.middleDragFromCenter(
|
||||
pointerZone,
|
||||
{ x: 140, y: 90 },
|
||||
{ steps: 10 }
|
||||
)
|
||||
|
||||
await expect.poll(getCanvasPosition).not.toEqual(canvasPositionBefore)
|
||||
}
|
||||
)
|
||||
|
||||
test('undo reverts a brush stroke', async ({ maskEditor }) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 26 KiB |
@@ -9,7 +9,7 @@ test.describe(
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
// Keep the viewport well below the menu content height so overflow is guaranteed.
|
||||
await comfyPage.page.setViewportSize({ width: 1280, height: 420 })
|
||||
await comfyPage.page.setViewportSize({ width: 1280, height: 300 })
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
|
||||
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
|
||||
|
||||
@@ -233,21 +233,21 @@ test.describe('Node search box', { tag: '@node' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.searchBox.addFilter('MODEL', 'Input Type')
|
||||
await comfyPage.searchBox.addFilter('CLIP', 'Output Type')
|
||||
await comfyPage.searchBox.addFilter('utils', 'Category')
|
||||
await comfyPage.searchBox.addFilter('utilities', 'Category')
|
||||
})
|
||||
|
||||
test('Can remove first filter', async ({ comfyPage }) => {
|
||||
await comfyPage.searchBox.removeFilter(0)
|
||||
await expectFilterChips(comfyPage, ['CLIP', 'utils'])
|
||||
await expectFilterChips(comfyPage, ['CLIP', 'utilities'])
|
||||
await comfyPage.searchBox.removeFilter(0)
|
||||
await expectFilterChips(comfyPage, ['utils'])
|
||||
await expectFilterChips(comfyPage, ['utilities'])
|
||||
await comfyPage.searchBox.removeFilter(0)
|
||||
await expectFilterChips(comfyPage, [])
|
||||
})
|
||||
|
||||
test('Can remove middle filter', async ({ comfyPage }) => {
|
||||
await comfyPage.searchBox.removeFilter(1)
|
||||
await expectFilterChips(comfyPage, ['MODEL', 'utils'])
|
||||
await expectFilterChips(comfyPage, ['MODEL', 'utilities'])
|
||||
})
|
||||
|
||||
test('Can remove last filter', async ({ comfyPage }) => {
|
||||
|
||||
@@ -56,6 +56,34 @@ test.describe('Selection Toolbox - More Options', { tag: '@ui' }, () => {
|
||||
await expect(nodeRef).not.toBeCollapsed()
|
||||
})
|
||||
|
||||
test('More Options menu does not surface duplicate LiteGraph Resize / Collapse / Expand entries', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const nodeRef = (
|
||||
await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
|
||||
)[0]
|
||||
await comfyPage.nodeOps.selectNodeWithPan(nodeRef)
|
||||
|
||||
const menu = await openMoreOptions(comfyPage)
|
||||
|
||||
await expect(
|
||||
menu.getByText('Minimize Node', { exact: true })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
menu.getByRole('menuitem', { name: 'Resize', exact: true })
|
||||
).toHaveCount(0)
|
||||
await expect(
|
||||
menu.getByRole('menuitem', { name: 'Collapse', exact: true })
|
||||
).toHaveCount(0)
|
||||
|
||||
await menu.getByText('Minimize Node', { exact: true }).click()
|
||||
await openMoreOptions(comfyPage)
|
||||
|
||||
await expect(
|
||||
menu.getByRole('menuitem', { name: 'Expand', exact: true })
|
||||
).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('copy via More Options menu', async ({ comfyPage }) => {
|
||||
const nodeRef = (
|
||||
await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Route } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { openErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
||||
import type { UserDataFullInfo } from '@/schemas/apiSchema'
|
||||
|
||||
test.describe('Workflows sidebar', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
@@ -45,6 +47,56 @@ test.describe('Workflows sidebar', () => {
|
||||
.toEqual(expect.arrayContaining(['workflow1', 'workflow2']))
|
||||
})
|
||||
|
||||
test(
|
||||
'Shows loading state while refreshing workflows',
|
||||
{ tag: '@smoke' },
|
||||
async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.workflowsTab
|
||||
const workflowsSyncRoute = /\/api\/userdata\?[^#]*\bdir=workflows\b/
|
||||
const emptyWorkflowList: UserDataFullInfo[] = []
|
||||
|
||||
let releaseSync!: () => void
|
||||
const syncBlocked = new Promise<void>((resolve) => {
|
||||
releaseSync = resolve
|
||||
})
|
||||
const syncFulfillments: Promise<void>[] = []
|
||||
|
||||
const holdSyncResponse = async (route: Route) => {
|
||||
if (route.request().method() !== 'GET') {
|
||||
await route.fallback()
|
||||
return
|
||||
}
|
||||
|
||||
const syncFulfilled = syncBlocked.then(() =>
|
||||
route.fulfill({ json: emptyWorkflowList })
|
||||
)
|
||||
syncFulfillments.push(syncFulfilled)
|
||||
await syncFulfilled
|
||||
}
|
||||
|
||||
await comfyPage.page.route(workflowsSyncRoute, holdSyncResponse)
|
||||
|
||||
try {
|
||||
const syncRequest = comfyPage.page.waitForRequest((request) =>
|
||||
workflowsSyncRoute.test(request.url())
|
||||
)
|
||||
|
||||
await tab.refreshButton.click()
|
||||
await syncRequest
|
||||
|
||||
await expect(tab.refreshButton).toBeDisabled()
|
||||
await expect(tab.refreshButton).toHaveAttribute('aria-busy', 'true')
|
||||
} finally {
|
||||
releaseSync()
|
||||
await Promise.all(syncFulfillments)
|
||||
await comfyPage.page.unroute(workflowsSyncRoute, holdSyncResponse)
|
||||
}
|
||||
|
||||
await expect(tab.refreshButton).toBeEnabled()
|
||||
await expect(tab.refreshButton).toHaveAttribute('aria-busy', 'false')
|
||||
}
|
||||
)
|
||||
|
||||
test('Can duplicate workflow', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.workflowsTab
|
||||
await comfyPage.menu.topbar.saveWorkflow('workflow1')
|
||||
|
||||
87
browser_tests/tests/subgraph/subgraphHashValidation.spec.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
async function waitForRootCanvasReady(page: Page) {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const state = await page.evaluate(() => ({
|
||||
rootId: window.app?.rootGraph?.id ?? '',
|
||||
canvasGraphId: window.app?.canvas?.graph?.id ?? ''
|
||||
}))
|
||||
return state.rootId !== '' && state.canvasGraphId === state.rootId
|
||||
})
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
async function expectCanvasOnRootGraph(page: Page) {
|
||||
await expect
|
||||
.poll(async () =>
|
||||
page.evaluate(() => ({
|
||||
rootId: window.app!.rootGraph.id,
|
||||
canvasGraphId: window.app!.canvas.graph?.id,
|
||||
hash: window.location.hash
|
||||
}))
|
||||
)
|
||||
.toEqual({
|
||||
rootId: expect.any(String),
|
||||
canvasGraphId: expect.stringMatching(/.+/),
|
||||
hash: expect.stringMatching(/^#.+/)
|
||||
})
|
||||
const state = await page.evaluate(() => ({
|
||||
rootId: window.app!.rootGraph.id,
|
||||
canvasGraphId: window.app!.canvas.graph?.id,
|
||||
hash: window.location.hash
|
||||
}))
|
||||
expect(state.canvasGraphId).toBe(state.rootId)
|
||||
expect(state.hash).toBe(`#${state.rootId}`)
|
||||
}
|
||||
|
||||
test.describe(
|
||||
'Subgraph hash validation (FE-559)',
|
||||
{ tag: ['@subgraph'] },
|
||||
() => {
|
||||
test('redirects URL and canvas to root for a non-existent subgraph hash', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await waitForRootCanvasReady(comfyPage.page)
|
||||
const rootId = await comfyPage.page.evaluate(
|
||||
() => window.app!.rootGraph.id
|
||||
)
|
||||
const phantomId = '11111111-1111-4111-8111-111111111111'
|
||||
expect(phantomId).not.toBe(rootId)
|
||||
|
||||
await comfyPage.page.evaluate((hash) => {
|
||||
window.location.hash = hash
|
||||
}, `#${phantomId}`)
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.page.evaluate(() => window.location.hash), {
|
||||
timeout: 5000
|
||||
})
|
||||
.toBe(`#${rootId}`)
|
||||
await expectCanvasOnRootGraph(comfyPage.page)
|
||||
})
|
||||
|
||||
test('redirects URL and canvas to root when hash is malformed', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await waitForRootCanvasReady(comfyPage.page)
|
||||
const rootId = await comfyPage.page.evaluate(
|
||||
() => window.app!.rootGraph.id
|
||||
)
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.location.hash = '#not-a-valid-uuid'
|
||||
})
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.page.evaluate(() => window.location.hash), {
|
||||
timeout: 5000
|
||||
})
|
||||
.toBe(`#${rootId}`)
|
||||
await expectCanvasOnRootGraph(comfyPage.page)
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -1,8 +1,7 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { readFileSync } from 'fs'
|
||||
import { resolve } from 'path'
|
||||
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
@@ -651,6 +650,12 @@ test(
|
||||
await expect.poll(isConnected).toBe(true)
|
||||
})
|
||||
|
||||
const rawClip = await comfyPage.subgraph.getInputBounds()
|
||||
const absolutePos = await comfyPage.canvasOps.toAbsolute(rawClip)
|
||||
const clip = { ...rawClip, ...absolutePos }
|
||||
await comfyPage.canvas.hover({ position: await seedIOSlot.getPosition() })
|
||||
const twoLinkScreenshot = await comfyPage.page.screenshot({ clip })
|
||||
|
||||
const stepsSlot = ksampler.getSlot('steps')
|
||||
|
||||
await test.step('Node -> I/O hover effect', async () => {
|
||||
@@ -659,9 +664,6 @@ test(
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.canvas.hover({ position: await seedIOSlot.getPosition() })
|
||||
|
||||
const rawClip = await comfyPage.subgraph.getInputBounds()
|
||||
const absolutePos = await comfyPage.canvasOps.toAbsolute(rawClip)
|
||||
const clip = { ...rawClip, ...absolutePos }
|
||||
await expect(comfyPage.page).toHaveScreenshot('vue-io-highlight.png', {
|
||||
clip
|
||||
})
|
||||
@@ -699,5 +701,18 @@ test(
|
||||
'opacity',
|
||||
'0'
|
||||
)
|
||||
|
||||
await test.step('Can disconnect link by right click', async () => {
|
||||
const stepsIOSlot = await comfyPage.subgraph.getInputSlot('steps')
|
||||
const { x, y } = await stepsIOSlot.getPosition()
|
||||
await comfyPage.page.mouse.click(x, y, { button: 'right' })
|
||||
await comfyPage.contextMenu.clickLitegraphMenuItem('Remove Slot')
|
||||
|
||||
await expect(slotParent).toHaveCSS('opacity', '0')
|
||||
|
||||
await comfyPage.canvas.hover({ position: await seedIOSlot.getPosition() })
|
||||
const postScreenshot = await comfyPage.page.screenshot({ clip })
|
||||
expect(postScreenshot).toStrictEqual(twoLinkScreenshot)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { WorkflowTemplates } from '@/platform/workflow/templates/types/template'
|
||||
import { getWav } from '@e2e/fixtures/components/AudioPreview'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
@@ -450,4 +451,57 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
test('Can open associated tutorial', async ({ comfyPage }) => {
|
||||
const tutorialUrl = 'https://comfyanonymous.github.io/ComfyUI_examples/'
|
||||
await comfyPage.page.route('**/templates/index.json', async (route) => {
|
||||
const response = [
|
||||
{
|
||||
moduleName: 'default',
|
||||
title: 'Test Templates',
|
||||
type: 'image',
|
||||
templates: [
|
||||
{
|
||||
name: 'template-with-tutorial',
|
||||
title: 'Template with a tutorial',
|
||||
mediaType: 'audio',
|
||||
mediaSubtype: 'wav',
|
||||
description: 'This template has a tutorial',
|
||||
tutorialUrl
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify(response),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
await comfyPage.page.route('**/templates/**.wav', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: getWav(),
|
||||
headers: {
|
||||
'Content-Type': 'image/x-wav',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
})
|
||||
})
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
const card = comfyPage.page.getByTestId(
|
||||
'template-workflow-template-with-tutorial'
|
||||
)
|
||||
await card.hover()
|
||||
const tutorialButton = card.getByRole('button', { name: 'See a tutorial' })
|
||||
await expect(tutorialButton).toBeVisible()
|
||||
const popupPromise = comfyPage.page.waitForEvent('popup', { timeout: 0 })
|
||||
await tutorialButton.click()
|
||||
const popup = await popupPromise
|
||||
expect(popup.url()).toEqual(tutorialUrl)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,29 @@ import {
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test.describe('Vue Nodes Canvas Pan', { tag: '@vue-nodes' }, () => {
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.canvasOps.resetView()
|
||||
})
|
||||
|
||||
test(
|
||||
'Middle-click drag on a Vue node pans canvas',
|
||||
{ tag: ['@canvas'] },
|
||||
async ({ comfyPage, comfyMouse }) => {
|
||||
const node = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
|
||||
const offsetBefore = await comfyPage.canvasOps.getOffset()
|
||||
|
||||
await comfyMouse.middleDragFromCenter(
|
||||
node,
|
||||
{ x: 140, y: 90 },
|
||||
{ steps: 10 }
|
||||
)
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.canvasOps.getOffset())
|
||||
.not.toEqual(offsetBefore)
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'@mobile Can pan with touch',
|
||||
{ tag: '@screenshot' },
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||
import {
|
||||
getPromotedWidgetNames,
|
||||
getPromotedWidgetCountByName
|
||||
} from '@e2e/fixtures/utils/promotedWidgets'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
const wstest = mergeTests(test, webSocketFixture)
|
||||
|
||||
test.describe('Vue Nodes Image Preview', { tag: '@vue-nodes' }, () => {
|
||||
async function loadImageOnNode(comfyPage: ComfyPage) {
|
||||
@@ -136,3 +140,44 @@ test.describe('Vue Nodes Image Preview', { tag: '@vue-nodes' }, () => {
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
async function countColumns(locator: Locator) {
|
||||
return await locator.locator('img').evaluateAll((images) => {
|
||||
const yOffsets = images.map((image) => image.getBoundingClientRect().y)
|
||||
return yOffsets.filter((yOffset) => yOffset === yOffsets[0]).length
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Vue Nodes Batch Image Preview', { tag: '@vue-nodes' }, () => {
|
||||
wstest(
|
||||
'Image previews tile to fit node',
|
||||
async ({ comfyMouse, comfyPage, getWebSocket }) => {
|
||||
const execution = new ExecutionHelper(comfyPage, await getWebSocket())
|
||||
|
||||
await test.step('Add node', async () => {
|
||||
await comfyPage.menu.topbar.newWorkflowButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.searchBoxV2.addNode('Preview Image')
|
||||
const previewImage = comfyPage.vueNodes.getNodeByTitle('Preview Image')
|
||||
await expect(previewImage).toBeVisible()
|
||||
})
|
||||
|
||||
const node = await comfyPage.vueNodes.getFixtureByTitle('Preview Image')
|
||||
|
||||
await test.step('Inject multiple previews', async () => {
|
||||
const file = { filename: 'example.png', type: 'input' }
|
||||
const images = new Array(100).fill(file)
|
||||
execution.executed('', '1', { images })
|
||||
await expect(node.imageGrid.locator('img')).toHaveCount(100)
|
||||
})
|
||||
|
||||
const { bottomRight } = node.resize
|
||||
await expect.poll(() => countColumns(node.imageGrid)).toBe(10)
|
||||
await comfyMouse.resizeByDragging(bottomRight, { x: 200 })
|
||||
await expect.poll(() => countColumns(node.imageGrid)).toBeGreaterThan(10)
|
||||
await comfyMouse.resizeByDragging(bottomRight, { x: -200, y: 200 })
|
||||
await expect.poll(() => countColumns(node.imageGrid)).toBeLessThan(10)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -247,11 +247,8 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
|
||||
)
|
||||
|
||||
const newHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
await expectPosChanged(loadCheckpointHeaderPos, newHeaderPos)
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'vue-node-moved-node-touch.png'
|
||||
)
|
||||
expect(newHeaderPos.x).toBeCloseTo(loadCheckpointHeaderPos.x + 64)
|
||||
expect(newHeaderPos.y).toBeCloseTo(loadCheckpointHeaderPos.y + 64)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 27 KiB |
@@ -5,6 +5,10 @@ import {
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test.describe('Vue Multiline String Widget', { tag: '@vue-nodes' }, () => {
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.canvasOps.resetView()
|
||||
})
|
||||
|
||||
const getFirstClipNode = (comfyPage: ComfyPage) =>
|
||||
comfyPage.vueNodes.getNodeByTitle('CLIP Text Encode (Prompt)').first()
|
||||
|
||||
@@ -54,4 +58,23 @@ test.describe('Vue Multiline String Widget', { tag: '@vue-nodes' }, () => {
|
||||
await textarea.click({ button: 'right' })
|
||||
await expect(vueContextMenu).toBeVisible()
|
||||
})
|
||||
|
||||
test(
|
||||
'Middle-click drag on textarea should pan canvas',
|
||||
{ tag: ['@canvas', '@widget'] },
|
||||
async ({ comfyPage, comfyMouse }) => {
|
||||
const textarea = getFirstMultilineStringWidget(comfyPage)
|
||||
const offsetBefore = await comfyPage.canvasOps.getOffset()
|
||||
|
||||
await comfyMouse.middleDragFromCenter(
|
||||
textarea,
|
||||
{ x: 140, y: 90 },
|
||||
{ steps: 10 }
|
||||
)
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.canvasOps.getOffset())
|
||||
.not.toEqual(offsetBefore)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -145,9 +145,7 @@ export default defineConfig([
|
||||
eslintConfigPrettier,
|
||||
// @ts-expect-error Type incompatibility between storybook plugin and ESLint config types
|
||||
storybookConfigs['flat/recommended'],
|
||||
// @ts-expect-error Type incompatibility between import-x plugin and ESLint config types
|
||||
importX.flatConfigs.recommended,
|
||||
// @ts-expect-error Type incompatibility between import-x plugin and ESLint config types
|
||||
importX.flatConfigs.typescript,
|
||||
{
|
||||
plugins: {
|
||||
|
||||
@@ -43,7 +43,6 @@ const config: KnipConfig = {
|
||||
'@iconify/json',
|
||||
'@primeuix/forms',
|
||||
'@primeuix/styled',
|
||||
'@primeuix/utils',
|
||||
'@primevue/icons'
|
||||
],
|
||||
ignore: [
|
||||
@@ -74,7 +73,7 @@ const config: KnipConfig = {
|
||||
},
|
||||
playwright: {
|
||||
config: ['playwright?(.*).config.ts'],
|
||||
entry: ['**/*.@(spec|test).?(c|m)[jt]s?(x)', 'browser_tests/**/*.ts']
|
||||
entry: ['browser_tests/**/*.@(spec|test).?(c|m)[jt]s?(x)']
|
||||
},
|
||||
tags: [
|
||||
'-knipIgnoreUnusedButUsedByCustomNodes',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.46.3",
|
||||
"version": "1.46.6",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -97,7 +97,7 @@
|
||||
"axios": "catalog:",
|
||||
"chart.js": "^4.5.0",
|
||||
"cva": "catalog:",
|
||||
"dompurify": "^3.2.5",
|
||||
"dompurify": "catalog:",
|
||||
"dotenv": "catalog:",
|
||||
"es-toolkit": "^1.39.9",
|
||||
"extendable-media-recorder": "^9.2.27",
|
||||
@@ -193,7 +193,7 @@
|
||||
"unplugin-icons": "catalog:",
|
||||
"unplugin-typegpu": "catalog:",
|
||||
"unplugin-vue-components": "catalog:",
|
||||
"uuid": "^11.1.0",
|
||||
"uuid": "catalog:",
|
||||
"vite": "catalog:",
|
||||
"vite-plugin-dts": "catalog:",
|
||||
"vite-plugin-html": "catalog:",
|
||||
|
||||
@@ -1892,3 +1892,17 @@ audio.comfy-audio.empty-audio-widget {
|
||||
300% 14px;
|
||||
background-attachment: local, local, scroll, scroll;
|
||||
}
|
||||
|
||||
/*
|
||||
PrimeVue overlays teleport to body. When a Reka modal dialog is open it sets
|
||||
body { pointer-events: none } via DismissableLayer, which propagates to the
|
||||
body-portaled overlays and makes them unclickable. PrimeVue's own Dialog
|
||||
sets pointer-events inline, but Select / ColorPicker / Popover / Autocomplete
|
||||
overlays do not, so they need to opt in here.
|
||||
*/
|
||||
.p-select-overlay,
|
||||
.p-colorpicker-panel,
|
||||
.p-popover,
|
||||
.p-autocomplete-overlay {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
2515
pnpm-lock.yaml
generated
@@ -13,13 +13,13 @@ catalog:
|
||||
'@astrojs/sitemap': ^3.7.1
|
||||
'@astrojs/vue': ^5.0.0
|
||||
'@comfyorg/comfyui-electron-types': 0.6.2
|
||||
'@eslint/js': ^9.39.1
|
||||
'@eslint/js': ^10.0.1
|
||||
'@formkit/auto-animate': ^0.9.0
|
||||
'@iconify-json/lucide': ^1.1.178
|
||||
'@iconify/json': ^2.2.380
|
||||
'@iconify/tailwind4': ^1.2.0
|
||||
'@iconify/tailwind4': ^1.2.3
|
||||
'@iconify/utils': ^3.1.0
|
||||
'@intlify/eslint-plugin-vue-i18n': ^4.1.1
|
||||
'@intlify/eslint-plugin-vue-i18n': ^4.5.0
|
||||
'@lobehub/i18n-cli': ^1.26.1
|
||||
'@pinia/testing': ^1.0.3
|
||||
'@playwright/test': ^1.58.1
|
||||
@@ -66,33 +66,33 @@ catalog:
|
||||
'@webgpu/types': ^0.1.66
|
||||
algoliasearch: ^5.21.0
|
||||
astro: ^5.10.0
|
||||
axios: ^1.13.5
|
||||
axios: ^1.15.2
|
||||
cross-env: ^10.1.0
|
||||
cva: 1.0.0-beta.4
|
||||
dompurify: ^3.3.1
|
||||
dompurify: ^3.4.5
|
||||
dotenv: ^16.4.5
|
||||
eslint: ^9.39.1
|
||||
eslint: ^10.4.0
|
||||
eslint-config-prettier: ^10.1.8
|
||||
eslint-import-resolver-typescript: ^4.4.4
|
||||
eslint-plugin-better-tailwindcss: ^4.3.1
|
||||
eslint-plugin-import-x: ^4.16.1
|
||||
eslint-plugin-import-x: ^4.16.2
|
||||
eslint-plugin-oxlint: 1.59.0
|
||||
eslint-plugin-playwright: ^2.10.1
|
||||
eslint-plugin-storybook: ^10.2.10
|
||||
eslint-plugin-testing-library: ^7.16.1
|
||||
eslint-plugin-unused-imports: ^4.3.0
|
||||
eslint-plugin-vue: ^10.6.2
|
||||
eslint-plugin-unused-imports: ^4.4.1
|
||||
eslint-plugin-vue: ^10.9.1
|
||||
fast-check: ^4.5.3
|
||||
firebase: ^11.6.0
|
||||
glob: ^13.0.6
|
||||
globals: ^16.5.0
|
||||
gsap: ^3.14.2
|
||||
happy-dom: ^20.0.11
|
||||
happy-dom: ^20.8.9
|
||||
husky: ^9.1.7
|
||||
jiti: 2.6.1
|
||||
jsdom: ^27.4.0
|
||||
jsonata: ^2.1.0
|
||||
knip: ^6.3.1
|
||||
knip: ^6.14.1
|
||||
lenis: ^1.3.21
|
||||
lint-staged: ^16.2.7
|
||||
markdown-table: ^3.0.4
|
||||
@@ -108,28 +108,29 @@ catalog:
|
||||
pretty-bytes: ^7.1.0
|
||||
primeicons: ^7.0.0
|
||||
primevue: ^4.2.5
|
||||
reka-ui: ^2.5.0
|
||||
reka-ui: 2.5.0
|
||||
rollup-plugin-visualizer: ^6.0.4
|
||||
storybook: ^10.2.10
|
||||
stylelint: ^16.26.1
|
||||
tailwindcss: ^4.3.0
|
||||
three: ^0.184.0
|
||||
tailwindcss-primeui: ^0.6.1
|
||||
three: ^0.184.0
|
||||
tsx: ^4.15.6
|
||||
tw-animate-css: ^1.3.8
|
||||
typegpu: ^0.8.2
|
||||
typescript: ^5.9.3
|
||||
typescript-eslint: ^8.49.0
|
||||
typescript-eslint: ^8.60.0
|
||||
unplugin-icons: ^22.5.0
|
||||
unplugin-typegpu: 0.8.0
|
||||
unplugin-vue-components: ^30.0.0
|
||||
uuid: ^11.1.1
|
||||
vee-validate: ^4.15.1
|
||||
vite: ^8.0.0
|
||||
vite: ^8.0.13
|
||||
vite-plugin-dts: ^4.5.4
|
||||
vite-plugin-html: ^3.2.2
|
||||
vite-plugin-vue-devtools: ^8.0.0
|
||||
vitest: ^4.0.16
|
||||
vue: ^3.5.13
|
||||
vue: ^3.5.34
|
||||
vue-component-type-helpers: ^3.2.1
|
||||
vue-eslint-parser: ^10.4.0
|
||||
vue-i18n: ^9.14.5
|
||||
@@ -160,3 +161,9 @@ overrides:
|
||||
vite: 'catalog:'
|
||||
'@tiptap/pm': 2.27.2
|
||||
'@types/eslint': '-'
|
||||
#Security overrides
|
||||
lodash: ^4.18.0
|
||||
yaml: ^2.8.3
|
||||
minimatch@^9.0.0: ^9.0.7
|
||||
minimatch@^10.0.0: ^10.2.3
|
||||
ajv@^8.0.0: ^8.18.0
|
||||
|
||||
75
src/base/pointerUtils.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
isMiddleButtonEvent,
|
||||
isMiddleButtonHeld,
|
||||
isMiddlePointerInput
|
||||
} from '@/base/pointerUtils'
|
||||
|
||||
describe('pointerUtils', () => {
|
||||
describe('isMiddlePointerInput', () => {
|
||||
it.for([
|
||||
{
|
||||
name: 'accepts a middle-button pointerdown',
|
||||
event: new PointerEvent('pointerdown', { button: 1, buttons: 4 }),
|
||||
expected: true
|
||||
},
|
||||
{
|
||||
name: 'accepts strict middle-only held buttons',
|
||||
event: new PointerEvent('pointermove', { buttons: 4 }),
|
||||
expected: true
|
||||
},
|
||||
{
|
||||
name: 'rejects chorded pointerdown when middle is only incidentally held',
|
||||
event: new PointerEvent('pointerdown', { button: 0, buttons: 5 }),
|
||||
expected: false
|
||||
}
|
||||
])('$name', ({ event, expected }) => {
|
||||
expect(isMiddlePointerInput(event)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isMiddleButtonHeld', () => {
|
||||
it.for([
|
||||
{
|
||||
name: 'accepts the middle-button bit alone',
|
||||
event: new PointerEvent('pointermove', { buttons: 4 }),
|
||||
expected: true
|
||||
},
|
||||
{
|
||||
name: 'accepts chorded moves that include the middle-button bit',
|
||||
event: new PointerEvent('pointermove', { buttons: 5 }),
|
||||
expected: true
|
||||
},
|
||||
{
|
||||
name: 'accepts pointercancel when the middle-button bit is still held',
|
||||
event: new PointerEvent('pointercancel', { buttons: 4 }),
|
||||
expected: true
|
||||
},
|
||||
{
|
||||
name: 'rejects primary-button-only moves',
|
||||
event: new PointerEvent('pointermove', { buttons: 1 }),
|
||||
expected: false
|
||||
}
|
||||
])('$name', ({ event, expected }) => {
|
||||
expect(isMiddleButtonHeld(event)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isMiddleButtonEvent', () => {
|
||||
it.for([
|
||||
{
|
||||
name: 'accepts a middle-button pointerup',
|
||||
event: new PointerEvent('pointerup', { button: 1 }),
|
||||
expected: true
|
||||
},
|
||||
{
|
||||
name: 'rejects a non-middle changed button even when middle is held',
|
||||
event: new MouseEvent('auxclick', { button: 2, buttons: 4 }),
|
||||
expected: false
|
||||
}
|
||||
])('$name', ({ event, expected }) => {
|
||||
expect(isMiddleButtonEvent(event)).toBe(expected)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -2,21 +2,14 @@
|
||||
* Utilities for pointer event handling
|
||||
*/
|
||||
|
||||
/**
|
||||
* Checks if a pointer or mouse event is a middle button input
|
||||
* @param event - The pointer or mouse event to check
|
||||
* @returns true if the event is from the middle button/wheel
|
||||
*/
|
||||
export function isMiddlePointerInput(
|
||||
event: PointerEvent | MouseEvent
|
||||
): boolean {
|
||||
if ('button' in event && event.button === 1) {
|
||||
return true
|
||||
}
|
||||
|
||||
if ('buttons' in event && typeof event.buttons === 'number') {
|
||||
return event.buttons === 4
|
||||
}
|
||||
|
||||
return false
|
||||
export function isMiddlePointerInput(event: MouseEvent): boolean {
|
||||
return event.button === 1 || event.buttons === 4
|
||||
}
|
||||
|
||||
export function isMiddleButtonHeld(event: MouseEvent): boolean {
|
||||
return (event.buttons & 4) === 4
|
||||
}
|
||||
|
||||
export function isMiddleButtonEvent(event: MouseEvent): boolean {
|
||||
return event.button === 1
|
||||
}
|
||||
|
||||
@@ -42,7 +42,8 @@ import type { StyleValue } from 'vue'
|
||||
|
||||
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
|
||||
import { useMediaCache } from '@/services/mediaCacheService'
|
||||
import type { ClassValue } from '@comfyorg/tailwind-utils'
|
||||
|
||||
type ClassValue = string | Record<string, boolean> | ClassValue[]
|
||||
|
||||
const {
|
||||
src,
|
||||
|
||||
@@ -159,7 +159,7 @@
|
||||
|
||||
<audio
|
||||
:ref="(el) => (audioRef = el as HTMLAudioElement)"
|
||||
:src="audioSrc"
|
||||
:src
|
||||
preload="metadata"
|
||||
class="hidden"
|
||||
/>
|
||||
@@ -192,7 +192,6 @@ const progressRef = ref<HTMLElement>()
|
||||
const {
|
||||
audioRef,
|
||||
waveformRef,
|
||||
audioSrc,
|
||||
bars,
|
||||
loading,
|
||||
isPlaying,
|
||||
|
||||
@@ -190,7 +190,7 @@
|
||||
variant="ghost"
|
||||
rounded="lg"
|
||||
:data-testid="`template-workflow-${template.name}`"
|
||||
class="hover:bg-base-background"
|
||||
class="group/card hover:bg-base-background"
|
||||
@mouseenter="hoveredTemplate = template.name"
|
||||
@mouseleave="hoveredTemplate = null"
|
||||
@click="onLoadWorkflow(template)"
|
||||
@@ -316,11 +316,11 @@
|
||||
class="flex flex-col-reverse justify-center"
|
||||
>
|
||||
<Button
|
||||
v-if="hoveredTemplate === template.name"
|
||||
v-tooltip.bottom="$t('g.seeTutorial')"
|
||||
v-bind="$attrs"
|
||||
:aria-label="$t('g.seeTutorial')"
|
||||
variant="inverted"
|
||||
size="icon"
|
||||
class="not-group-hover/card:opacity-0"
|
||||
@click.stop="openTutorial(template)"
|
||||
>
|
||||
<i class="icon-[lucide--info] size-4" />
|
||||
|
||||
@@ -8,6 +8,10 @@ import { defineComponent, h } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
|
||||
import {
|
||||
onRekaFocusOutside,
|
||||
onRekaPointerDownOutside
|
||||
} from '@/components/dialog/rekaPrimeVueBridge'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const i18n = createI18n({
|
||||
@@ -190,3 +194,88 @@ describe('GlobalDialog Reka parity with PrimeVue', () => {
|
||||
expect(store.isDialogOpen('reka-esc-blocked')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldPreventRekaDismiss', () => {
|
||||
function makeEvent(target: Element | null) {
|
||||
let prevented = false
|
||||
return {
|
||||
detail: { originalEvent: { target } },
|
||||
preventDefault: () => {
|
||||
prevented = true
|
||||
},
|
||||
get defaultPrevented() {
|
||||
return prevented
|
||||
}
|
||||
} as unknown as CustomEvent<{ originalEvent: PointerEvent }> & {
|
||||
defaultPrevented: boolean
|
||||
}
|
||||
}
|
||||
|
||||
it.for([
|
||||
'p-select-overlay',
|
||||
'p-colorpicker-panel',
|
||||
'p-popover',
|
||||
'p-autocomplete-overlay',
|
||||
'p-overlay-mask',
|
||||
'p-dialog'
|
||||
])('prevents dismiss when target is inside %s', (className) => {
|
||||
const overlay = document.createElement('div')
|
||||
overlay.className = className
|
||||
const inner = document.createElement('button')
|
||||
overlay.appendChild(inner)
|
||||
document.body.appendChild(overlay)
|
||||
|
||||
const event = makeEvent(inner)
|
||||
onRekaPointerDownOutside({ dismissableMask: undefined }, event)
|
||||
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
overlay.remove()
|
||||
})
|
||||
|
||||
it('allows dismiss when target is outside any PrimeVue overlay', () => {
|
||||
const event = makeEvent(document.body)
|
||||
onRekaPointerDownOutside({ dismissableMask: undefined }, event)
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
})
|
||||
|
||||
it('prevents dismiss when dismissableMask is false even outside an overlay', () => {
|
||||
const event = makeEvent(document.body)
|
||||
onRekaPointerDownOutside({ dismissableMask: false }, event)
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
})
|
||||
|
||||
it.for(['p-dialog', 'p-select-overlay'])(
|
||||
'focus-outside on a sibling %s portal does not dismiss the parent',
|
||||
(className) => {
|
||||
const overlay = document.createElement('div')
|
||||
overlay.className = className
|
||||
const inner = document.createElement('button')
|
||||
overlay.appendChild(inner)
|
||||
document.body.appendChild(overlay)
|
||||
|
||||
const event = makeEvent(inner)
|
||||
onRekaFocusOutside(event)
|
||||
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
overlay.remove()
|
||||
}
|
||||
)
|
||||
|
||||
it('focus-outside still dismisses when focus moves to a non-portal element', () => {
|
||||
const event = makeEvent(document.body)
|
||||
onRekaFocusOutside(event)
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
})
|
||||
|
||||
it('focus-outside on a sibling Reka portal does not dismiss the parent', () => {
|
||||
const portal = document.createElement('div')
|
||||
portal.setAttribute('role', 'dialog')
|
||||
document.body.appendChild(portal)
|
||||
|
||||
const event = makeEvent(portal)
|
||||
onRekaFocusOutside(event)
|
||||
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
portal.remove()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,9 +8,14 @@
|
||||
@update:open="(open) => onRekaOpenChange(item.key, open)"
|
||||
>
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogOverlay
|
||||
v-reka-z-index
|
||||
:class="item.dialogComponentProps.overlayClass"
|
||||
/>
|
||||
<DialogContent
|
||||
v-reka-z-index
|
||||
:size="item.dialogComponentProps.size ?? 'md'"
|
||||
:maximized="!!item.dialogComponentProps.maximized"
|
||||
:class="item.dialogComponentProps.contentClass"
|
||||
:aria-labelledby="item.key"
|
||||
@escape-key-down="
|
||||
@@ -19,34 +24,51 @@
|
||||
e.preventDefault()
|
||||
"
|
||||
@pointer-down-outside="
|
||||
(e) =>
|
||||
item.dialogComponentProps.dismissableMask === false &&
|
||||
e.preventDefault()
|
||||
(e) => onRekaPointerDownOutside(item.dialogComponentProps, e)
|
||||
"
|
||||
@focus-outside="onRekaFocusOutside"
|
||||
@mousedown="() => dialogStore.riseDialog({ key: item.key })"
|
||||
>
|
||||
<DialogHeader v-if="!item.dialogComponentProps.headless">
|
||||
<component
|
||||
:is="item.headerComponent"
|
||||
v-if="item.headerComponent"
|
||||
v-bind="item.headerProps"
|
||||
:id="item.key"
|
||||
/>
|
||||
<DialogTitle v-else :id="item.key">
|
||||
{{ item.title || ' ' }}
|
||||
</DialogTitle>
|
||||
<DialogClose v-if="item.dialogComponentProps.closable !== false" />
|
||||
</DialogHeader>
|
||||
<div class="flex-1 overflow-auto px-4 py-2">
|
||||
<template v-if="item.dialogComponentProps.headless">
|
||||
<component
|
||||
:is="item.component"
|
||||
v-bind="item.contentProps"
|
||||
:maximized="item.dialogComponentProps.maximized"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter v-if="item.footerComponent">
|
||||
<component :is="item.footerComponent" v-bind="item.footerProps" />
|
||||
</DialogFooter>
|
||||
</template>
|
||||
<template v-else>
|
||||
<DialogHeader>
|
||||
<component
|
||||
:is="item.headerComponent"
|
||||
v-if="item.headerComponent"
|
||||
v-bind="item.headerProps"
|
||||
:id="item.key"
|
||||
/>
|
||||
<DialogTitle v-else :id="item.key">
|
||||
{{ item.title || ' ' }}
|
||||
</DialogTitle>
|
||||
<div class="flex items-center gap-1">
|
||||
<DialogMaximize
|
||||
v-if="item.dialogComponentProps.maximizable"
|
||||
:maximized="!!item.dialogComponentProps.maximized"
|
||||
@toggle="toggleMaximize(item)"
|
||||
/>
|
||||
<DialogClose
|
||||
v-if="item.dialogComponentProps.closable !== false"
|
||||
/>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<div class="flex-1 overflow-auto px-4 py-2">
|
||||
<component
|
||||
:is="item.component"
|
||||
v-bind="item.contentProps"
|
||||
:maximized="item.dialogComponentProps.maximized"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter v-if="item.footerComponent">
|
||||
<component :is="item.footerComponent" v-bind="item.footerProps" />
|
||||
</DialogFooter>
|
||||
</template>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
@@ -55,7 +77,6 @@
|
||||
v-model:visible="item.visible"
|
||||
class="global-dialog"
|
||||
v-bind="item.dialogComponentProps"
|
||||
:pt="getDialogPt(item)"
|
||||
:aria-labelledby="item.key"
|
||||
>
|
||||
<template #header>
|
||||
@@ -86,29 +107,25 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { merge } from 'es-toolkit/compat'
|
||||
import PrimeDialog from 'primevue/dialog'
|
||||
import type { DialogPassThroughOptions } from 'primevue/dialog'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import Dialog from '@/components/ui/dialog/Dialog.vue'
|
||||
import DialogClose from '@/components/ui/dialog/DialogClose.vue'
|
||||
import DialogContent from '@/components/ui/dialog/DialogContent.vue'
|
||||
import DialogFooter from '@/components/ui/dialog/DialogFooter.vue'
|
||||
import DialogHeader from '@/components/ui/dialog/DialogHeader.vue'
|
||||
import DialogMaximize from '@/components/ui/dialog/DialogMaximize.vue'
|
||||
import DialogOverlay from '@/components/ui/dialog/DialogOverlay.vue'
|
||||
import DialogPortal from '@/components/ui/dialog/DialogPortal.vue'
|
||||
import DialogTitle from '@/components/ui/dialog/DialogTitle.vue'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import type { DialogComponentProps, DialogInstance } from '@/stores/dialogStore'
|
||||
import {
|
||||
onRekaFocusOutside,
|
||||
onRekaPointerDownOutside
|
||||
} from '@/components/dialog/rekaPrimeVueBridge'
|
||||
import { vRekaZIndex } from '@/components/dialog/vRekaZIndex'
|
||||
import type { DialogInstance } from '@/stores/dialogStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
const teamWorkspacesEnabled = computed(
|
||||
() => isCloud && flags.teamWorkspacesEnabled
|
||||
)
|
||||
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
function isRekaItem(item: DialogInstance) {
|
||||
@@ -119,20 +136,8 @@ function onRekaOpenChange(key: string, open: boolean) {
|
||||
if (!open) dialogStore.closeDialog({ key })
|
||||
}
|
||||
|
||||
function getDialogPt(item: {
|
||||
key: string
|
||||
dialogComponentProps: DialogComponentProps
|
||||
}): DialogPassThroughOptions {
|
||||
const isWorkspaceSettingsDialog =
|
||||
item.key === 'global-settings' && teamWorkspacesEnabled.value
|
||||
const basePt = item.dialogComponentProps.pt || {}
|
||||
|
||||
if (isWorkspaceSettingsDialog) {
|
||||
return merge(basePt, {
|
||||
mask: { class: 'p-8' }
|
||||
})
|
||||
}
|
||||
return basePt
|
||||
function toggleMaximize(item: DialogInstance) {
|
||||
item.dialogComponentProps.maximized = !item.dialogComponentProps.maximized
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -163,19 +168,6 @@ function getDialogPt(item: {
|
||||
}
|
||||
}
|
||||
|
||||
/* Workspace mode: wider settings dialog */
|
||||
.settings-dialog-workspace {
|
||||
width: 100%;
|
||||
max-width: 1440px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.settings-dialog-workspace .p-dialog-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.manager-dialog {
|
||||
height: 80vh;
|
||||
max-width: 1724px;
|
||||
|
||||
@@ -244,7 +244,7 @@
|
||||
<ContextMenuPortal>
|
||||
<ContextMenuContent
|
||||
:style="keybindingOverlayContentStyle"
|
||||
class="z-1200 min-w-56 rounded-lg border border-border-subtle bg-base-background px-2 py-3 shadow-interface"
|
||||
class="z-1800 min-w-56 rounded-lg border border-border-subtle bg-base-background px-2 py-3 shadow-interface"
|
||||
>
|
||||
<ContextMenuItem
|
||||
class="flex cursor-pointer items-center gap-2 rounded-sm px-3 py-2 text-sm text-text-primary outline-none select-none hover:bg-node-component-surface-hovered focus:bg-node-component-surface-hovered data-disabled:cursor-default data-disabled:opacity-50"
|
||||
|
||||
49
src/components/dialog/rekaPrimeVueBridge.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
// PrimeVue overlays (Select, ColorPicker, Popover, Autocomplete, stacked
|
||||
// PrimeVue Dialogs) teleport to body. Reka treats clicks on body-portaled
|
||||
// elements as outside its dialog and would auto-dismiss on the first
|
||||
// interaction, tearing the overlay down mid-interaction. Treat any
|
||||
// PrimeVue overlay click as inside.
|
||||
const PRIMEVUE_OVERLAY_SELECTORS =
|
||||
'.p-select-overlay, .p-colorpicker-panel, .p-popover, .p-autocomplete-overlay, .p-overlay, .p-overlay-mask, .p-dialog'
|
||||
|
||||
// Reka portals its own dialogs / popovers / menus into the body too. When a
|
||||
// nested Reka layer opens on top of a non-modal parent, the parent's
|
||||
// DismissableLayer sees the focus shift / pointer-down as "outside" and would
|
||||
// dismiss itself. These selectors cover the portaled roots so we can treat
|
||||
// interactions on them as inside.
|
||||
const REKA_PORTAL_SELECTORS =
|
||||
'[data-reka-popper-content-wrapper], [data-reka-dialog-content], [data-reka-menu-content], [data-reka-context-menu-content], [role="dialog"], [role="menu"], [role="listbox"], [role="tooltip"]'
|
||||
|
||||
const OUTSIDE_LAYER_SELECTORS = `${PRIMEVUE_OVERLAY_SELECTORS}, ${REKA_PORTAL_SELECTORS}`
|
||||
|
||||
type OutsideEvent = CustomEvent<{ originalEvent: Event }>
|
||||
|
||||
function isInsideOverlay(target: EventTarget | null): boolean {
|
||||
return (
|
||||
target instanceof Element &&
|
||||
target.closest(OUTSIDE_LAYER_SELECTORS) !== null
|
||||
)
|
||||
}
|
||||
|
||||
export function onRekaPointerDownOutside(
|
||||
options: { dismissableMask?: boolean },
|
||||
event: OutsideEvent
|
||||
) {
|
||||
if (isInsideOverlay(event.detail.originalEvent.target)) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
if (options.dismissableMask === false) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
// Focus / interact-outside fires when focus moves to a sibling portal (a
|
||||
// nested Reka or PrimeVue dialog teleported to body). Without this guard a
|
||||
// non-modal Reka dialog would dismiss itself the moment a nested dialog
|
||||
// receives focus.
|
||||
export function onRekaFocusOutside(event: OutsideEvent) {
|
||||
if (isInsideOverlay(event.detail.originalEvent.target)) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
17
src/components/dialog/vRekaZIndex.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { ZIndex } from '@primeuix/utils/zindex'
|
||||
import type { Directive } from 'vue'
|
||||
|
||||
// Both Reka and PrimeVue dialogs can appear at any depth in dialogStack, in
|
||||
// any order. PrimeVue auto-increments a per-key z-index counter so later
|
||||
// dialogs always cover earlier ones; Reka uses a static z-1700 class which
|
||||
// can lose to an already-open PrimeVue dialog. Registering Reka's content
|
||||
// element with the same ZIndex counter (key 'modal', base 1700) makes both
|
||||
// renderers share one stacking sequence: whichever dialog opens last wins.
|
||||
export const vRekaZIndex: Directive<HTMLElement> = {
|
||||
mounted(el) {
|
||||
ZIndex.set('modal', el, 1700)
|
||||
},
|
||||
beforeUnmount(el) {
|
||||
ZIndex.clear(el)
|
||||
}
|
||||
}
|
||||
@@ -63,9 +63,9 @@
|
||||
v-if="shouldRenderVueNodes && comfyApp.canvas && comfyAppReady"
|
||||
:canvas="comfyApp.canvas"
|
||||
@wheel.capture="canvasInteractions.forwardEventToCanvas"
|
||||
@pointerdown.capture="forwardPanEvent"
|
||||
@pointerup.capture="forwardPanEvent"
|
||||
@pointermove.capture="forwardPanEvent"
|
||||
@pointerdown.capture="forwardPointerDownPanEvent"
|
||||
@pointerup.capture="forwardPointerUpPanEvent"
|
||||
@pointermove.capture="forwardPointerMovePanEvent"
|
||||
>
|
||||
<!-- Vue nodes rendered based on graph nodes -->
|
||||
<LGraphNode
|
||||
@@ -120,7 +120,11 @@ import {
|
||||
} from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { isMiddlePointerInput } from '@/base/pointerUtils'
|
||||
import {
|
||||
isMiddleButtonEvent,
|
||||
isMiddleButtonHeld,
|
||||
isMiddlePointerInput
|
||||
} from '@/base/pointerUtils'
|
||||
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
|
||||
import TopMenuSection from '@/components/TopMenuSection.vue'
|
||||
import BottomPanel from '@/components/bottomPanel/BottomPanel.vue'
|
||||
@@ -593,8 +597,23 @@ onUnmounted(() => {
|
||||
cleanupErrorHooks = null
|
||||
vueNodeLifecycle.cleanup()
|
||||
})
|
||||
function forwardPanEvent(e: PointerEvent) {
|
||||
if (!isMiddlePointerInput(e)) return
|
||||
function forwardPointerDownPanEvent(e: PointerEvent) {
|
||||
forwardPanEvent(e, isMiddlePointerInput)
|
||||
}
|
||||
|
||||
function forwardPointerMovePanEvent(e: PointerEvent) {
|
||||
forwardPanEvent(e, isMiddleButtonHeld)
|
||||
}
|
||||
|
||||
function forwardPointerUpPanEvent(e: PointerEvent) {
|
||||
forwardPanEvent(e, isMiddleButtonEvent)
|
||||
}
|
||||
|
||||
function forwardPanEvent(
|
||||
e: PointerEvent,
|
||||
isMiddleInput: (event: PointerEvent) => boolean
|
||||
) {
|
||||
if (!isMiddleInput(e)) return
|
||||
if (shouldIgnoreCopyPaste(e.target) && document.activeElement === e.target)
|
||||
return
|
||||
|
||||
|
||||
@@ -472,7 +472,9 @@ describe('SelectionToolbox', () => {
|
||||
const forwardEventToCanvasSpy = vi.fn()
|
||||
mockCanvasInteractions.mockReturnValue({
|
||||
handleWheel: vi.fn(),
|
||||
handlePointer: vi.fn(),
|
||||
handlePointerDown: vi.fn(),
|
||||
handlePointerMove: vi.fn(),
|
||||
handlePointerUp: vi.fn(),
|
||||
forwardEventToCanvas: forwardEventToCanvasSpy,
|
||||
shouldHandleNodePointerEvents: { value: true } as ReturnType<
|
||||
typeof useCanvasInteractions
|
||||
|
||||
@@ -167,7 +167,10 @@ describe('TabErrors.vue', () => {
|
||||
|
||||
expect(screen.getAllByText('KSampler').length).toBeGreaterThanOrEqual(1)
|
||||
expect(screen.getByText('#10')).toBeInTheDocument()
|
||||
expect(screen.getByText('RuntimeError: Out of memory')).toBeInTheDocument()
|
||||
expect(screen.getByText('Execution failed')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('Node threw an error during execution.')
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText(/Line 1/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -246,9 +249,9 @@ describe('TabErrors.vue', () => {
|
||||
})
|
||||
|
||||
expect(screen.getAllByText('KSampler').length).toBeGreaterThanOrEqual(1)
|
||||
expect(screen.getByText('RuntimeError: Out of memory')).toBeInTheDocument()
|
||||
expect(screen.getByText('Execution failed')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('runtime-error-panel')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('RuntimeError: Out of memory')).toHaveLength(1)
|
||||
expect(screen.getAllByText('Execution failed')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('shows missing model Refresh in the section header when no model is downloadable', async () => {
|
||||
|
||||
@@ -46,7 +46,22 @@ vi.mock('@/i18n', () => {
|
||||
'errorCatalog.promptErrors.prompt_no_outputs.title':
|
||||
'Prompt has no outputs',
|
||||
'errorCatalog.promptErrors.prompt_no_outputs.desc':
|
||||
'The workflow does not contain any output nodes (e.g. Save Image, Preview Image) to produce a result.'
|
||||
'The workflow does not contain any output nodes (e.g. Save Image, Preview Image) to produce a result.',
|
||||
'errorCatalog.runtimeErrors.execution_failed.title': 'Execution failed',
|
||||
'errorCatalog.runtimeErrors.execution_failed.message':
|
||||
'Node threw an error during execution.',
|
||||
'errorCatalog.runtimeErrors.execution_failed.itemLabel': '{nodeName}',
|
||||
'errorCatalog.runtimeErrors.execution_failed.toastTitle':
|
||||
'{nodeName} failed',
|
||||
'errorCatalog.runtimeErrors.execution_failed.toastMessage':
|
||||
'This node threw an error during execution. Check its inputs or try a different configuration.',
|
||||
'errorCatalog.runtimeErrors.out_of_memory.title': 'Generation failed',
|
||||
'errorCatalog.runtimeErrors.out_of_memory.message':
|
||||
'Not enough GPU memory. Try reducing image resolution or batch size and run again.',
|
||||
'errorCatalog.runtimeErrors.out_of_memory.itemLabel': '{nodeName}',
|
||||
'errorCatalog.runtimeErrors.out_of_memory.toastTitle': 'Generation failed',
|
||||
'errorCatalog.runtimeErrors.out_of_memory.toastMessage':
|
||||
'Not enough GPU memory. Try reducing image resolution or batch size and run again.'
|
||||
}
|
||||
|
||||
const interpolate = (
|
||||
@@ -158,6 +173,7 @@ function createErrorGroups() {
|
||||
describe('useErrorGroups', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
mockIsCloud.value = false
|
||||
})
|
||||
|
||||
describe('missingPackGroups', () => {
|
||||
@@ -421,7 +437,8 @@ describe('useErrorGroups', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('includes execution error from runtime errors', async () => {
|
||||
it('uses general execution_failed display fields for unrecognized runtime execution errors', async () => {
|
||||
mockIsCloud.value = true
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.lastExecutionError = {
|
||||
prompt_id: 'test-prompt',
|
||||
@@ -430,7 +447,7 @@ describe('useErrorGroups', () => {
|
||||
node_type: 'KSampler',
|
||||
executed: [],
|
||||
exception_type: 'RuntimeError',
|
||||
exception_message: 'CUDA out of memory',
|
||||
exception_message: 'mat1 and mat2 shapes cannot be multiplied',
|
||||
traceback: ['line 1', 'line 2'],
|
||||
current_inputs: {},
|
||||
current_outputs: {}
|
||||
@@ -443,15 +460,52 @@ describe('useErrorGroups', () => {
|
||||
expect(execGroups.length).toBeGreaterThan(0)
|
||||
if (execGroups[0].type !== 'execution') return
|
||||
expect(execGroups[0].cards[0].errors[0]).toMatchObject({
|
||||
message: 'RuntimeError: CUDA out of memory',
|
||||
message: 'RuntimeError: mat1 and mat2 shapes cannot be multiplied',
|
||||
details: 'line 1\nline 2',
|
||||
isRuntimeError: true,
|
||||
exceptionType: 'RuntimeError'
|
||||
exceptionType: 'RuntimeError',
|
||||
catalogId: 'execution_failed',
|
||||
displayTitle: 'Execution failed',
|
||||
displayMessage: 'Node threw an error during execution.',
|
||||
displayItemLabel: 'KSampler',
|
||||
toastTitle: 'KSampler failed',
|
||||
toastMessage:
|
||||
'This node threw an error during execution. Check its inputs or try a different configuration.'
|
||||
})
|
||||
// TODO(FE-816 overlay-redesign): Runtime execution errors intentionally
|
||||
// bypass catalog display fields until targeted runtime handling lands.
|
||||
expect(execGroups[0].cards[0].errors[0].displayItemLabel).toBeUndefined()
|
||||
expect(execGroups[0].cards[0].errors[0].toastTitle).toBeUndefined()
|
||||
})
|
||||
|
||||
it('adds display fields for targeted runtime execution errors', async () => {
|
||||
mockIsCloud.value = true
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.lastExecutionError = {
|
||||
prompt_id: 'test-prompt',
|
||||
timestamp: Date.now(),
|
||||
node_id: 5,
|
||||
node_type: 'KSampler',
|
||||
executed: [],
|
||||
exception_type: 'torch.OutOfMemoryError',
|
||||
exception_message:
|
||||
'Allocation on device 0 failed.\nThis error means you ran out of memory on your GPU.',
|
||||
traceback: ['line 1', 'line 2'],
|
||||
current_inputs: {},
|
||||
current_outputs: {}
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
const execGroup = groups.allErrorGroups.value.find(
|
||||
(g) => g.type === 'execution'
|
||||
)
|
||||
expect(execGroup?.type).toBe('execution')
|
||||
if (execGroup?.type !== 'execution') return
|
||||
|
||||
const error = execGroup.cards[0].errors[0]
|
||||
expect(error.message).toContain('torch.OutOfMemoryError:')
|
||||
expect(error.catalogId).toBe('out_of_memory')
|
||||
expect(error.displayMessage).toBe(
|
||||
'Not enough GPU memory. Try reducing image resolution or batch size and run again.'
|
||||
)
|
||||
expect(error.displayItemLabel).toBe('KSampler')
|
||||
expect(error.toastTitle).toBe('Generation failed')
|
||||
})
|
||||
|
||||
it('includes prompt error when present', async () => {
|
||||
|
||||
@@ -427,7 +427,13 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
message: `${e.exception_type}: ${e.exception_message}`,
|
||||
details: e.traceback.join('\n'),
|
||||
isRuntimeError: true,
|
||||
exceptionType: e.exception_type
|
||||
exceptionType: e.exception_type,
|
||||
...resolveRunErrorMessage({
|
||||
kind: 'execution',
|
||||
error: e,
|
||||
nodeDisplayName:
|
||||
resolveNodeInfo(String(e.node_id)).title || e.node_type
|
||||
})
|
||||
}
|
||||
],
|
||||
filterBySelection
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { render } from '@testing-library/vue'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { nextTick, reactive, ref, watchEffect } from 'vue'
|
||||
@@ -34,6 +35,7 @@ const {
|
||||
bookmarkedWorkflows: [] as ComfyWorkflow[],
|
||||
openWorkflows: [] as ComfyWorkflow[],
|
||||
activeWorkflow: null as ComfyWorkflow | null,
|
||||
isSyncLoading: false,
|
||||
syncWorkflows: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
|
||||
@@ -232,6 +234,7 @@ describe('BaseWorkflowsSidebarTab', () => {
|
||||
mockWorkflowStore.bookmarkedWorkflows = []
|
||||
mockWorkflowStore.openWorkflows = []
|
||||
mockWorkflowStore.activeWorkflow = null
|
||||
mockWorkflowStore.isSyncLoading = false
|
||||
})
|
||||
|
||||
const renderComponent = () =>
|
||||
@@ -279,6 +282,36 @@ describe('BaseWorkflowsSidebarTab', () => {
|
||||
expect(getLeafPaths(getSearchRoot())).toEqual(['workflows/test-alpha.json'])
|
||||
})
|
||||
|
||||
it('refreshes when idle and exposes busy state while workflows are syncing', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
renderComponent()
|
||||
|
||||
const refreshButton = screen.getByRole('button', { name: 'g.refresh' })
|
||||
expect(refreshButton).toBeEnabled()
|
||||
expect(refreshButton).toHaveAttribute('aria-busy', 'false')
|
||||
|
||||
await user.click(refreshButton)
|
||||
|
||||
expect(mockWorkflowStore.syncWorkflows).toHaveBeenCalledTimes(1)
|
||||
|
||||
mockWorkflowStore.isSyncLoading = true
|
||||
await nextTick()
|
||||
|
||||
expect(refreshButton).toBeDisabled()
|
||||
expect(refreshButton).toHaveAttribute('aria-busy', 'true')
|
||||
|
||||
mockWorkflowStore.isSyncLoading = false
|
||||
await nextTick()
|
||||
|
||||
expect(refreshButton).toBeEnabled()
|
||||
expect(refreshButton).toHaveAttribute('aria-busy', 'false')
|
||||
|
||||
await user.click(refreshButton)
|
||||
|
||||
expect(mockWorkflowStore.syncWorkflows).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('reactively updates filtered workflows when a workflow is removed', async () => {
|
||||
mockWorkflowStore.workflows = [
|
||||
createMockWorkflow('workflows/test-alpha.json'),
|
||||
|
||||
@@ -11,12 +11,24 @@
|
||||
<template #tool-buttons>
|
||||
<Button
|
||||
v-tooltip.bottom="$t('g.refresh')"
|
||||
data-testid="workflows-refresh-button"
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
:aria-label="$t('g.refresh')"
|
||||
:aria-busy="workflowStore.isSyncLoading"
|
||||
:disabled="workflowStore.isSyncLoading"
|
||||
@click="workflowStore.syncWorkflows()"
|
||||
>
|
||||
<i class="icon-[lucide--refresh-cw] size-4" />
|
||||
<i
|
||||
aria-hidden="true"
|
||||
data-testid="workflows-refresh-icon"
|
||||
:class="
|
||||
cn(
|
||||
'icon-[lucide--refresh-cw] size-4',
|
||||
workflowStore.isSyncLoading && 'animate-spin'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</Button>
|
||||
</template>
|
||||
<template #header>
|
||||
@@ -170,6 +182,7 @@ import {
|
||||
getWorkflowSuffix
|
||||
} from '@/utils/formatUtil'
|
||||
import { buildTree, sortedTree } from '@/utils/treeUtil'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { title, filter, searchSubject, dataTestid, hideLeafIcon } = defineProps<{
|
||||
title: string
|
||||
|
||||
@@ -10,11 +10,13 @@ import { dialogContentVariants } from './dialog.variants'
|
||||
|
||||
const {
|
||||
size,
|
||||
maximized = false,
|
||||
class: customClass = '',
|
||||
...restProps
|
||||
} = defineProps<
|
||||
DialogContentProps & {
|
||||
size?: DialogContentSize
|
||||
maximized?: boolean
|
||||
class?: HTMLAttributes['class']
|
||||
}
|
||||
>()
|
||||
@@ -26,7 +28,7 @@ const forwarded = useForwardPropsEmits(restProps, emits)
|
||||
<template>
|
||||
<DialogContent
|
||||
v-bind="forwarded"
|
||||
:class="cn(dialogContentVariants({ size }), customClass)"
|
||||
:class="cn(dialogContentVariants({ size, maximized }), customClass)"
|
||||
>
|
||||
<slot />
|
||||
</DialogContent>
|
||||
|
||||
25
src/components/ui/dialog/DialogMaximize.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const { maximized = false } = defineProps<{ maximized?: boolean }>()
|
||||
const emit = defineEmits<{ toggle: [] }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Button
|
||||
:aria-label="maximized ? t('g.restoreDialog') : t('g.maximizeDialog')"
|
||||
size="icon"
|
||||
variant="muted-textonly"
|
||||
@click="emit('toggle')"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
maximized ? 'icon-[lucide--minimize-2]' : 'icon-[lucide--maximize-2]'
|
||||
"
|
||||
/>
|
||||
</Button>
|
||||
</template>
|
||||
@@ -2,7 +2,7 @@ import type { VariantProps } from 'cva'
|
||||
import { cva } from 'cva'
|
||||
|
||||
export const dialogContentVariants = cva({
|
||||
base: 'fixed top-1/2 left-1/2 z-1700 flex max-h-[85vh] w-[calc(100vw-1rem)] -translate-x-1/2 -translate-y-1/2 flex-col rounded-lg border border-border-subtle bg-base-background shadow-lg outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
base: 'fixed z-1700 flex flex-col rounded-lg border border-border-subtle bg-base-background shadow-lg outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
variants: {
|
||||
size: {
|
||||
sm: 'sm:max-w-sm',
|
||||
@@ -10,14 +10,19 @@ export const dialogContentVariants = cva({
|
||||
lg: 'sm:max-w-3xl',
|
||||
xl: 'sm:max-w-5xl',
|
||||
full: 'sm:max-w-[calc(100vw-1rem)]'
|
||||
},
|
||||
maximized: {
|
||||
true: 'inset-2 top-2 left-2 size-auto max-h-none max-w-none sm:max-w-none',
|
||||
false: 'top-1/2 left-1/2 max-h-[85vh] w-[calc(100vw-1rem)] -translate-1/2'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'md'
|
||||
size: 'md',
|
||||
maximized: false
|
||||
}
|
||||
})
|
||||
|
||||
export type DialogContentVariants = VariantProps<typeof dialogContentVariants>
|
||||
type DialogContentVariants = VariantProps<typeof dialogContentVariants>
|
||||
|
||||
export type DialogContentSize = NonNullable<DialogContentVariants['size']>
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import type { MenuOption } from './useMoreOptionsMenu'
|
||||
import {
|
||||
buildStructuredMenu,
|
||||
@@ -175,6 +177,28 @@ describe('contextMenuConverter', () => {
|
||||
expect(result.find((opt) => opt.label === 'Properties')).toBeUndefined()
|
||||
})
|
||||
|
||||
it.for([
|
||||
{ label: 'Resize', callback: LGraphCanvas.onMenuResizeNode },
|
||||
{ label: 'Collapse', callback: LGraphCanvas.onMenuNodeCollapse },
|
||||
{ label: 'Expand', callback: LGraphCanvas.onMenuNodeCollapse }
|
||||
])(
|
||||
'should skip built-in LiteGraph $label entry by callback identity',
|
||||
({ label, callback }) => {
|
||||
const items = [{ content: label, callback }]
|
||||
const result = convertContextMenuToOptions(items, undefined, false)
|
||||
expect(result.find((opt) => opt.label === label)).toBeUndefined()
|
||||
}
|
||||
)
|
||||
|
||||
it.for(['Resize', 'Collapse', 'Expand'])(
|
||||
'should keep extension-provided %s entries (different callback identity)',
|
||||
(label) => {
|
||||
const items = [{ content: label, callback: () => {} }]
|
||||
const result = convertContextMenuToOptions(items, undefined, false)
|
||||
expect(result.find((opt) => opt.label === label)).toBeDefined()
|
||||
}
|
||||
)
|
||||
|
||||
it('should convert basic menu items with content', () => {
|
||||
const items = [{ content: 'Test Item', callback: () => {} }]
|
||||
const result = convertContextMenuToOptions(items, undefined, false)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { default as DOMPurify } from 'dompurify'
|
||||
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphCanvas, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
IContextMenuValue,
|
||||
LGraphNode,
|
||||
@@ -24,6 +24,17 @@ const HARD_BLACKLIST = new Set([
|
||||
'Copy (Clipspace)'
|
||||
])
|
||||
|
||||
/**
|
||||
* Callbacks of built-in LiteGraph node menu items that are superseded by
|
||||
* the Vue node menu (Minimize Node / Expand Node) or have no working
|
||||
* Vue-side equivalent. Matched by callback identity so that extensions
|
||||
* providing their own items with the same labels are not affected.
|
||||
*/
|
||||
const SUPPRESSED_LITEGRAPH_CALLBACKS = new Set<unknown>([
|
||||
LGraphCanvas.onMenuResizeNode,
|
||||
LGraphCanvas.onMenuNodeCollapse
|
||||
])
|
||||
|
||||
/**
|
||||
* Core menu items - items that should appear in the main menu, not under Extensions
|
||||
* Includes both LiteGraph base menu items and ComfyUI built-in functionality
|
||||
@@ -46,11 +57,9 @@ const CORE_MENU_ITEMS = new Set([
|
||||
'Frame selection',
|
||||
'Frame Nodes',
|
||||
'Minimize Node',
|
||||
'Expand',
|
||||
'Collapse',
|
||||
'Expand Node',
|
||||
// Info and adjustments
|
||||
'Node Info',
|
||||
'Resize',
|
||||
'Title',
|
||||
'Properties Panel',
|
||||
'Adjust Size',
|
||||
@@ -231,9 +240,7 @@ const MENU_ORDER: string[] = [
|
||||
'Frame selection',
|
||||
'Frame Nodes',
|
||||
'Minimize Node',
|
||||
'Expand',
|
||||
'Collapse',
|
||||
'Resize',
|
||||
'Expand Node',
|
||||
'Clone',
|
||||
// Section 4: Node properties
|
||||
'Node Info',
|
||||
@@ -301,14 +308,14 @@ export function buildStructuredMenu(options: MenuOption[]): MenuOption[] {
|
||||
// Section boundaries based on MENU_ORDER indices
|
||||
// Section 1: 0-2 (Rename, Copy, Duplicate)
|
||||
// Section 2: 3-8 (Run Branch, Pin, Unpin, Bypass, Remove Bypass, Mute)
|
||||
// Section 3: 9-15 (Convert to Subgraph, Frame selection, Minimize Node, Expand, Collapse, Resize, Clone)
|
||||
// Section 4: 16-17 (Node Info, Color)
|
||||
// Section 5: 18+ (Image operations and fallback items)
|
||||
// Section 3: 9-14 (Convert to Subgraph, Frame selection, Frame Nodes, Minimize Node, Expand Node, Clone)
|
||||
// Section 4: 15-16 (Node Info, Color)
|
||||
// Section 5: 17+ (Image operations and fallback items)
|
||||
const getSectionNumber = (index: number): number => {
|
||||
if (index <= 2) return 1
|
||||
if (index <= 8) return 2
|
||||
if (index <= 15) return 3
|
||||
if (index <= 17) return 4
|
||||
if (index <= 14) return 3
|
||||
if (index <= 16) return 4
|
||||
return 5
|
||||
}
|
||||
|
||||
@@ -389,6 +396,13 @@ export function convertContextMenuToOptions(
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip built-in LiteGraph items that the Vue menu replaces.
|
||||
// Matched by callback identity, not label, to avoid suppressing
|
||||
// extension-provided items that happen to share a label.
|
||||
if (item.callback && SUPPRESSED_LITEGRAPH_CALLBACKS.has(item.callback)) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip if a similar item already exists in results
|
||||
if (isDuplicateItem(item.content, result)) {
|
||||
continue
|
||||
|
||||
@@ -33,7 +33,6 @@ describe('Shift+Click Drawing Logic', () => {
|
||||
// Segment 2: B -> C
|
||||
const result2 = resampleSegment([pB, pC], spacing, remainder)
|
||||
outputPoints.push(...result2.points)
|
||||
remainder = result2.remainder
|
||||
|
||||
// Verify final state
|
||||
// Start offset 2. Points at 2, 6, 10 (relative to B).
|
||||
|
||||
@@ -260,7 +260,9 @@ export function useMaskEditorLoader() {
|
||||
const filename = urlObj.searchParams.get('filename')
|
||||
|
||||
if (!filename) {
|
||||
throw new Error('Image URL missing filename parameter')
|
||||
throw new Error('Image URL missing filename parameter', {
|
||||
cause: error
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -269,7 +271,7 @@ export function useMaskEditorLoader() {
|
||||
type: urlObj.searchParams.get('type') || undefined
|
||||
}
|
||||
} catch (e) {
|
||||
throw new Error(`Invalid image URL: ${url}`)
|
||||
throw new Error(`Invalid image URL: ${url}`, { cause: e })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ type MockStore = {
|
||||
isPanning: boolean
|
||||
}
|
||||
|
||||
const mockStore: MockStore = reactive({
|
||||
const mockStore = reactive<MockStore>({
|
||||
currentTool: Tools.MaskPen,
|
||||
activeLayer: 'mask',
|
||||
pointerZone: null,
|
||||
@@ -24,7 +24,7 @@ const mockStore: MockStore = reactive({
|
||||
brushPreviewGradientVisible: false,
|
||||
isAdjustingBrush: false,
|
||||
isPanning: false
|
||||
}) as MockStore
|
||||
})
|
||||
|
||||
const mockBrushDrawing = {
|
||||
startDrawing: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -82,7 +82,7 @@ const mockKeyboard = {
|
||||
isKeyDown: vi.fn().mockReturnValue(false),
|
||||
addListeners: vi.fn(),
|
||||
removeListeners: vi.fn()
|
||||
}
|
||||
} satisfies Parameters<typeof useToolManager>[0]
|
||||
|
||||
const mockPanZoom = {
|
||||
initializeCanvasPanZoom: vi.fn(),
|
||||
@@ -96,36 +96,43 @@ const mockPanZoom = {
|
||||
invalidatePanZoom: vi.fn(),
|
||||
addPenPointerId: vi.fn(),
|
||||
removePenPointerId: vi.fn()
|
||||
} satisfies Parameters<typeof useToolManager>[1]
|
||||
|
||||
type TestPointerEventInit = PointerEventInit & {
|
||||
offsetX?: number
|
||||
offsetY?: number
|
||||
type?: string
|
||||
}
|
||||
|
||||
const pointerEvent = (
|
||||
init: Partial<PointerEvent> & { pointerType?: string }
|
||||
): PointerEvent => {
|
||||
return {
|
||||
preventDefault: vi.fn(),
|
||||
const pointerEvent = ({
|
||||
offsetX = 0,
|
||||
offsetY = 0,
|
||||
type = 'pointerdown',
|
||||
...init
|
||||
}: TestPointerEventInit = {}): PointerEvent => {
|
||||
const event = new PointerEvent(type, {
|
||||
pointerId: 1,
|
||||
pointerType: 'mouse',
|
||||
button: 0,
|
||||
buttons: 0,
|
||||
clientX: 0,
|
||||
clientY: 0,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
altKey: false,
|
||||
...init
|
||||
} as unknown as PointerEvent
|
||||
})
|
||||
vi.spyOn(event, 'preventDefault')
|
||||
Object.defineProperties(event, {
|
||||
offsetX: { value: offsetX },
|
||||
offsetY: { value: offsetY }
|
||||
})
|
||||
return event
|
||||
}
|
||||
|
||||
let scope: EffectScope | null = null
|
||||
|
||||
const setup = (): ReturnType<typeof useToolManager> => {
|
||||
scope = effectScope()
|
||||
return scope.run(() =>
|
||||
useToolManager(
|
||||
mockKeyboard as unknown as Parameters<typeof useToolManager>[0],
|
||||
mockPanZoom as unknown as Parameters<typeof useToolManager>[1]
|
||||
)
|
||||
)!
|
||||
return scope.run(() => useToolManager(mockKeyboard, mockPanZoom))!
|
||||
}
|
||||
|
||||
describe('useToolManager', () => {
|
||||
@@ -307,7 +314,9 @@ describe('useToolManager', () => {
|
||||
|
||||
it('should start panning on middle mouse button (buttons===4)', async () => {
|
||||
const tm = setup()
|
||||
await tm.handlePointerDown(pointerEvent({ buttons: 4 }))
|
||||
await tm.handlePointerDown(
|
||||
pointerEvent({ type: 'pointerdown', buttons: 4 })
|
||||
)
|
||||
|
||||
expect(mockPanZoom.handlePanStart).toHaveBeenCalled()
|
||||
expect(mockStore.brushVisible).toBe(false)
|
||||
@@ -434,7 +443,19 @@ describe('useToolManager', () => {
|
||||
|
||||
it('should pan on middle button drag', async () => {
|
||||
const tm = setup()
|
||||
await tm.handlePointerMove(pointerEvent({ buttons: 4 }))
|
||||
await tm.handlePointerMove(
|
||||
pointerEvent({ type: 'pointermove', buttons: 4 })
|
||||
)
|
||||
|
||||
expect(mockPanZoom.handlePanMove).toHaveBeenCalled()
|
||||
expect(mockBrushDrawing.handleDrawing).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should keep panning when middle button is held with another button', async () => {
|
||||
const tm = setup()
|
||||
await tm.handlePointerMove(
|
||||
pointerEvent({ type: 'pointermove', buttons: 5 })
|
||||
)
|
||||
|
||||
expect(mockPanZoom.handlePanMove).toHaveBeenCalled()
|
||||
expect(mockBrushDrawing.handleDrawing).not.toHaveBeenCalled()
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import { isMiddleButtonHeld, isMiddlePointerInput } from '@/base/pointerUtils'
|
||||
import type {
|
||||
Point,
|
||||
ImageLayer,
|
||||
ToolInternalSettings
|
||||
} from '@/extensions/core/maskeditor/types'
|
||||
import { Tools } from '@/extensions/core/maskeditor/types'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
import { useBrushDrawing } from './useBrushDrawing'
|
||||
import { useCanvasTools } from './useCanvasTools'
|
||||
import { useCoordinateTransform } from './useCoordinateTransform'
|
||||
import type { useKeyboard } from './useKeyboard'
|
||||
import type { usePanAndZoom } from './usePanAndZoom'
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
export function useToolManager(
|
||||
keyboard: ReturnType<typeof useKeyboard>,
|
||||
@@ -118,9 +120,7 @@ export function useToolManager(
|
||||
panZoom.addPenPointerId(event.pointerId)
|
||||
}
|
||||
|
||||
const isSpacePressed = keyboard.isKeyDown(' ')
|
||||
|
||||
if (event.buttons === 4 || (event.buttons === 1 && isSpacePressed)) {
|
||||
if (shouldStartPan(event)) {
|
||||
panZoom.handlePanStart(event)
|
||||
|
||||
store.brushVisible = false
|
||||
@@ -177,9 +177,7 @@ export function useToolManager(
|
||||
const newCursorPoint = { x: event.clientX, y: event.clientY }
|
||||
panZoom.updateCursorPosition(newCursorPoint)
|
||||
|
||||
const isSpacePressed = keyboard.isKeyDown(' ')
|
||||
|
||||
if (event.buttons === 4 || (event.buttons === 1 && isSpacePressed)) {
|
||||
if (shouldContinuePan(event)) {
|
||||
await panZoom.handlePanMove(event)
|
||||
return
|
||||
}
|
||||
@@ -223,6 +221,18 @@ export function useToolManager(
|
||||
mouseDownPoint.value = null
|
||||
}
|
||||
|
||||
function isLeftButtonSpacePan(event: PointerEvent) {
|
||||
return event.buttons === 1 && keyboard.isKeyDown(' ')
|
||||
}
|
||||
|
||||
function shouldStartPan(event: PointerEvent) {
|
||||
return isMiddlePointerInput(event) || isLeftButtonSpacePan(event)
|
||||
}
|
||||
|
||||
function shouldContinuePan(event: PointerEvent) {
|
||||
return isMiddleButtonHeld(event) || isLeftButtonSpacePan(event)
|
||||
}
|
||||
|
||||
return {
|
||||
switchTool,
|
||||
setActiveLayer,
|
||||
|
||||
@@ -6,7 +6,9 @@ import { createMockMediaNode } from '@/renderer/extensions/vueNodes/widgets/comp
|
||||
const { canvasInteractionsMock } = vi.hoisted(() => ({
|
||||
canvasInteractionsMock: {
|
||||
handleWheel: vi.fn(),
|
||||
handlePointer: vi.fn(),
|
||||
handlePointerDown: vi.fn(),
|
||||
handlePointerMove: vi.fn(),
|
||||
handlePointerUp: vi.fn(),
|
||||
forwardEventToCanvas: vi.fn()
|
||||
}
|
||||
}))
|
||||
@@ -41,16 +43,18 @@ describe('useNodeAnimatedImage', () => {
|
||||
element.dispatchEvent(new PointerEvent('pointerdown', { button: 0 }))
|
||||
|
||||
expect(canvasInteractionsMock.handleWheel).toHaveBeenCalledTimes(1)
|
||||
expect(canvasInteractionsMock.handlePointer).toHaveBeenCalledTimes(3)
|
||||
expect(canvasInteractionsMock.handlePointerMove).toHaveBeenCalledTimes(1)
|
||||
expect(canvasInteractionsMock.handlePointerUp).toHaveBeenCalledTimes(1)
|
||||
expect(canvasInteractionsMock.handlePointerDown).toHaveBeenCalledTimes(1)
|
||||
expect(canvasInteractionsMock.forwardEventToCanvas).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes right-click pointerdown through forwardEventToCanvas, not handlePointer', () => {
|
||||
it('routes right-click pointerdown through forwardEventToCanvas, not handlePointerDown', () => {
|
||||
const { element } = setup()
|
||||
element.dispatchEvent(new PointerEvent('pointerdown', { button: 2 }))
|
||||
|
||||
expect(canvasInteractionsMock.forwardEventToCanvas).toHaveBeenCalledTimes(1)
|
||||
expect(canvasInteractionsMock.handlePointer).not.toHaveBeenCalled()
|
||||
expect(canvasInteractionsMock.handlePointerDown).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('detaches every listener when the preview is removed', () => {
|
||||
@@ -64,7 +68,9 @@ describe('useNodeAnimatedImage', () => {
|
||||
element.dispatchEvent(new PointerEvent('pointerdown', { button: 2 }))
|
||||
|
||||
expect(canvasInteractionsMock.handleWheel).not.toHaveBeenCalled()
|
||||
expect(canvasInteractionsMock.handlePointer).not.toHaveBeenCalled()
|
||||
expect(canvasInteractionsMock.handlePointerMove).not.toHaveBeenCalled()
|
||||
expect(canvasInteractionsMock.handlePointerUp).not.toHaveBeenCalled()
|
||||
expect(canvasInteractionsMock.handlePointerDown).not.toHaveBeenCalled()
|
||||
expect(canvasInteractionsMock.forwardEventToCanvas).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||