Compare commits
16 Commits
codex/back
...
drjkl/worl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbad9e58b8 | ||
|
|
945f959259 | ||
|
|
d4268e50a8 | ||
|
|
207d3f5aad | ||
|
|
7c4b44db78 | ||
|
|
c07d893fc0 | ||
|
|
8d94e89e13 | ||
|
|
faed4f9599 | ||
|
|
243d584d33 | ||
|
|
91f22f4986 | ||
|
|
419ffcc60a | ||
|
|
5aef3900b3 | ||
|
|
6101a0d310 | ||
|
|
ec1f3333db | ||
|
|
e40982cd22 | ||
|
|
9a39207b52 |
6
.github/workflows/ci-perf-report.yaml
vendored
@@ -54,14 +54,10 @@ jobs:
|
||||
- name: Start ComfyUI server
|
||||
uses: ./.github/actions/start-comfyui-server
|
||||
|
||||
# PRs run each test once to keep wall time bounded; main runs 3× so the
|
||||
# baseline saved to perf-data has enough samples to median over noise.
|
||||
- name: Run performance tests
|
||||
id: perf
|
||||
continue-on-error: true
|
||||
env:
|
||||
PERF_REPEAT: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && '3' || '2' }}
|
||||
run: pnpm exec playwright test --project=performance --workers=1 --repeat-each=$PERF_REPEAT
|
||||
run: pnpm exec playwright test --project=performance --workers=1 --repeat-each=3
|
||||
|
||||
- name: Upload perf metrics
|
||||
if: always()
|
||||
|
||||
@@ -9,7 +9,6 @@ import en from '@frontend-locales/en/main.json' with { type: 'json' }
|
||||
import enNodes from '@frontend-locales/en/nodeDefs.json' with { type: 'json' }
|
||||
|
||||
import enSettings from '@frontend-locales/en/settings.json' with { type: 'json' }
|
||||
import { getDefaultLocale } from '@frontend-locales/localeConfig'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
function buildLocale<
|
||||
@@ -168,7 +167,7 @@ const messages: Record<string, LocaleMessages> = {
|
||||
export const i18n = createI18n({
|
||||
// Must set `false`, as Vue I18n Legacy API is for Vue 2
|
||||
legacy: false,
|
||||
locale: getDefaultLocale(),
|
||||
locale: navigator.language.split('-')[0] || 'en',
|
||||
fallbackLocale: 'en',
|
||||
messages,
|
||||
// Ignore warnings for locale options as each option is in its own language.
|
||||
|
||||
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 96 KiB |
@@ -27,7 +27,6 @@
|
||||
"gsap": "catalog:",
|
||||
"lenis": "catalog:",
|
||||
"posthog-js": "catalog:",
|
||||
"three": "catalog:",
|
||||
"vue": "catalog:",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
|
||||
@@ -1,31 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { externalLinks } from '../../config/routes'
|
||||
import { useHeroLogo } from '../../composables/useHeroLogo'
|
||||
import { t } from '../../i18n/translations'
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const logoContainer = ref<HTMLElement>()
|
||||
const { loaded: logoLoaded } = useHeroLogo(logoContainer)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="relative flex min-h-auto flex-col lg:flex-row lg:items-center"
|
||||
>
|
||||
<div
|
||||
ref="logoContainer"
|
||||
class="relative flex aspect-square w-full flex-1 items-center justify-center"
|
||||
>
|
||||
<img
|
||||
v-show="!logoLoaded"
|
||||
src="https://media.comfy.org/website/homepage/hero-logo-seq/Logo00.webp"
|
||||
alt="Comfy logo"
|
||||
class="w-3/5"
|
||||
<div class="relative flex-1">
|
||||
<video
|
||||
src="https://media.comfy.org/website/homepage/hero-logo-seq.webm"
|
||||
autoplay
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -77,10 +77,7 @@ const plans: PricingPlan[] = [
|
||||
ctaKey: 'pricing.plan.creator.cta',
|
||||
ctaHref: subscribeUrl('creator'),
|
||||
featureIntroKey: 'pricing.plan.creator.featureIntro',
|
||||
features: [
|
||||
{ text: 'pricing.plan.creator.feature1' },
|
||||
{ text: 'pricing.plan.creator.feature2' }
|
||||
],
|
||||
features: [{ text: 'pricing.plan.creator.feature1' }],
|
||||
isPopular: true
|
||||
},
|
||||
{
|
||||
@@ -93,10 +90,7 @@ const plans: PricingPlan[] = [
|
||||
ctaKey: 'pricing.plan.pro.cta',
|
||||
ctaHref: subscribeUrl('pro'),
|
||||
featureIntroKey: 'pricing.plan.pro.featureIntro',
|
||||
features: [
|
||||
{ text: 'pricing.plan.pro.feature1' },
|
||||
{ text: 'pricing.plan.pro.feature2' }
|
||||
]
|
||||
features: [{ text: 'pricing.plan.pro.feature1' }]
|
||||
},
|
||||
{
|
||||
id: 'enterprise',
|
||||
|
||||
@@ -1,328 +0,0 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import * as THREE from 'three'
|
||||
import { SVGLoader } from 'three/addons/loaders/SVGLoader.js'
|
||||
|
||||
import { prefersReducedMotion } from './useReducedMotion'
|
||||
|
||||
const IMAGE_COUNT = 16
|
||||
const BASE_URL = 'https://media.comfy.org/website/homepage/hero-logo-seq'
|
||||
|
||||
const SVG_MARKUP = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 375 404"><path fill="#000000" d="M296.597 302.576C297.299 300.205 297.682 297.705 297.682 295.078C297.682 280.529 285.938 268.736 271.45 268.736H153.883C147.564 268.8 142.395 263.673 142.395 257.328C142.395 256.174 142.586 255.084 142.841 254.059L174.499 143.309C175.839 138.438 180.307 134.849 185.541 134.849L303.554 134.72C328.446 134.72 349.444 117.864 355.763 94.8555L373.506 33.1353C374.081 30.9562 374.4 28.5848 374.4 26.2134C374.4 11.7288 362.72 0 348.295 0H205.518C180.754 0 159.819 16.7279 153.373 39.4804L141.373 81.5886C139.969 86.3954 135.565 89.9205 130.332 89.9205H96.0573C71.4845 89.9205 50.7412 106.328 44.1034 128.824L0.957382 280.144C0.319127 282.387 0 284.823 0 287.258C0 301.807 11.7439 313.6 26.2323 313.6H59.9321C66.2508 313.6 71.4207 318.727 71.4207 325.137C71.4207 326.226 71.293 327.316 70.9739 328.341L59.0385 370.065C58.4641 372.308 58.0811 374.615 58.0811 376.987C58.0811 391.471 69.7612 403.2 84.1857 403.2L227.027 403.072C251.855 403.072 272.79 386.28 279.172 363.399L296.533 302.64L296.597 302.576Z"/></svg>`
|
||||
|
||||
interface HeroLogoConfig {
|
||||
speed: number
|
||||
tiltX: number
|
||||
tiltZ: number
|
||||
zoom: number
|
||||
fov: number
|
||||
logoColor: string
|
||||
extrudeDepth: number
|
||||
cursorTiltStrength: number
|
||||
bgScale: number
|
||||
slideDuration: number
|
||||
}
|
||||
|
||||
const DEFAULTS: HeroLogoConfig = {
|
||||
speed: 1,
|
||||
tiltX: -0.1,
|
||||
tiltZ: -0.1,
|
||||
zoom: 7,
|
||||
fov: 50,
|
||||
logoColor: '#F2FF59',
|
||||
extrudeDepth: 200,
|
||||
cursorTiltStrength: 0.5,
|
||||
bgScale: 0.8,
|
||||
slideDuration: 0.4
|
||||
}
|
||||
|
||||
function buildImageUrls(): string[] {
|
||||
return Array.from({ length: IMAGE_COUNT }, (_, i) => {
|
||||
const index = String(i).padStart(5, '0')
|
||||
return `${BASE_URL}/image_sequence_${index}.webp`
|
||||
})
|
||||
}
|
||||
|
||||
function parseShapes(): THREE.Shape[] {
|
||||
const loader = new SVGLoader()
|
||||
const svgData = loader.parse(SVG_MARKUP)
|
||||
const shapes: THREE.Shape[] = []
|
||||
svgData.paths.forEach((path) => {
|
||||
shapes.push(...SVGLoader.createShapes(path))
|
||||
})
|
||||
return shapes
|
||||
}
|
||||
|
||||
function loadTextures(urls: string[]): Promise<THREE.Texture[]> {
|
||||
return Promise.all(
|
||||
urls.map(
|
||||
(url) =>
|
||||
new Promise<THREE.Texture | null>((resolve) => {
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
img.onload = () => {
|
||||
const tex = new THREE.Texture(img)
|
||||
tex.needsUpdate = true
|
||||
tex.colorSpace = THREE.SRGBColorSpace
|
||||
resolve(tex)
|
||||
}
|
||||
img.onerror = () => resolve(null)
|
||||
img.src = url
|
||||
})
|
||||
)
|
||||
).then((results) => results.filter((t): t is THREE.Texture => t !== null))
|
||||
}
|
||||
|
||||
export function useHeroLogo(
|
||||
containerRef: Ref<HTMLElement | undefined>,
|
||||
config: Partial<HeroLogoConfig> = {}
|
||||
) {
|
||||
const cfg = { ...DEFAULTS, ...config }
|
||||
const loaded = ref(false)
|
||||
let cleanup: (() => void) | undefined
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const container = containerRef.value
|
||||
if (!container || prefersReducedMotion()) return
|
||||
|
||||
const { width, height } = container.getBoundingClientRect()
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({
|
||||
antialias: true,
|
||||
stencil: true,
|
||||
alpha: true
|
||||
})
|
||||
renderer.setSize(width, height)
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace
|
||||
renderer.domElement.style.position = 'absolute'
|
||||
renderer.domElement.style.inset = '0'
|
||||
renderer.domElement.style.width = '100%'
|
||||
renderer.domElement.style.height = '100%'
|
||||
renderer.domElement.style.opacity = '0'
|
||||
renderer.domElement.setAttribute('aria-hidden', 'true')
|
||||
container.appendChild(renderer.domElement)
|
||||
|
||||
let disposed = false
|
||||
const teardowns: Array<() => void> = []
|
||||
cleanup = () => {
|
||||
disposed = true
|
||||
teardowns.forEach((fn) => fn())
|
||||
}
|
||||
teardowns.push(() => {
|
||||
renderer.dispose()
|
||||
renderer.domElement.remove()
|
||||
})
|
||||
|
||||
const scene = new THREE.Scene()
|
||||
const camera = new THREE.PerspectiveCamera(
|
||||
cfg.fov,
|
||||
width / height,
|
||||
0.1,
|
||||
1000
|
||||
)
|
||||
camera.position.z = cfg.zoom
|
||||
|
||||
// SVG shape
|
||||
const shapes = parseShapes()
|
||||
const tempGeo = new THREE.ShapeGeometry(shapes)
|
||||
tempGeo.computeBoundingBox()
|
||||
const bb = tempGeo.boundingBox!
|
||||
const cx = (bb.max.x + bb.min.x) / 2
|
||||
const cy = (bb.max.y + bb.min.y) / 2
|
||||
const scaleFactor = 3 / (bb.max.y - bb.min.y)
|
||||
tempGeo.dispose()
|
||||
|
||||
// Image sequence textures — load first frame eagerly, rest lazily
|
||||
const urls = buildImageUrls()
|
||||
const textures = await loadTextures(urls.slice(0, 1))
|
||||
if (disposed) return
|
||||
|
||||
renderer.domElement.style.opacity = '1'
|
||||
loaded.value = true
|
||||
|
||||
loadTextures(urls.slice(1)).then((rest) => {
|
||||
if (!disposed) textures.push(...rest)
|
||||
})
|
||||
|
||||
// Background plane (stencil read)
|
||||
const bgPlaneGeo = new THREE.PlaneGeometry(14, 14)
|
||||
const bgPlaneMat = new THREE.MeshBasicMaterial({
|
||||
transparent: true,
|
||||
opacity: 1,
|
||||
map: textures[0] ?? null,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
stencilWrite: true,
|
||||
stencilFunc: THREE.EqualStencilFunc,
|
||||
stencilRef: 1,
|
||||
stencilFail: THREE.KeepStencilOp,
|
||||
stencilZFail: THREE.KeepStencilOp,
|
||||
stencilZPass: THREE.KeepStencilOp
|
||||
})
|
||||
const bgPlane = new THREE.Mesh(bgPlaneGeo, bgPlaneMat)
|
||||
bgPlane.renderOrder = 1
|
||||
bgPlane.scale.set(cfg.bgScale, cfg.bgScale, 1)
|
||||
scene.add(bgPlane)
|
||||
|
||||
// Logo group
|
||||
const group = new THREE.Group()
|
||||
scene.add(group)
|
||||
|
||||
const s = scaleFactor
|
||||
const depth = cfg.extrudeDepth
|
||||
|
||||
// Front face
|
||||
const shapeGeo = new THREE.ShapeGeometry(shapes)
|
||||
shapeGeo.translate(-cx, -cy, 0)
|
||||
shapeGeo.scale(s, -s, s)
|
||||
const shapeMat = new THREE.MeshBasicMaterial({
|
||||
color: cfg.logoColor,
|
||||
side: THREE.DoubleSide,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
transparent: true
|
||||
})
|
||||
const logoMesh = new THREE.Mesh(shapeGeo, shapeMat)
|
||||
logoMesh.renderOrder = 2
|
||||
group.add(logoMesh)
|
||||
|
||||
// Extrusion stencil mask
|
||||
const extrudeGeo = new THREE.ExtrudeGeometry(shapes, {
|
||||
depth,
|
||||
bevelEnabled: false
|
||||
})
|
||||
extrudeGeo.translate(-cx, -cy, -depth)
|
||||
extrudeGeo.scale(s, -s, s)
|
||||
const extrudeMat = new THREE.MeshBasicMaterial({
|
||||
colorWrite: false,
|
||||
depthWrite: true,
|
||||
depthTest: true,
|
||||
stencilWrite: true,
|
||||
stencilRef: 1,
|
||||
stencilFunc: THREE.AlwaysStencilFunc,
|
||||
stencilZPass: THREE.ReplaceStencilOp,
|
||||
stencilFail: THREE.KeepStencilOp,
|
||||
stencilZFail: THREE.KeepStencilOp,
|
||||
side: THREE.DoubleSide
|
||||
})
|
||||
const extrudeMesh = new THREE.Mesh(extrudeGeo, extrudeMat)
|
||||
extrudeMesh.renderOrder = 0
|
||||
group.add(extrudeMesh)
|
||||
|
||||
// Interaction
|
||||
let isDragging = false
|
||||
let previousX = 0
|
||||
let dragVelocity = 0
|
||||
let currentTiltX = 0
|
||||
let currentTiltY = 0
|
||||
let pointerX = 0
|
||||
let pointerY = 0
|
||||
let rotationT = 0
|
||||
let currentSlide = 0
|
||||
let slideTimer = 0
|
||||
let animationId = 0
|
||||
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
pointerX = (e.clientX / window.innerWidth) * 2 - 1
|
||||
pointerY = (e.clientY / window.innerHeight) * 2 - 1
|
||||
}
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
isDragging = true
|
||||
dragVelocity = 0
|
||||
previousX = e.clientX
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (!isDragging) return
|
||||
dragVelocity = (e.clientX - previousX) * 0.005
|
||||
rotationT += dragVelocity
|
||||
previousX = e.clientX
|
||||
}
|
||||
|
||||
function onPointerUp() {
|
||||
isDragging = false
|
||||
}
|
||||
|
||||
function onResize() {
|
||||
const rect = container!.getBoundingClientRect()
|
||||
camera.aspect = rect.width / rect.height
|
||||
camera.updateProjectionMatrix()
|
||||
renderer.setSize(rect.width, rect.height)
|
||||
}
|
||||
|
||||
window.addEventListener('mousemove', onMouseMove)
|
||||
renderer.domElement.addEventListener('pointerdown', onPointerDown)
|
||||
window.addEventListener('pointermove', onPointerMove)
|
||||
window.addEventListener('pointerup', onPointerUp)
|
||||
window.addEventListener('resize', onResize)
|
||||
|
||||
const clock = new THREE.Clock()
|
||||
|
||||
function animate() {
|
||||
if (disposed) return
|
||||
animationId = requestAnimationFrame(animate)
|
||||
const dt = clock.getDelta()
|
||||
|
||||
if (!isDragging && Math.abs(dragVelocity) > 0.0001) {
|
||||
dragVelocity *= 0.95
|
||||
rotationT += dragVelocity
|
||||
} else if (!isDragging) {
|
||||
dragVelocity = 0
|
||||
}
|
||||
|
||||
rotationT += cfg.speed * dt
|
||||
|
||||
currentTiltX += (pointerY - currentTiltX) * 0.08
|
||||
currentTiltY += (pointerX - currentTiltY) * 0.08
|
||||
|
||||
group.rotation.y = rotationT % (Math.PI * 2)
|
||||
group.rotation.x = cfg.tiltX - currentTiltX * cfg.cursorTiltStrength
|
||||
group.rotation.z = cfg.tiltZ
|
||||
|
||||
if (textures.length > 1) {
|
||||
slideTimer += dt
|
||||
if (slideTimer >= cfg.slideDuration) {
|
||||
slideTimer = 0
|
||||
currentSlide = (currentSlide + 1) % textures.length
|
||||
bgPlaneMat.map = textures[currentSlide]
|
||||
bgPlaneMat.needsUpdate = true
|
||||
}
|
||||
}
|
||||
|
||||
renderer.render(scene, camera)
|
||||
}
|
||||
|
||||
animate()
|
||||
|
||||
teardowns.push(
|
||||
() => cancelAnimationFrame(animationId),
|
||||
() => window.removeEventListener('mousemove', onMouseMove),
|
||||
() =>
|
||||
renderer.domElement.removeEventListener('pointerdown', onPointerDown),
|
||||
() => window.removeEventListener('pointermove', onPointerMove),
|
||||
() => window.removeEventListener('pointerup', onPointerUp),
|
||||
() => window.removeEventListener('resize', onResize),
|
||||
() => bgPlaneGeo.dispose(),
|
||||
() => bgPlaneMat.dispose(),
|
||||
() => shapeGeo.dispose(),
|
||||
() => shapeMat.dispose(),
|
||||
() => extrudeGeo.dispose(),
|
||||
() => extrudeMat.dispose(),
|
||||
() => textures.forEach((tex) => tex.dispose())
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('[useHeroLogo] initialization failed:', err)
|
||||
cleanup?.()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
cleanup?.()
|
||||
})
|
||||
|
||||
return { loaded }
|
||||
}
|
||||
@@ -1119,10 +1119,6 @@ const translations = {
|
||||
en: 'Import your own LoRAs',
|
||||
'zh-CN': '导入你自己的 LoRA'
|
||||
},
|
||||
'pricing.plan.creator.feature2': {
|
||||
en: '3 concurrent API jobs',
|
||||
'zh-CN': '3 个并发 API 任务'
|
||||
},
|
||||
|
||||
'pricing.plan.pro.label': { en: 'PRO', 'zh-CN': '专业版' },
|
||||
'pricing.plan.pro.summary': {
|
||||
@@ -1147,10 +1143,6 @@ const translations = {
|
||||
en: 'Longer workflow runtime (up to 1 hour)',
|
||||
'zh-CN': '更长工作流运行时长(最长 1 小时)'
|
||||
},
|
||||
'pricing.plan.pro.feature2': {
|
||||
en: '5 concurrent API jobs',
|
||||
'zh-CN': '5 个并发 API 任务'
|
||||
},
|
||||
|
||||
'pricing.enterprise.label': { en: 'ENTERPRISE', 'zh-CN': '企业版' },
|
||||
'pricing.enterprise.heading': {
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
{
|
||||
"id": "2f54e2f0-6db4-4bdf-84a8-9c3ea3ec0123",
|
||||
"revision": 0,
|
||||
"last_node_id": 13,
|
||||
"last_link_id": 9,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 11,
|
||||
"type": "422723e8-4bf6-438c-823f-881ca81acead",
|
||||
"pos": [120, 180],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "clip", "type": "CLIP", "link": null },
|
||||
{ "name": "model", "type": "MODEL", "link": null },
|
||||
{ "name": "positive", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "negative", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "latent_image", "type": "LATENT", "link": null }
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["Alpha\n"]
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"type": "422723e8-4bf6-438c-823f-881ca81acead",
|
||||
"pos": [420, 180],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "clip", "type": "CLIP", "link": null },
|
||||
{ "name": "model", "type": "MODEL", "link": null },
|
||||
{ "name": "positive", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "negative", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "latent_image", "type": "LATENT", "link": null }
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["Beta\n"]
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"type": "422723e8-4bf6-438c-823f-881ca81acead",
|
||||
"pos": [720, 180],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "clip", "type": "CLIP", "link": null },
|
||||
{ "name": "model", "type": "MODEL", "link": null },
|
||||
{ "name": "positive", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "negative", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "latent_image", "type": "LATENT", "link": null }
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["Gamma\n"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "422723e8-4bf6-438c-823f-881ca81acead",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 11,
|
||||
"lastLinkId": 15,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "New Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [481.59912109375, 379.13336181640625, 120, 160]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [1121.59912109375, 379.13336181640625, 120, 40]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "0f07c10e-5705-4764-9b24-b69606c6dbcc",
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"linkIds": [10],
|
||||
"pos": { "0": 581.59912109375, "1": 399.13336181640625 }
|
||||
},
|
||||
{
|
||||
"id": "214a5060-24dd-4299-ab78-8027dc5b9c59",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"linkIds": [11],
|
||||
"pos": { "0": 581.59912109375, "1": 419.13336181640625 }
|
||||
},
|
||||
{
|
||||
"id": "8ab94c5d-e7df-433c-9177-482a32340552",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"linkIds": [12],
|
||||
"pos": { "0": 581.59912109375, "1": 439.13336181640625 }
|
||||
},
|
||||
{
|
||||
"id": "8a4cd719-8c67-473b-9b44-ac0582d02641",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [13],
|
||||
"pos": { "0": 581.59912109375, "1": 459.13336181640625 }
|
||||
},
|
||||
{
|
||||
"id": "a78d6b3a-ad40-4300-b0a5-2cdbdb8dc135",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [14],
|
||||
"pos": { "0": 581.59912109375, "1": 479.13336181640625 }
|
||||
},
|
||||
{
|
||||
"id": "4c7abe0c-902d-49ef-a5b0-cbf02b50b693",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"linkIds": [15],
|
||||
"pos": { "0": 581.59912109375, "1": 499.13336181640625 }
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 10,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [661.59912109375, 314.13336181640625],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "clip",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 11
|
||||
},
|
||||
{
|
||||
"localized_name": "text",
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"widget": { "name": "text" },
|
||||
"link": 10
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "CONDITIONING",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [""]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"type": "KSampler",
|
||||
"pos": [674.1234741210938, 570.5839233398438],
|
||||
"size": [270, 262],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "model",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 12
|
||||
},
|
||||
{
|
||||
"localized_name": "positive",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 13
|
||||
},
|
||||
{
|
||||
"localized_name": "negative",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 14
|
||||
},
|
||||
{
|
||||
"localized_name": "latent_image",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 15
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 10,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 10,
|
||||
"target_slot": 1,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 1,
|
||||
"target_id": 10,
|
||||
"target_slot": 0,
|
||||
"type": "CLIP"
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 2,
|
||||
"target_id": 11,
|
||||
"target_slot": 0,
|
||||
"type": "MODEL"
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 3,
|
||||
"target_id": 11,
|
||||
"target_slot": 1,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 4,
|
||||
"target_id": 11,
|
||||
"target_slot": 2,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 5,
|
||||
"target_id": 11,
|
||||
"target_slot": 3,
|
||||
"type": "LATENT"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
},
|
||||
"frontendVersion": "1.24.1"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
{
|
||||
"id": "5c4a1450-26b8-4b34-b5ea-e3465273441e",
|
||||
"revision": 0,
|
||||
"last_node_id": 4,
|
||||
"last_link_id": 2,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "16aadaf6-aa66-4041-843e-589a6572a3ac",
|
||||
"pos": [602, 409],
|
||||
"size": [225, 144],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"proxyWidgets": [
|
||||
["1", "value"],
|
||||
["4", "value"]
|
||||
]
|
||||
},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "16aadaf6-aa66-4041-843e-589a6572a3ac",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 4,
|
||||
"lastLinkId": 2,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "New Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [349, 383, 128, 68]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [867, 383, 128, 48]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "50fd1af4-4f20-434f-9828-6971210be4e9",
|
||||
"name": "value",
|
||||
"type": "STRING",
|
||||
"linkIds": [1],
|
||||
"pos": [453, 407]
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "PrimitiveString",
|
||||
"pos": [537, 368],
|
||||
"size": [270, 108],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "value",
|
||||
"name": "value",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "value"
|
||||
},
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "STRING",
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "PrimitiveString"
|
||||
},
|
||||
"widgets_values": [""]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "PrimitiveInt",
|
||||
"pos": [534.9899497487436, 515.4924623115581],
|
||||
"size": [270, 104],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "value",
|
||||
"name": "value",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "value"
|
||||
},
|
||||
"link": 2
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "INT",
|
||||
"name": "INT",
|
||||
"type": "INT",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "PrimitiveInt"
|
||||
},
|
||||
"widgets_values": [0, "randomize"]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "PrimitiveNode",
|
||||
"pos": [258.4381232333541, 549.1608040200999],
|
||||
"size": [225, 104],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "INT",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "value"
|
||||
},
|
||||
"links": [2]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Run widget replace on values": false
|
||||
},
|
||||
"widgets_values": [0, "randomize"]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 1,
|
||||
"target_slot": 0,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": 4,
|
||||
"origin_slot": 0,
|
||||
"target_id": 3,
|
||||
"target_slot": 0,
|
||||
"type": "INT"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"frontendVersion": "1.44.17"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -352,12 +352,6 @@ export class ComfyPage {
|
||||
await nextFrame(this.page)
|
||||
}
|
||||
|
||||
async idleFrames(count: number) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
await this.nextFrame()
|
||||
}
|
||||
}
|
||||
|
||||
async delay(ms: number) {
|
||||
return sleep(ms)
|
||||
}
|
||||
|
||||
@@ -217,20 +217,13 @@ export class VueNodeHelpers {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Locator for the Enter Subgraph footer button.
|
||||
*/
|
||||
getSubgraphEnterButton(nodeId?: string): Locator {
|
||||
const root = nodeId ? this.getNodeLocator(nodeId) : this.page
|
||||
return root.getByTestId(TestIds.widgets.subgraphEnterButton).first()
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter the subgraph of a node.
|
||||
* @param nodeId - The ID of the node to enter the subgraph of. If not provided, the first matched subgraph will be entered.
|
||||
*/
|
||||
async enterSubgraph(nodeId?: string): Promise<void> {
|
||||
const editButton = this.getSubgraphEnterButton(nodeId)
|
||||
const locator = nodeId ? this.getNodeLocator(nodeId) : this.page
|
||||
const editButton = locator.getByTestId(TestIds.widgets.subgraphEnterButton)
|
||||
|
||||
// The footer tab button extends below the node body (visible area),
|
||||
// but its bounding box center overlaps the node body div.
|
||||
|
||||
@@ -39,32 +39,10 @@ class ComfyQueueButton {
|
||||
await this.dropdownButton.click()
|
||||
return new ComfyQueueButtonOptions(this.actionbar.page)
|
||||
}
|
||||
|
||||
public async openOptions() {
|
||||
const options = new ComfyQueueButtonOptions(this.actionbar.page)
|
||||
if (!(await options.menu.isVisible())) {
|
||||
await this.dropdownButton.click()
|
||||
}
|
||||
return options
|
||||
}
|
||||
}
|
||||
|
||||
class ComfyQueueButtonOptions {
|
||||
public readonly menu: Locator
|
||||
public readonly modeItems: Locator
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.menu = page.getByRole('menu')
|
||||
this.modeItems = this.menu.getByRole('menuitem')
|
||||
}
|
||||
|
||||
public modeItem(name: string) {
|
||||
return this.menu.getByRole('menuitem', { name, exact: true })
|
||||
}
|
||||
|
||||
public async selectMode(name: string) {
|
||||
await this.modeItem(name).click()
|
||||
}
|
||||
constructor(public readonly page: Page) {}
|
||||
|
||||
public async setMode(mode: AutoQueueMode) {
|
||||
await this.page.evaluate((mode) => {
|
||||
|
||||
@@ -95,7 +95,6 @@ export class NodeLibrarySidebarTabV2 extends SidebarTab {
|
||||
public readonly allTab: Locator
|
||||
public readonly blueprintsTab: Locator
|
||||
public readonly sortButton: Locator
|
||||
public readonly nodePreview: Locator
|
||||
|
||||
constructor(public override readonly page: Page) {
|
||||
super(page, 'node-library')
|
||||
@@ -104,7 +103,6 @@ export class NodeLibrarySidebarTabV2 extends SidebarTab {
|
||||
this.allTab = this.getTab('All')
|
||||
this.blueprintsTab = this.getTab('Blueprints')
|
||||
this.sortButton = this.sidebarContent.getByRole('button', { name: 'Sort' })
|
||||
this.nodePreview = page.getByTestId(TestIds.sidebar.nodePreviewCard)
|
||||
}
|
||||
|
||||
getTab(name: string) {
|
||||
|
||||
@@ -215,12 +215,11 @@ export class AssetHelper {
|
||||
return this.store.size
|
||||
}
|
||||
private handleListAssets(route: Route, url: URL) {
|
||||
const includeTags = parseAssetTagParam(url.searchParams.get('include_tags'))
|
||||
const excludeTags = parseAssetTagParam(url.searchParams.get('exclude_tags'))
|
||||
const includeTags = url.searchParams.get('include_tags')?.split(',') ?? []
|
||||
const limit = parseInt(url.searchParams.get('limit') ?? '0', 10)
|
||||
const offset = parseInt(url.searchParams.get('offset') ?? '0', 10)
|
||||
|
||||
let filtered = this.getFilteredAssets(includeTags, excludeTags)
|
||||
let filtered = this.getFilteredAssets(includeTags)
|
||||
if (limit > 0) {
|
||||
filtered = filtered.slice(offset, offset + limit)
|
||||
}
|
||||
@@ -297,29 +296,15 @@ export class AssetHelper {
|
||||
this.paginationOptions = null
|
||||
this.uploadResponse = null
|
||||
}
|
||||
private getFilteredAssets(
|
||||
includeTags: string[],
|
||||
excludeTags: string[]
|
||||
): Asset[] {
|
||||
private getFilteredAssets(tags: string[]): Asset[] {
|
||||
const assets = [...this.store.values()]
|
||||
if (tags.length === 0) return assets
|
||||
|
||||
return assets.filter(
|
||||
(asset) =>
|
||||
includeTags.every((tag) => (asset.tags ?? []).includes(tag)) &&
|
||||
excludeTags.every((tag) => !(asset.tags ?? []).includes(tag))
|
||||
return assets.filter((asset) =>
|
||||
tags.every((tag) => (asset.tags ?? []).includes(tag))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function parseAssetTagParam(value: string | null): string[] {
|
||||
return (
|
||||
value
|
||||
?.split(',')
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean) ?? []
|
||||
)
|
||||
}
|
||||
|
||||
export function createAssetHelper(
|
||||
page: Page,
|
||||
...operators: AssetOperator[]
|
||||
|
||||
@@ -6,71 +6,6 @@ import type { Locator, Page } from '@playwright/test'
|
||||
import type { KeyboardHelper } from '@e2e/fixtures/helpers/KeyboardHelper'
|
||||
import { getMimeType } from '@e2e/fixtures/utils/mimeTypeUtil'
|
||||
|
||||
function readFilePayload(filePath: string) {
|
||||
const buffer = readFileSync(filePath)
|
||||
const bufferArray = [...new Uint8Array(buffer)]
|
||||
const fileName = basename(filePath)
|
||||
const fileType = getMimeType(fileName)
|
||||
|
||||
return { bufferArray, fileName, fileType }
|
||||
}
|
||||
|
||||
async function dispatchFilePaste(
|
||||
page: Page,
|
||||
payload: ReturnType<typeof readFilePayload>
|
||||
): Promise<void> {
|
||||
await page.evaluate(({ bufferArray, fileName, fileType }) => {
|
||||
const file = new File([new Uint8Array(bufferArray)], fileName, {
|
||||
type: fileType
|
||||
})
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.items.add(file)
|
||||
|
||||
const target = document.activeElement ?? document
|
||||
target.dispatchEvent(
|
||||
new ClipboardEvent('paste', {
|
||||
clipboardData: dataTransfer,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
)
|
||||
}, payload)
|
||||
}
|
||||
|
||||
async function interceptNextFilePaste(
|
||||
page: Page,
|
||||
payload: ReturnType<typeof readFilePayload>
|
||||
): Promise<void> {
|
||||
await page.evaluate(({ bufferArray, fileName, fileType }) => {
|
||||
document.addEventListener(
|
||||
'paste',
|
||||
(e: ClipboardEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopImmediatePropagation()
|
||||
|
||||
const file = new File([new Uint8Array(bufferArray)], fileName, {
|
||||
type: fileType
|
||||
})
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.items.add(file)
|
||||
|
||||
document.dispatchEvent(
|
||||
new ClipboardEvent('paste', {
|
||||
clipboardData: dataTransfer,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
)
|
||||
},
|
||||
{ capture: true, once: true }
|
||||
)
|
||||
}, payload)
|
||||
}
|
||||
|
||||
type PasteFileOptions = {
|
||||
mode?: 'keyboard' | 'direct'
|
||||
}
|
||||
|
||||
export class ClipboardHelper {
|
||||
constructor(
|
||||
private readonly keyboard: KeyboardHelper,
|
||||
@@ -85,20 +20,43 @@ export class ClipboardHelper {
|
||||
await this.keyboard.ctrlSend('KeyV', locator ?? null)
|
||||
}
|
||||
|
||||
async pasteFile(
|
||||
filePath: string,
|
||||
{ mode = 'keyboard' }: PasteFileOptions = {}
|
||||
): Promise<void> {
|
||||
const payload = readFilePayload(filePath)
|
||||
async pasteFile(filePath: string): Promise<void> {
|
||||
const buffer = readFileSync(filePath)
|
||||
const bufferArray = [...new Uint8Array(buffer)]
|
||||
const fileName = basename(filePath)
|
||||
const fileType = getMimeType(fileName)
|
||||
|
||||
if (mode === 'keyboard') {
|
||||
await interceptNextFilePaste(this.page, payload)
|
||||
await this.paste()
|
||||
return
|
||||
}
|
||||
// Register a one-time capturing-phase listener that intercepts the next
|
||||
// paste event and injects file data onto clipboardData.
|
||||
await this.page.evaluate(
|
||||
({ bufferArray, fileName, fileType }) => {
|
||||
document.addEventListener(
|
||||
'paste',
|
||||
(e: ClipboardEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopImmediatePropagation()
|
||||
|
||||
// Browser clipboard APIs cannot reliably seed arbitrary files in tests.
|
||||
// Dispatch the app-level paste event with file clipboardData directly.
|
||||
await dispatchFilePaste(this.page, payload)
|
||||
const file = new File([new Uint8Array(bufferArray)], fileName, {
|
||||
type: fileType
|
||||
})
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.items.add(file)
|
||||
|
||||
const syntheticEvent = new ClipboardEvent('paste', {
|
||||
clipboardData: dataTransfer,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
document.dispatchEvent(syntheticEvent)
|
||||
},
|
||||
{ capture: true, once: true }
|
||||
)
|
||||
},
|
||||
{ bufferArray, fileName, fileType }
|
||||
)
|
||||
|
||||
// Trigger a real Ctrl+V keystroke — the capturing listener above will
|
||||
// intercept it and re-dispatch with file data attached.
|
||||
await this.paste()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,10 @@ import type {
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type {
|
||||
ProxyWidgetTuple,
|
||||
SerializedProxyWidgetTuple
|
||||
} from '@/core/schemas/promotionSchema'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
@@ -386,7 +390,7 @@ export class SubgraphHelper {
|
||||
}
|
||||
|
||||
async getHostPromotedTupleSnapshot(): Promise<
|
||||
{ hostNodeId: string; promotedWidgets: [string, string][] }[]
|
||||
{ hostNodeId: string; promotedWidgets: SerializedProxyWidgetTuple[] }[]
|
||||
> {
|
||||
return this.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
@@ -401,15 +405,18 @@ export class SubgraphHelper {
|
||||
: []
|
||||
const promotedWidgets = proxyWidgets
|
||||
.filter(
|
||||
(entry): entry is [string, string] =>
|
||||
(entry): entry is ProxyWidgetTuple =>
|
||||
Array.isArray(entry) &&
|
||||
entry.length >= 2 &&
|
||||
typeof entry[0] === 'string' &&
|
||||
typeof entry[1] === 'string'
|
||||
)
|
||||
.map(
|
||||
([interiorNodeId, widgetName]) =>
|
||||
[interiorNodeId, widgetName] as [string, string]
|
||||
([sourceNodeId, serializedSourceWidgetName]) =>
|
||||
[
|
||||
sourceNodeId,
|
||||
serializedSourceWidgetName
|
||||
] satisfies SerializedProxyWidgetTuple
|
||||
)
|
||||
|
||||
return {
|
||||
|
||||
@@ -8,7 +8,6 @@ export const TestIds = {
|
||||
toolbar: 'side-toolbar',
|
||||
nodeLibrary: 'node-library-tree',
|
||||
nodeLibrarySearch: 'node-library-search',
|
||||
nodePreviewCard: 'node-preview-card',
|
||||
workflows: 'workflows-sidebar',
|
||||
modeToggle: 'mode-toggle'
|
||||
},
|
||||
@@ -76,15 +75,7 @@ export const TestIds = {
|
||||
publishTabPanel: 'publish-tab-panel',
|
||||
apiSignin: 'api-signin-dialog',
|
||||
updatePassword: 'update-password-dialog',
|
||||
cloudNotification: 'cloud-notification-dialog',
|
||||
openSharedWorkflow: 'open-shared-workflow-dialog',
|
||||
openSharedWorkflowTitle: 'open-shared-workflow-title',
|
||||
openSharedWorkflowClose: 'open-shared-workflow-close',
|
||||
openSharedWorkflowErrorClose: 'open-shared-workflow-error-close',
|
||||
openSharedWorkflowCancel: 'open-shared-workflow-cancel',
|
||||
openSharedWorkflowOpenWithoutImporting:
|
||||
'open-shared-workflow-open-without-importing',
|
||||
openSharedWorkflowConfirm: 'open-shared-workflow-confirm'
|
||||
cloudNotification: 'cloud-notification-dialog'
|
||||
},
|
||||
keybindings: {
|
||||
presetMenu: 'keybinding-preset-menu'
|
||||
|
||||
@@ -1,250 +0,0 @@
|
||||
import { test as base } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
import type {
|
||||
Asset,
|
||||
ImportPublishedAssetsRequest,
|
||||
ListAssetsResponse
|
||||
} from '@comfyorg/ingest-types'
|
||||
import type { z } from 'zod'
|
||||
|
||||
import type { zSharedWorkflowResponse } from '@/platform/workflow/sharing/schemas/shareSchemas'
|
||||
import type { AssetInfo } from '@/schemas/apiSchema'
|
||||
|
||||
type SharedWorkflowResponse = z.input<typeof zSharedWorkflowResponse>
|
||||
|
||||
export const sharedWorkflowImportScenario = {
|
||||
shareId: 'shared-missing-media-e2e',
|
||||
workflowId: 'shared-missing-media-workflow',
|
||||
publishedAssetId: 'published-input-asset-1',
|
||||
inputFileName: 'shared_imported_image.png'
|
||||
} as const
|
||||
|
||||
export type SharedWorkflowRequestEvent =
|
||||
| 'import'
|
||||
| 'input-assets-including-public-before-import'
|
||||
| 'input-assets-including-public-after-import'
|
||||
|
||||
export interface SharedWorkflowImportMocks {
|
||||
resetAndStartRecording: () => void
|
||||
getImportBody: () => ImportPublishedAssetsRequest | undefined
|
||||
getRequestEvents: () => SharedWorkflowRequestEvent[]
|
||||
waitForPublicInclusiveInputAssetResponseAfterImport: () => Promise<void>
|
||||
}
|
||||
|
||||
const defaultInputFileName = '00000000000000000000000Aexample.png'
|
||||
|
||||
const sharedWorkflowAsset: AssetInfo = {
|
||||
id: sharedWorkflowImportScenario.publishedAssetId,
|
||||
name: sharedWorkflowImportScenario.inputFileName,
|
||||
preview_url: '',
|
||||
storage_url: '',
|
||||
model: false,
|
||||
public: false,
|
||||
in_library: false
|
||||
}
|
||||
|
||||
const defaultInputAsset: Asset = {
|
||||
id: 'default-input-asset',
|
||||
name: defaultInputFileName,
|
||||
asset_hash: defaultInputFileName,
|
||||
size: 1_024,
|
||||
mime_type: 'image/png',
|
||||
tags: ['input'],
|
||||
created_at: '2026-05-01T00:00:00Z',
|
||||
updated_at: '2026-05-01T00:00:00Z',
|
||||
last_access_time: '2026-05-01T00:00:00Z'
|
||||
}
|
||||
|
||||
const importedInputAsset: Asset = {
|
||||
id: 'imported-input-asset',
|
||||
name: sharedWorkflowImportScenario.inputFileName,
|
||||
asset_hash: sharedWorkflowImportScenario.inputFileName,
|
||||
size: 1_024,
|
||||
mime_type: 'image/png',
|
||||
tags: ['input'],
|
||||
created_at: '2026-05-01T00:00:00Z',
|
||||
updated_at: '2026-05-01T00:00:00Z',
|
||||
last_access_time: '2026-05-01T00:00:00Z'
|
||||
}
|
||||
|
||||
const sharedWorkflowResponse: SharedWorkflowResponse = {
|
||||
share_id: sharedWorkflowImportScenario.shareId,
|
||||
workflow_id: sharedWorkflowImportScenario.workflowId,
|
||||
name: 'Shared Missing Media Workflow',
|
||||
listed: true,
|
||||
publish_time: '2026-05-01T00:00:00Z',
|
||||
workflow_json: {
|
||||
version: 0.4,
|
||||
last_node_id: 10,
|
||||
last_link_id: 0,
|
||||
nodes: [
|
||||
{
|
||||
id: 10,
|
||||
type: 'LoadImage',
|
||||
pos: [50, 200],
|
||||
size: [315, 314],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0,
|
||||
inputs: [],
|
||||
outputs: [
|
||||
{
|
||||
name: 'IMAGE',
|
||||
type: 'IMAGE',
|
||||
links: null
|
||||
},
|
||||
{
|
||||
name: 'MASK',
|
||||
type: 'MASK',
|
||||
links: null
|
||||
}
|
||||
],
|
||||
properties: {
|
||||
'Node name for S&R': 'LoadImage'
|
||||
},
|
||||
widgets_values: [sharedWorkflowImportScenario.inputFileName, 'image']
|
||||
}
|
||||
],
|
||||
links: [],
|
||||
groups: [],
|
||||
config: {},
|
||||
extra: {
|
||||
ds: {
|
||||
offset: [0, 0],
|
||||
scale: 1
|
||||
}
|
||||
}
|
||||
},
|
||||
assets: [sharedWorkflowAsset]
|
||||
}
|
||||
|
||||
export const sharedWorkflowImportFixture = base.extend<{
|
||||
sharedWorkflowImportMocks: SharedWorkflowImportMocks
|
||||
}>({
|
||||
sharedWorkflowImportMocks: async ({ page }, use) => {
|
||||
const mocks = await mockSharedWorkflowImportFlow(page)
|
||||
await use(mocks)
|
||||
}
|
||||
})
|
||||
|
||||
async function mockSharedWorkflowImportFlow(
|
||||
page: Page
|
||||
): Promise<SharedWorkflowImportMocks> {
|
||||
let isRecording = false
|
||||
let importEndpointCalled = false
|
||||
let importBody: ImportPublishedAssetsRequest | undefined
|
||||
let resolvePublicInclusiveInputAssetResponseAfterImport: () => void = () => {}
|
||||
let publicInclusiveInputAssetResponseAfterImport = new Promise<void>(
|
||||
(resolve) => {
|
||||
resolvePublicInclusiveInputAssetResponseAfterImport = resolve
|
||||
}
|
||||
)
|
||||
const requestEvents: SharedWorkflowRequestEvent[] = []
|
||||
|
||||
function resetPublicInclusiveInputAssetResponseWaiter() {
|
||||
publicInclusiveInputAssetResponseAfterImport = new Promise<void>(
|
||||
(resolve) => {
|
||||
resolvePublicInclusiveInputAssetResponseAfterImport = resolve
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function recordRequestEvent(event: SharedWorkflowRequestEvent) {
|
||||
if (isRecording) requestEvents.push(event)
|
||||
}
|
||||
|
||||
await page.route(
|
||||
`**/workflows/published/${sharedWorkflowImportScenario.shareId}`,
|
||||
async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(sharedWorkflowResponse)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
await page.route('**/api/assets/import', async (route) => {
|
||||
recordRequestEvent('import')
|
||||
importBody = route.request().postDataJSON() as ImportPublishedAssetsRequest
|
||||
importEndpointCalled = true
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({})
|
||||
})
|
||||
})
|
||||
|
||||
// Excludes `/api/assets/import` so the specific route above
|
||||
// remains isolated from the general asset listing mock.
|
||||
await page.route(/\/api\/assets(?=\?|$)/, async (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
const includeTags = getTagParam(url, 'include_tags')
|
||||
const isInputAssetRequest = includeTags.includes('input')
|
||||
const includesPublicAssets =
|
||||
url.searchParams.get('include_public') === 'true'
|
||||
const isPublicInclusiveInputAssetRequest =
|
||||
isInputAssetRequest && includesPublicAssets
|
||||
const isAfterImportPublicInclusiveInputAssetRequest =
|
||||
isPublicInclusiveInputAssetRequest && importEndpointCalled
|
||||
|
||||
if (isPublicInclusiveInputAssetRequest) {
|
||||
recordRequestEvent(
|
||||
importEndpointCalled
|
||||
? 'input-assets-including-public-after-import'
|
||||
: 'input-assets-including-public-before-import'
|
||||
)
|
||||
}
|
||||
|
||||
const allAssets = [
|
||||
defaultInputAsset,
|
||||
...(importEndpointCalled ? [importedInputAsset] : [])
|
||||
]
|
||||
const assets = includeTags.length
|
||||
? allAssets.filter((asset) =>
|
||||
includeTags.every((tag) => asset.tags?.includes(tag))
|
||||
)
|
||||
: allAssets
|
||||
|
||||
const response: ListAssetsResponse = {
|
||||
assets,
|
||||
total: assets.length,
|
||||
has_more: false
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
|
||||
if (isAfterImportPublicInclusiveInputAssetRequest) {
|
||||
resolvePublicInclusiveInputAssetResponseAfterImport()
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
resetAndStartRecording: () => {
|
||||
isRecording = true
|
||||
importEndpointCalled = false
|
||||
importBody = undefined
|
||||
requestEvents.length = 0
|
||||
resetPublicInclusiveInputAssetResponseWaiter()
|
||||
},
|
||||
getImportBody: () => importBody,
|
||||
getRequestEvents: () => [...requestEvents],
|
||||
waitForPublicInclusiveInputAssetResponseAfterImport: () =>
|
||||
publicInclusiveInputAssetResponseAfterImport
|
||||
}
|
||||
}
|
||||
|
||||
function getTagParam(url: URL, key: string): string[] {
|
||||
return (
|
||||
url.searchParams
|
||||
.get(key)
|
||||
?.split(',')
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean) ?? []
|
||||
)
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
export async function openMoreOptionsMenu(
|
||||
comfyPage: ComfyPage,
|
||||
nodeTitle: string
|
||||
) {
|
||||
const nodes = await comfyPage.nodeOps.getNodeRefsByTitle(nodeTitle)
|
||||
if (nodes.length === 0) {
|
||||
throw new Error(`No "${nodeTitle}" nodes found`)
|
||||
}
|
||||
|
||||
await nodes[0].centerOnNode()
|
||||
await nodes[0].click('title')
|
||||
|
||||
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible()
|
||||
|
||||
const moreOptionsBtn = comfyPage.page.getByTestId('more-options-button')
|
||||
await expect(moreOptionsBtn).toBeVisible()
|
||||
await moreOptionsBtn.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const menu = comfyPage.page.locator('.p-contextmenu')
|
||||
await expect(menu).toBeVisible()
|
||||
|
||||
return menu
|
||||
}
|
||||
@@ -133,29 +133,6 @@ test.describe('AssetHelper', () => {
|
||||
expect(data.assets[0].id).toBe(STABLE_CHECKPOINT.id)
|
||||
})
|
||||
|
||||
test('GET /assets filters by exclude_tags', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
assetApi.configure(
|
||||
withAsset(STABLE_INPUT_IMAGE),
|
||||
withAsset({
|
||||
...STABLE_INPUT_IMAGE,
|
||||
id: 'missing-input',
|
||||
tags: ['input', 'missing']
|
||||
})
|
||||
)
|
||||
await assetApi.mock()
|
||||
|
||||
const { body } = await assetApi.fetch(
|
||||
`${comfyPage.url}/api/assets?include_tags=input,&exclude_tags= missing,`
|
||||
)
|
||||
const data = body as { assets: Array<{ id: string }> }
|
||||
expect(data.assets.map((asset) => asset.id)).toEqual([
|
||||
STABLE_INPUT_IMAGE.id
|
||||
])
|
||||
})
|
||||
|
||||
test('GET /assets/:id returns single asset or 404', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
// Regression test for https://github.com/Comfy-Org/ComfyUI_frontend/issues/10563
|
||||
//
|
||||
// Pins the end-to-end cascade through createI18n + coreSettings defaultValue +
|
||||
// GraphView watchEffect: when navigator.language base tag is unsupported (e.g.
|
||||
// 'de-DE') and Comfy.Locale is unset (fresh-install state), sidebar labels
|
||||
// must render translated strings, not literal i18n keys like
|
||||
// 'sideToolbar.labels.assets'.
|
||||
test.describe('i18n locale fallback', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.page.addInitScript(() => {
|
||||
Object.defineProperty(navigator, 'language', {
|
||||
value: 'de-DE',
|
||||
configurable: true
|
||||
})
|
||||
Object.defineProperty(navigator, 'languages', {
|
||||
value: ['de-DE', 'de'],
|
||||
configurable: true
|
||||
})
|
||||
})
|
||||
// Default sidebar size on small viewports hides labels; force normal so
|
||||
// .side-bar-button-label is rendered for the assertion.
|
||||
await comfyPage.settings.setSetting('Comfy.Sidebar.Size', 'normal')
|
||||
await comfyPage.page.reload()
|
||||
await comfyPage.waitForAppReady()
|
||||
})
|
||||
|
||||
test('sidebar labels render translated strings, not raw i18n keys', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
await page.setViewportSize({ width: 1920, height: 1080 })
|
||||
|
||||
const labelTexts = await page
|
||||
.getByTestId('side-toolbar')
|
||||
.locator('.side-bar-button-label')
|
||||
.allTextContents()
|
||||
|
||||
expect(labelTexts.length).toBeGreaterThan(0)
|
||||
for (const text of labelTexts) {
|
||||
expect(text).not.toContain('sideToolbar.labels')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
@@ -1,548 +0,0 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
const MULTI_BINDING_COMMAND = 'Comfy.Canvas.DeleteSelectedItems'
|
||||
const SINGLE_BINDING_COMMAND = 'Comfy.SaveWorkflow'
|
||||
const NO_BINDING_COMMAND = 'TestCommand.KeybindingPanelE2E.NoBinding'
|
||||
|
||||
async function searchKeybindings(page: Page, query: string) {
|
||||
await getKeybindingSearchInput(page).fill(query)
|
||||
}
|
||||
|
||||
async function clearSearch(page: Page) {
|
||||
await getKeybindingSearchInput(page).clear()
|
||||
}
|
||||
|
||||
function getKeybindingSearchInput(page: Page): Locator {
|
||||
return page.getByPlaceholder('Search Keybindings...')
|
||||
}
|
||||
|
||||
function getCommandRow(page: Page, commandId: string): Locator {
|
||||
return page
|
||||
.locator('.keybinding-panel tr')
|
||||
.filter({ has: page.locator(`[title="${commandId}"]`) })
|
||||
}
|
||||
|
||||
function getExpansionContent(page: Page, commandId: string): Locator {
|
||||
// PrimeVue renders the expansion row as the next sibling <tr> of the
|
||||
// expanded row. Scoping by sibling avoids matching unrelated expanded rows.
|
||||
return getCommandRow(page, commandId)
|
||||
.locator('xpath=following-sibling::tr[1]')
|
||||
.getByTestId('keybinding-expansion-content')
|
||||
}
|
||||
|
||||
async function openContextMenu(page: Page, commandId: string) {
|
||||
const row = getCommandRow(page, commandId)
|
||||
await row.locator(`[title="${commandId}"]`).click({ button: 'right' })
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: /Change keybinding/i })
|
||||
).toBeVisible()
|
||||
}
|
||||
|
||||
function getKeybindingInput(page: Page): Locator {
|
||||
return getEditKeybindingDialog(page).locator('input[autofocus]')
|
||||
}
|
||||
|
||||
function getEditKeybindingDialog(page: Page): Locator {
|
||||
return page.getByRole('dialog', { name: /Modify keybinding/i })
|
||||
}
|
||||
|
||||
function getRemoveAllKeybindingsDialog(page: Page): Locator {
|
||||
return page.getByRole('dialog', { name: /Remove all keybindings/i })
|
||||
}
|
||||
|
||||
function getResetAllKeybindingsDialog(page: Page): Locator {
|
||||
return page.getByRole('dialog', { name: /Reset all keybindings/i })
|
||||
}
|
||||
|
||||
async function pressComboOnInput(page: Page, combo: string) {
|
||||
const input = getKeybindingInput(page)
|
||||
await expect(input).toBeFocused()
|
||||
await input.press(combo)
|
||||
}
|
||||
|
||||
async function saveAndCloseKeybindingDialog(page: Page) {
|
||||
const dialog = getEditKeybindingDialog(page)
|
||||
await dialog.getByRole('button', { name: /Save/i }).click()
|
||||
await expect(dialog).toBeHidden()
|
||||
}
|
||||
|
||||
async function cancelAndCloseDialog(page: Page) {
|
||||
const dialog = getEditKeybindingDialog(page)
|
||||
await dialog.getByRole('button', { name: /Cancel/i }).click()
|
||||
await expect(dialog).toBeHidden()
|
||||
}
|
||||
|
||||
async function addKeybindingToRow(page: Page, row: Locator, combo: string) {
|
||||
await row.getByRole('button', { name: /Add new keybinding/i }).click()
|
||||
await pressComboOnInput(page, combo)
|
||||
await saveAndCloseKeybindingDialog(page)
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await registerNoBindingCommand(comfyPage)
|
||||
await comfyPage.settingDialog.open()
|
||||
await comfyPage.settingDialog.category('Keybinding').click()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Keybinding.NewBindings', [])
|
||||
await comfyPage.settings.setSetting('Comfy.Keybinding.UnsetBindings', [])
|
||||
})
|
||||
|
||||
async function registerNoBindingCommand(comfyPage: ComfyPage) {
|
||||
await comfyPage.page.evaluate((commandId) => {
|
||||
const app = window.app!
|
||||
app.registerExtension({
|
||||
name: 'TestExtension.KeybindingPanelE2E',
|
||||
commands: [{ id: commandId, function: () => {} }]
|
||||
})
|
||||
}, NO_BINDING_COMMAND)
|
||||
}
|
||||
|
||||
test.describe('Keybinding Panel', { tag: '@keyboard' }, () => {
|
||||
test.describe('Row Expansion', () => {
|
||||
test('Click on row with 2+ keybindings toggles expansion', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, MULTI_BINDING_COMMAND)
|
||||
const row = getCommandRow(page, MULTI_BINDING_COMMAND)
|
||||
await expect(row).toBeVisible()
|
||||
|
||||
await row.locator(`[title="${MULTI_BINDING_COMMAND}"]`).click()
|
||||
|
||||
const expansionContent = getExpansionContent(page, MULTI_BINDING_COMMAND)
|
||||
await expect(expansionContent).toBeVisible()
|
||||
|
||||
await row.locator(`[title="${MULTI_BINDING_COMMAND}"]`).click()
|
||||
await expect(expansionContent).toBeHidden()
|
||||
})
|
||||
|
||||
test('Click on row with 1 keybinding does not expand', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
|
||||
const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
|
||||
await expect(row).toBeVisible()
|
||||
|
||||
await row.locator(`[title="${SINGLE_BINDING_COMMAND}"]`).click()
|
||||
|
||||
const expansionContent = getExpansionContent(page, SINGLE_BINDING_COMMAND)
|
||||
await expect(expansionContent).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Double-Click', () => {
|
||||
test('Double-click row with 0 keybindings opens Add dialog', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, NO_BINDING_COMMAND)
|
||||
const row = getCommandRow(page, NO_BINDING_COMMAND)
|
||||
await expect(row).toBeVisible()
|
||||
|
||||
await row.locator(`[title="${NO_BINDING_COMMAND}"]`).dblclick()
|
||||
|
||||
const input = getKeybindingInput(page)
|
||||
await expect(input).toBeVisible()
|
||||
|
||||
await cancelAndCloseDialog(page)
|
||||
})
|
||||
|
||||
test('Double-click row with 1 keybinding opens Edit dialog', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
|
||||
const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
|
||||
await expect(row).toBeVisible()
|
||||
|
||||
await row.locator(`[title="${SINGLE_BINDING_COMMAND}"]`).dblclick()
|
||||
|
||||
const input = getKeybindingInput(page)
|
||||
await expect(input).toBeVisible()
|
||||
|
||||
await cancelAndCloseDialog(page)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Context Menu', () => {
|
||||
test('Right-click row shows context menu with correct items', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
|
||||
await openContextMenu(page, SINGLE_BINDING_COMMAND)
|
||||
|
||||
const changeItem = page.getByRole('menuitem', {
|
||||
name: /Change keybinding/i
|
||||
})
|
||||
const addItem = page.getByRole('menuitem', {
|
||||
name: /Add new keybinding/i
|
||||
})
|
||||
const resetItem = page.getByRole('menuitem', {
|
||||
name: /Reset to default/i
|
||||
})
|
||||
const removeItem = page.getByRole('menuitem', {
|
||||
name: /Remove keybinding/i
|
||||
})
|
||||
|
||||
await expect(changeItem).toBeVisible()
|
||||
await expect(addItem).toBeVisible()
|
||||
await expect(resetItem).toBeVisible()
|
||||
await expect(removeItem).toBeVisible()
|
||||
|
||||
await page.keyboard.press('Escape')
|
||||
})
|
||||
|
||||
test("Context menu 'Add new keybinding' opens add dialog", async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
|
||||
await openContextMenu(page, SINGLE_BINDING_COMMAND)
|
||||
|
||||
await page.getByRole('menuitem', { name: /Add new keybinding/i }).click()
|
||||
|
||||
const input = getKeybindingInput(page)
|
||||
await expect(input).toBeVisible()
|
||||
|
||||
await cancelAndCloseDialog(page)
|
||||
})
|
||||
|
||||
test("Context menu 'Change keybinding' on single-binding command opens edit dialog", async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
|
||||
await openContextMenu(page, SINGLE_BINDING_COMMAND)
|
||||
|
||||
await page.getByRole('menuitem', { name: /Change keybinding/i }).click()
|
||||
|
||||
const input = getKeybindingInput(page)
|
||||
await expect(input).toBeVisible()
|
||||
|
||||
await cancelAndCloseDialog(page)
|
||||
})
|
||||
|
||||
test("Context menu 'Change keybinding' on multi-binding command expands row", async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, MULTI_BINDING_COMMAND)
|
||||
|
||||
const expansionContent = getExpansionContent(page, MULTI_BINDING_COMMAND)
|
||||
await expect(expansionContent).toBeHidden()
|
||||
|
||||
await openContextMenu(page, MULTI_BINDING_COMMAND)
|
||||
|
||||
await page.getByRole('menuitem', { name: /Change keybinding/i }).click()
|
||||
|
||||
await expect(expansionContent).toBeVisible()
|
||||
})
|
||||
|
||||
test("Context menu 'Remove keybinding' after adding second binding shows confirm dialog", async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
|
||||
const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
|
||||
|
||||
await addKeybindingToRow(page, row, 'Control+Shift+F9')
|
||||
|
||||
await openContextMenu(page, SINGLE_BINDING_COMMAND)
|
||||
await page.getByRole('menuitem', { name: /Remove keybinding/i }).click()
|
||||
|
||||
const confirmDialog = getRemoveAllKeybindingsDialog(page)
|
||||
await expect(confirmDialog).toBeVisible()
|
||||
await confirmDialog.getByRole('button', { name: /Remove all/i }).click()
|
||||
|
||||
await expect(row.locator('td').nth(1)).toContainText('-')
|
||||
})
|
||||
|
||||
test("Context menu 'Reset to default' resets modified command", async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
|
||||
const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
|
||||
|
||||
await addKeybindingToRow(page, row, 'Control+Shift+F10')
|
||||
|
||||
await openContextMenu(page, SINGLE_BINDING_COMMAND)
|
||||
await page.getByRole('menuitem', { name: /Reset to default/i }).click()
|
||||
|
||||
await expect(row.getByRole('button', { name: /Reset/i })).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Context menu items disabled when no keybindings', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, NO_BINDING_COMMAND)
|
||||
await openContextMenu(page, NO_BINDING_COMMAND)
|
||||
|
||||
const changeItem = page.getByRole('menuitem', {
|
||||
name: /Change keybinding/i
|
||||
})
|
||||
const removeItem = page.getByRole('menuitem', {
|
||||
name: /Remove keybinding/i
|
||||
})
|
||||
|
||||
await expect(changeItem).toHaveAttribute('data-disabled', '')
|
||||
await expect(removeItem).toHaveAttribute('data-disabled', '')
|
||||
|
||||
await page.keyboard.press('Escape')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Action Buttons', () => {
|
||||
test('Edit button opens edit dialog for single-binding command', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
|
||||
const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
|
||||
|
||||
const editButton = row.getByRole('button', { name: /^Edit$/i })
|
||||
await expect(editButton).toBeVisible()
|
||||
await editButton.click()
|
||||
|
||||
const input = getKeybindingInput(page)
|
||||
await expect(input).toBeVisible()
|
||||
|
||||
await cancelAndCloseDialog(page)
|
||||
})
|
||||
|
||||
test('Add button opens add dialog', async ({ comfyPage }) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
|
||||
const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
|
||||
|
||||
await row.getByRole('button', { name: /Add new keybinding/i }).click()
|
||||
|
||||
const input = getKeybindingInput(page)
|
||||
await expect(input).toBeVisible()
|
||||
|
||||
await cancelAndCloseDialog(page)
|
||||
})
|
||||
|
||||
test('Reset button is disabled for unmodified commands', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
|
||||
const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
|
||||
|
||||
const resetButton = row.getByRole('button', { name: /Reset/i })
|
||||
await expect(resetButton).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Reset button resets modified keybinding', async ({ comfyPage }) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
|
||||
const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
|
||||
|
||||
await addKeybindingToRow(page, row, 'Control+Shift+F11')
|
||||
|
||||
const resetButton = row.getByRole('button', { name: /Reset/i })
|
||||
await expect(resetButton).toBeEnabled()
|
||||
|
||||
await resetButton.click()
|
||||
|
||||
await expect(resetButton).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Delete button is disabled for commands with 0 keybindings', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, NO_BINDING_COMMAND)
|
||||
const row = getCommandRow(page, NO_BINDING_COMMAND)
|
||||
|
||||
const deleteButton = row.getByRole('button', { name: /Delete/i })
|
||||
await expect(deleteButton).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Delete button removes single keybinding directly', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, NO_BINDING_COMMAND)
|
||||
const row = getCommandRow(page, NO_BINDING_COMMAND)
|
||||
|
||||
await addKeybindingToRow(page, row, 'Control+Shift+F12')
|
||||
|
||||
const deleteButton = row.getByRole('button', { name: /Delete/i })
|
||||
await expect(deleteButton).toBeEnabled()
|
||||
await deleteButton.click()
|
||||
|
||||
await expect(row.locator('td').nth(1)).toContainText('-')
|
||||
})
|
||||
|
||||
test('Delete button on command with 2+ keybindings shows confirm dialog', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, MULTI_BINDING_COMMAND)
|
||||
const row = getCommandRow(page, MULTI_BINDING_COMMAND)
|
||||
|
||||
const deleteButton = row.getByRole('button', { name: /Delete/i })
|
||||
await deleteButton.click()
|
||||
|
||||
const confirmDialog = getRemoveAllKeybindingsDialog(page)
|
||||
await expect(confirmDialog).toBeVisible()
|
||||
|
||||
await confirmDialog.getByRole('button', { name: /Cancel/i }).click()
|
||||
await expect(confirmDialog).toBeHidden()
|
||||
await expect(row.locator('td').nth(1)).not.toContainText('-')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Expanded Row Actions', () => {
|
||||
test('Edit button in expanded row opens edit dialog for that binding', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, MULTI_BINDING_COMMAND)
|
||||
|
||||
await page.locator(`[title="${MULTI_BINDING_COMMAND}"]`).click()
|
||||
const expansionContent = getExpansionContent(page, MULTI_BINDING_COMMAND)
|
||||
await expect(expansionContent).toBeVisible()
|
||||
|
||||
const firstBindingRow = expansionContent
|
||||
.getByTestId('keybinding-expansion-binding')
|
||||
.first()
|
||||
await firstBindingRow.getByRole('button', { name: /^Edit$/i }).click()
|
||||
|
||||
const input = getKeybindingInput(page)
|
||||
await expect(input).toBeVisible()
|
||||
|
||||
await cancelAndCloseDialog(page)
|
||||
})
|
||||
|
||||
test('Delete button in expanded row removes that binding and collapses', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, MULTI_BINDING_COMMAND)
|
||||
|
||||
await page.locator(`[title="${MULTI_BINDING_COMMAND}"]`).click()
|
||||
const expansionContent = getExpansionContent(page, MULTI_BINDING_COMMAND)
|
||||
await expect(expansionContent).toBeVisible()
|
||||
|
||||
const bindingRows = expansionContent.getByTestId(
|
||||
'keybinding-expansion-binding'
|
||||
)
|
||||
await expect
|
||||
.poll(() => bindingRows.count(), {
|
||||
message: 'Expected at least 2 bindings'
|
||||
})
|
||||
.toBeGreaterThanOrEqual(2)
|
||||
const initialBindingCount = await bindingRows.count()
|
||||
|
||||
await bindingRows
|
||||
.first()
|
||||
.getByRole('button', { name: /Remove keybinding/i })
|
||||
.click()
|
||||
|
||||
if (initialBindingCount === 2) {
|
||||
// Expansion auto-collapses when bindings drop below 2
|
||||
await expect(expansionContent).toBeHidden()
|
||||
} else {
|
||||
await expect(bindingRows).toHaveCount(initialBindingCount - 1)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Reset All', () => {
|
||||
test('Reset All button shows confirmation and resets on confirm', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
|
||||
const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
|
||||
await addKeybindingToRow(page, row, 'Control+Shift+F8')
|
||||
|
||||
await expect(row.getByRole('button', { name: /Reset/i })).toBeEnabled()
|
||||
|
||||
await clearSearch(page)
|
||||
|
||||
const resetAllButton = page
|
||||
.locator('.keybinding-panel')
|
||||
.getByRole('button', { name: /Reset All/i })
|
||||
await resetAllButton.click()
|
||||
|
||||
const confirmDialog = getResetAllKeybindingsDialog(page)
|
||||
await expect(confirmDialog).toBeVisible()
|
||||
await expect(confirmDialog).toContainText(/Reset all keybindings/i)
|
||||
|
||||
await confirmDialog.getByRole('button', { name: /Reset All/i }).click()
|
||||
|
||||
await expect(comfyPage.toast.visibleToasts).toHaveCount(1)
|
||||
|
||||
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
|
||||
const rowAfterReset = getCommandRow(page, SINGLE_BINDING_COMMAND)
|
||||
await expect(
|
||||
rowAfterReset.getByRole('button', { name: /Reset/i })
|
||||
).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Reset All confirmation can be cancelled', async ({ comfyPage }) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
const resetAllButton = page
|
||||
.locator('.keybinding-panel')
|
||||
.getByRole('button', { name: /Reset All/i })
|
||||
await resetAllButton.click()
|
||||
|
||||
const confirmDialog = getResetAllKeybindingsDialog(page)
|
||||
await expect(confirmDialog).toBeVisible()
|
||||
await confirmDialog.getByRole('button', { name: /Cancel/i }).click()
|
||||
|
||||
await expect(confirmDialog).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Search Filter', () => {
|
||||
test('Typing in search clears expanded rows', async ({ comfyPage }) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, MULTI_BINDING_COMMAND)
|
||||
|
||||
await page.locator(`[title="${MULTI_BINDING_COMMAND}"]`).click()
|
||||
const expansionContent = getExpansionContent(page, MULTI_BINDING_COMMAND)
|
||||
await expect(expansionContent).toBeVisible()
|
||||
|
||||
// Changing the filter triggers watch(filters, ...) which clears expansion
|
||||
await searchKeybindings(page, MULTI_BINDING_COMMAND + ' ')
|
||||
await expect(expansionContent).toBeHidden()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 57 KiB |
@@ -1,65 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { openMoreOptionsMenu } from '@e2e/fixtures/utils/selectionToolboxMoreOptions'
|
||||
|
||||
test.describe(
|
||||
'Node context menu shape submenu (FE-570)',
|
||||
{ tag: '@ui' },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
|
||||
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
|
||||
})
|
||||
|
||||
async function expectShapePopoverVisible(comfyPage: ComfyPage) {
|
||||
const popover = comfyPage.page
|
||||
.locator('.p-popover')
|
||||
.filter({ hasText: 'Default' })
|
||||
await expect(popover).toBeVisible()
|
||||
await expect(popover).toContainText('Box')
|
||||
await expect(popover).toContainText('Card')
|
||||
|
||||
const popoverBox = await popover.boundingBox()
|
||||
expect(popoverBox).not.toBeNull()
|
||||
expect(popoverBox!.width).toBeGreaterThan(0)
|
||||
expect(popoverBox!.height).toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
test('Shape popover opens when the menu fits in the viewport', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.setViewportSize({ width: 1280, height: 900 })
|
||||
const menu = await openMoreOptionsMenu(comfyPage, 'KSampler')
|
||||
const rootList = menu.locator(':scope > ul')
|
||||
|
||||
await expect
|
||||
.poll(() => rootList.evaluate((el) => getComputedStyle(el).overflowY))
|
||||
.toBe('visible')
|
||||
|
||||
await menu.getByRole('menuitem', { name: 'Shape' }).click()
|
||||
await expectShapePopoverVisible(comfyPage)
|
||||
})
|
||||
|
||||
test('Shape popover opens even when the menu must scroll', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.setViewportSize({ width: 1280, height: 520 })
|
||||
const menu = await openMoreOptionsMenu(comfyPage, 'KSampler')
|
||||
const rootList = menu.locator(':scope > ul')
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
rootList.evaluate((el) => el.scrollHeight > el.clientHeight)
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
const shapeItem = menu.getByRole('menuitem', { name: 'Shape' })
|
||||
await shapeItem.scrollIntoViewIfNeeded()
|
||||
await shapeItem.click()
|
||||
await expectShapePopoverVisible(comfyPage)
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -351,45 +351,6 @@ test.describe('Performance', { tag: ['@perf'] }, () => {
|
||||
})
|
||||
})
|
||||
|
||||
test(
|
||||
'subgraph transition (enter and exit)',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
async ({ comfyPage }, testInfo) => {
|
||||
// Heaviest perf test: loads an 80-node subgraph and pays ~30s/repeat.
|
||||
// The signal is dominated by N=80 mount cost, so a single sample per
|
||||
// CI invocation is sufficient — early-return on subsequent repeats.
|
||||
if (testInfo.repeatEachIndex > 0) return
|
||||
|
||||
// Load workflow with a subgraph containing 80 interior nodes.
|
||||
// Entering the subgraph unmounts root nodes and mounts all 80 interior
|
||||
// nodes synchronously — this is the bottleneck we're measuring.
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/large-subgraph-80-nodes')
|
||||
|
||||
await comfyPage.idleFrames(30)
|
||||
|
||||
await comfyPage.vueNodes.enterSubgraph()
|
||||
await comfyPage.vueNodes.waitForNodes(80)
|
||||
await comfyPage.idleFrames(30)
|
||||
|
||||
// Exit back to root graph before measuring a fresh enter/exit cycle
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
await comfyPage.idleFrames(10)
|
||||
|
||||
// Start measuring the enter transition
|
||||
await comfyPage.perf.startMeasuring()
|
||||
|
||||
await comfyPage.vueNodes.enterSubgraph()
|
||||
await comfyPage.vueNodes.waitForNodes(80)
|
||||
await comfyPage.idleFrames(30)
|
||||
|
||||
const m = await comfyPage.perf.stopMeasuring('subgraph-transition-enter')
|
||||
recordMeasurement(m)
|
||||
console.log(
|
||||
`Subgraph enter (80 nodes): ${m.taskDurationMs.toFixed(0)}ms task, ${m.layouts} layouts, TBT=${m.totalBlockingTimeMs.toFixed(0)}ms`
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
test('workflow execution', async ({ comfyPage }) => {
|
||||
// Uses lightweight PrimitiveString → PreviewAny workflow (no GPU needed)
|
||||
await comfyPage.workflow.loadWorkflow('execution/partial_execution')
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Preview as Text node', () => {
|
||||
test('does not include preview widget values in the API prompt', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const node = window.LiteGraph!.createNode('PreviewAny')!
|
||||
node.pos = [500, 200]
|
||||
window.app!.graph.add(node)
|
||||
})
|
||||
|
||||
// Simulate a previous execution: backend returned text and the frontend
|
||||
// populated the preview widget values. The next prompt submission must
|
||||
// NOT echo those values back as inputs (which would change the cache
|
||||
// signature and trigger a redundant re-execution).
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const node = window.app!.graph.nodes.find((n) => n.type === 'PreviewAny')!
|
||||
for (const widget of node.widgets ?? []) {
|
||||
if (widget.name?.startsWith('preview_')) {
|
||||
widget.value = 'rendered preview content from previous execution'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const apiWorkflow = await comfyPage.workflow.getExportedWorkflow({
|
||||
api: true
|
||||
})
|
||||
|
||||
const previewEntry = Object.values(apiWorkflow).find(
|
||||
(n) => n.class_type === 'PreviewAny'
|
||||
)
|
||||
expect(previewEntry).toBeDefined()
|
||||
|
||||
expect(previewEntry!.inputs).not.toHaveProperty('preview_markdown')
|
||||
expect(previewEntry!.inputs).not.toHaveProperty('preview_text')
|
||||
expect(previewEntry!.inputs).not.toHaveProperty('previewMode')
|
||||
})
|
||||
})
|
||||
@@ -1,63 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { PromptResponse } from '@/schemas/apiSchema'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
const queueModeLabels = ['Run', 'Run (On Change)', 'Run (Instant)']
|
||||
const runOnChangeLabel = queueModeLabels[1]
|
||||
|
||||
test.describe('Queue button modes', { tag: '@ui' }, () => {
|
||||
test('Run button is visible in topbar', async ({ comfyPage }) => {
|
||||
await expect(comfyPage.actionbar.queueButton.primaryButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('Queue mode trigger menu is visible', async ({ comfyPage }) => {
|
||||
await expect(comfyPage.actionbar.queueButton.dropdownButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('Clicking queue mode trigger opens mode menu', async ({ comfyPage }) => {
|
||||
const options = await comfyPage.actionbar.queueButton.openOptions()
|
||||
|
||||
await expect(options.menu).toBeVisible()
|
||||
})
|
||||
|
||||
test('Queue mode menu shows available modes', async ({ comfyPage }) => {
|
||||
const options = await comfyPage.actionbar.queueButton.openOptions()
|
||||
|
||||
await expect(options.menu).toBeVisible()
|
||||
await expect(options.modeItems).toHaveText(queueModeLabels)
|
||||
})
|
||||
|
||||
test('Selecting a non-default mode updates the Run button label', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const queueButton = comfyPage.actionbar.queueButton
|
||||
const options = await queueButton.openOptions()
|
||||
|
||||
await expect(options.menu).toBeVisible()
|
||||
await options.selectMode(runOnChangeLabel)
|
||||
|
||||
await expect(queueButton.primaryButton).toContainText(runOnChangeLabel)
|
||||
})
|
||||
|
||||
test('Run button sends prompt when clicked', async ({ comfyPage }) => {
|
||||
let promptQueued = false
|
||||
const mockResponse: PromptResponse = {
|
||||
prompt_id: 'test-id',
|
||||
node_errors: {},
|
||||
error: ''
|
||||
}
|
||||
await comfyPage.page.route('**/api/prompt', async (route) => {
|
||||
promptQueued = true
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify(mockResponse)
|
||||
})
|
||||
})
|
||||
|
||||
await comfyPage.actionbar.queueButton.primaryButton.click()
|
||||
|
||||
await expect.poll(() => promptQueued).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -16,7 +16,7 @@ test.describe(
|
||||
await comfyPage.runButton.click()
|
||||
|
||||
const saveImageNode = comfyPage.vueNodes.getNodeByTitle('Save Image')
|
||||
const saveWebmNode = comfyPage.vueNodes.getNodeByTitle('Save WEBM')
|
||||
const saveWebmNode = comfyPage.vueNodes.getNodeByTitle('SaveWEBM')
|
||||
|
||||
// Wait for SaveImage to render an img inside .image-preview
|
||||
await expect(saveImageNode.locator('.image-preview img')).toBeVisible({
|
||||
|
||||
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 93 KiB |
@@ -54,44 +54,14 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
|
||||
.toBe(initialCount - 1)
|
||||
})
|
||||
|
||||
test('info button opens the right-side info tab in new menu mode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', true)
|
||||
await comfyPage.settings.setSetting('Comfy.RightSidePanel.IsOpen', false)
|
||||
|
||||
test('info button opens properties panel', async ({ comfyPage }) => {
|
||||
const nodeRef = (await comfyPage.nodeOps.getNodeRefsByTitle('KSampler'))[0]
|
||||
await selectNodeWithPan(comfyPage, nodeRef)
|
||||
await expect(comfyPage.menu.propertiesPanel.root).toBeHidden()
|
||||
|
||||
const infoButton = comfyPage.page.getByTestId('info-button')
|
||||
await expect(infoButton).toBeVisible()
|
||||
await infoButton.click()
|
||||
|
||||
const panel = comfyPage.menu.propertiesPanel.root
|
||||
await expect(panel).toBeVisible()
|
||||
await expect(panel.getByTestId('panel-tab-info')).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true'
|
||||
)
|
||||
await expect(panel).toContainText('KSampler')
|
||||
await expect(comfyPage.menu.nodeLibraryTab.selectedTabButton).toBeHidden()
|
||||
})
|
||||
|
||||
test('info button is hidden when the new menu is disabled', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
|
||||
|
||||
const nodeRef = (await comfyPage.nodeOps.getNodeRefsByTitle('KSampler'))[0]
|
||||
await selectNodeWithPan(comfyPage, nodeRef)
|
||||
|
||||
await expect(comfyPage.selectionToolbox).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.selectionToolbox.getByTestId('info-button')
|
||||
).toBeHidden()
|
||||
await expect(comfyPage.page.getByTestId('properties-panel')).toBeVisible()
|
||||
})
|
||||
|
||||
test('convert-to-subgraph button visible with multi-select', async ({
|
||||
|
||||
@@ -2,7 +2,6 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { openMoreOptionsMenu } from '@e2e/fixtures/utils/selectionToolboxMoreOptions'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
@@ -19,19 +18,70 @@ test.describe(
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
|
||||
const openMoreOptions = (comfyPage: ComfyPage) =>
|
||||
openMoreOptionsMenu(comfyPage, 'KSampler')
|
||||
const openMoreOptions = async (comfyPage: ComfyPage) => {
|
||||
const ksamplerNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
|
||||
if (ksamplerNodes.length === 0) {
|
||||
throw new Error('No KSampler nodes found')
|
||||
}
|
||||
|
||||
test('hides Node Info from More Options menu when the new menu is disabled', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
|
||||
// Drag the KSampler to the center of the screen
|
||||
const nodePos = await ksamplerNodes[0].getPosition()
|
||||
const viewportSize = comfyPage.page.viewportSize()
|
||||
if (!viewportSize) {
|
||||
throw new Error(
|
||||
'Viewport size is null - page may not be properly initialized'
|
||||
)
|
||||
}
|
||||
const centerX = viewportSize.width / 3
|
||||
const centerY = viewportSize.height / 2
|
||||
await comfyPage.canvasOps.dragAndDrop(
|
||||
{ x: nodePos.x, y: nodePos.y },
|
||||
{ x: centerX, y: centerY }
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await ksamplerNodes[0].click('title')
|
||||
|
||||
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible()
|
||||
|
||||
const moreOptionsBtn = comfyPage.page.getByTestId('more-options-button')
|
||||
await expect(moreOptionsBtn).toBeVisible()
|
||||
|
||||
await moreOptionsBtn.click()
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const menuOptionsVisible = await comfyPage.page
|
||||
.getByText('Rename')
|
||||
.isVisible({ timeout: 2000 })
|
||||
.catch(() => false)
|
||||
if (menuOptionsVisible) {
|
||||
return
|
||||
}
|
||||
|
||||
await moreOptionsBtn.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const menuOptionsVisibleAfterClick = await comfyPage.page
|
||||
.getByText('Rename')
|
||||
.isVisible({ timeout: 2000 })
|
||||
.catch(() => false)
|
||||
if (menuOptionsVisibleAfterClick) {
|
||||
return
|
||||
}
|
||||
|
||||
throw new Error('Could not open More Options menu - popover not showing')
|
||||
}
|
||||
|
||||
test('opens Node Info from More Options menu', async ({ comfyPage }) => {
|
||||
await openMoreOptions(comfyPage)
|
||||
const nodeInfoButton = comfyPage.page.getByRole('menuitem', {
|
||||
name: 'Node Info'
|
||||
const nodeInfoButton = comfyPage.page.getByText('Node Info', {
|
||||
exact: true
|
||||
})
|
||||
await expect(nodeInfoButton).toBeHidden()
|
||||
await expect(nodeInfoButton).toBeVisible()
|
||||
await nodeInfoButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
|
||||
test('changes node shape via Shape submenu', async ({ comfyPage }) => {
|
||||
@@ -40,14 +90,11 @@ test.describe(
|
||||
)[0]
|
||||
|
||||
await openMoreOptions(comfyPage)
|
||||
// Shape now opens via body-appended popover (FE-570); a hover no
|
||||
// longer reveals the submenu — match the Color flow and click.
|
||||
await comfyPage.page.getByText('Shape', { exact: true }).click()
|
||||
const shapePopover = comfyPage.page
|
||||
.locator('.p-popover')
|
||||
.filter({ hasText: 'Default' })
|
||||
await expect(shapePopover.getByText('Box', { exact: true })).toBeVisible()
|
||||
await shapePopover.getByText('Box', { exact: true }).click()
|
||||
await comfyPage.page.getByText('Shape', { exact: true }).hover()
|
||||
await expect(
|
||||
comfyPage.page.getByText('Box', { exact: true })
|
||||
).toBeVisible()
|
||||
await comfyPage.page.getByText('Box', { exact: true }).click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect.poll(() => nodeRef.getProperty<number>('shape')).toBe(1)
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import {
|
||||
sharedWorkflowImportFixture,
|
||||
sharedWorkflowImportScenario
|
||||
} from '@e2e/fixtures/sharedWorkflowImportFixture'
|
||||
import type { SharedWorkflowImportMocks } from '@e2e/fixtures/sharedWorkflowImportFixture'
|
||||
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
|
||||
import type { WorkspaceStore } from '@e2e/types/globals'
|
||||
|
||||
const IMPORT_ORDER_TIMEOUT_MS = 5_000
|
||||
|
||||
async function expectImportPrecedesPublicInclusiveInputAssetScan(
|
||||
mocks: SharedWorkflowImportMocks
|
||||
): Promise<void> {
|
||||
await expect(async () => {
|
||||
const events = mocks.getRequestEvents()
|
||||
const importIndex = events.indexOf('import')
|
||||
const afterImportIndex = events.indexOf(
|
||||
'input-assets-including-public-after-import'
|
||||
)
|
||||
|
||||
expect(
|
||||
events,
|
||||
'public-inclusive input assets must not be scanned before import'
|
||||
).not.toContain('input-assets-including-public-before-import')
|
||||
expect(importIndex, `events: ${events.join(',')}`).toBeGreaterThanOrEqual(0)
|
||||
expect(afterImportIndex, `events: ${events.join(',')}`).toBeGreaterThan(
|
||||
importIndex
|
||||
)
|
||||
}).toPass({ timeout: IMPORT_ORDER_TIMEOUT_MS })
|
||||
}
|
||||
|
||||
async function getCachedMissingMediaWarningNames(
|
||||
comfyPage: ComfyPage
|
||||
): Promise<string[] | null> {
|
||||
return await comfyPage.page.evaluate(() => {
|
||||
const workflow = (window.app!.extensionManager as WorkspaceStore).workflow
|
||||
.activeWorkflow
|
||||
if (!workflow) return null
|
||||
|
||||
return (
|
||||
workflow.pendingWarnings?.missingMediaCandidates?.map(
|
||||
(candidate) => candidate.name
|
||||
) ?? []
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async function expectNoMissingMediaAfterPublicInclusiveAssetScan(
|
||||
comfyPage: ComfyPage,
|
||||
mocks: SharedWorkflowImportMocks
|
||||
): Promise<void> {
|
||||
await mocks.waitForPublicInclusiveInputAssetResponseAfterImport()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
).toBeHidden()
|
||||
await expect
|
||||
.poll(() => getCachedMissingMediaWarningNames(comfyPage))
|
||||
.toEqual([])
|
||||
}
|
||||
|
||||
async function openPanelAndExpectNoMissingMedia(
|
||||
comfyPage: ComfyPage
|
||||
): Promise<void> {
|
||||
const page = comfyPage.page
|
||||
const errorOverlay = page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
await expect(errorOverlay).toBeHidden()
|
||||
|
||||
const panel = new PropertiesPanelHelper(page)
|
||||
await panel.open(comfyPage.actionbar.propertiesButton)
|
||||
await expect(
|
||||
panel.root.getByTestId(TestIds.propertiesPanel.errorsTab)
|
||||
).toBeHidden()
|
||||
await expect(page.getByTestId(TestIds.dialogs.missingMediaGroup)).toHaveCount(
|
||||
0
|
||||
)
|
||||
}
|
||||
|
||||
const test = mergeTests(comfyPageFixture, sharedWorkflowImportFixture)
|
||||
|
||||
test.describe('Shared workflow missing media', { tag: '@cloud' }, () => {
|
||||
// Missing media only surfaces the overlay when the Errors tab is enabled
|
||||
// (src/stores/executionErrorStore.ts).
|
||||
test.beforeEach(async ({ comfyPage, sharedWorkflowImportMocks }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
true
|
||||
)
|
||||
sharedWorkflowImportMocks.resetAndStartRecording()
|
||||
await comfyPage.page.goto(
|
||||
new URL(
|
||||
`/?share=${sharedWorkflowImportScenario.shareId}`,
|
||||
comfyPage.url
|
||||
).toString()
|
||||
)
|
||||
await comfyPage.waitForAppReady()
|
||||
})
|
||||
|
||||
test('imports shared media before loading workflow so missing media is not surfaced', async ({
|
||||
comfyPage,
|
||||
sharedWorkflowImportMocks
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
const dialog = page.getByTestId(TestIds.dialogs.openSharedWorkflow)
|
||||
await expect(
|
||||
dialog.getByTestId(TestIds.dialogs.openSharedWorkflowTitle)
|
||||
).toBeVisible()
|
||||
|
||||
await dialog.getByTestId(TestIds.dialogs.openSharedWorkflowConfirm).click()
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() =>
|
||||
window.app!.graph.nodes.map((node) => ({
|
||||
type: node.type,
|
||||
value: node.widgets?.[0]?.value
|
||||
}))
|
||||
)
|
||||
)
|
||||
.toEqual([
|
||||
{
|
||||
type: 'LoadImage',
|
||||
value: sharedWorkflowImportScenario.inputFileName
|
||||
}
|
||||
])
|
||||
await expectImportPrecedesPublicInclusiveInputAssetScan(
|
||||
sharedWorkflowImportMocks
|
||||
)
|
||||
await expectNoMissingMediaAfterPublicInclusiveAssetScan(
|
||||
comfyPage,
|
||||
sharedWorkflowImportMocks
|
||||
)
|
||||
|
||||
expect(sharedWorkflowImportMocks.getImportBody()).toEqual({
|
||||
published_asset_ids: [sharedWorkflowImportScenario.publishedAssetId],
|
||||
share_id: sharedWorkflowImportScenario.shareId
|
||||
})
|
||||
expect(new URL(page.url()).searchParams.has('share')).toBe(false)
|
||||
await openPanelAndExpectNoMissingMedia(comfyPage)
|
||||
})
|
||||
})
|
||||
@@ -120,13 +120,4 @@ test.describe('Node library sidebar V2', () => {
|
||||
await expect(options.first()).toBeVisible()
|
||||
await expect.poll(() => options.count()).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
test('Blueprint previews include description', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.nodeLibraryTabV2
|
||||
await tab.blueprintsTab.click()
|
||||
|
||||
await tab.getNode('test blueprint').hover()
|
||||
await expect(tab.nodePreview, 'Preview displays on hover').toBeVisible()
|
||||
await expect(tab.nodePreview).toContainText('Inverts the image')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
const MULTI_INSTANCE_WORKFLOW =
|
||||
'subgraphs/subgraph-multi-instance-promoted-text-values'
|
||||
|
||||
test.describe(
|
||||
'Multi-instance subgraph promoted widget rendering in Vue mode',
|
||||
{ tag: ['@subgraph', '@vue-nodes', '@widget'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
test('Each subgraph instance renders its own promoted widget value, not the interior default', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const expectedByNodeId: Record<string, string> = {
|
||||
'11': 'Alpha\n',
|
||||
'12': 'Beta\n',
|
||||
'13': 'Gamma\n'
|
||||
}
|
||||
|
||||
await comfyPage.workflow.loadWorkflow(MULTI_INSTANCE_WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes(3)
|
||||
|
||||
for (const [nodeId, expectedValue] of Object.entries(expectedByNodeId)) {
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator(nodeId)
|
||||
await expect(subgraphNode).toBeVisible()
|
||||
|
||||
const textarea = subgraphNode.getByRole('textbox', {
|
||||
name: 'text',
|
||||
exact: true
|
||||
})
|
||||
await expect(textarea).toHaveValue(expectedValue)
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -535,28 +535,6 @@ test.describe(
|
||||
.poll(() => getPromotedWidgetCount(comfyPage, '11'))
|
||||
.toBeLessThan(initialWidgetCount)
|
||||
})
|
||||
|
||||
test('Does not cleanup unconfigured Primitive', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-link-and-proxied-primitive'
|
||||
)
|
||||
await expect
|
||||
.poll(
|
||||
() => getPromotedWidgetCount(comfyPage, '2'),
|
||||
'Primitive widget is restored on load'
|
||||
)
|
||||
.toBe(2)
|
||||
|
||||
await comfyPage.page.evaluate(() => app!.canvas.setDirty(true))
|
||||
const subgraphNode = await comfyPage.nodeOps.getFirstNodeRef()
|
||||
const promotedPrimitive = await subgraphNode!.getWidget(1)
|
||||
await expect
|
||||
.poll(
|
||||
() => promotedPrimitive.getValue(),
|
||||
'Primitive widget is not in a disconnected state'
|
||||
)
|
||||
.toBe(0)
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -14,6 +14,34 @@ import {
|
||||
const DUPLICATE_IDS_WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
|
||||
const LEGACY_PREFIXED_WORKFLOW =
|
||||
'subgraphs/nested-subgraph-legacy-prefixed-proxy-widgets'
|
||||
const LEGACY_THREE_TUPLE_WORKFLOW = 'subgraphs/nested-duplicate-widget-names'
|
||||
const MULTI_INSTANCE_WORKFLOW =
|
||||
'subgraphs/subgraph-multi-instance-promoted-text-values'
|
||||
|
||||
async function getPromotedHostWidgetValues(
|
||||
comfyPage: ComfyPage,
|
||||
nodeIds: string[]
|
||||
) {
|
||||
return comfyPage.page.evaluate((ids) => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
|
||||
return ids.map((id) => {
|
||||
const node = graph.getNodeById(id)
|
||||
if (
|
||||
!node ||
|
||||
typeof node.isSubgraphNode !== 'function' ||
|
||||
!node.isSubgraphNode()
|
||||
) {
|
||||
return { id, values: [] as unknown[] }
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
values: (node.widgets ?? []).map((widget) => widget.value)
|
||||
}
|
||||
})
|
||||
}, nodeIds)
|
||||
}
|
||||
|
||||
async function expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage: ComfyPage,
|
||||
@@ -498,4 +526,63 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Legacy 3-tuple proxyWidgets entries serialize back to 2-tuples after load',
|
||||
{ tag: '@vue-nodes' },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow(LEGACY_THREE_TUPLE_WORKFLOW)
|
||||
|
||||
const hostNode = comfyPage.vueNodes.getNodeLocator('4')
|
||||
await expect(hostNode).toBeVisible()
|
||||
|
||||
const promotedTextbox = hostNode.getByRole('textbox', {
|
||||
name: 'text',
|
||||
exact: true
|
||||
})
|
||||
await expect(promotedTextbox).toHaveCount(1)
|
||||
await expect(promotedTextbox).toHaveValue('22222222222')
|
||||
|
||||
await expect(hostNode.getByText('text', { exact: true })).toBeVisible()
|
||||
|
||||
const serializedProxyWidgets = await comfyPage.page.evaluate(() => {
|
||||
const serialized = window.app!.graph!.serialize()
|
||||
const hostNode = serialized.nodes.find((node) => node.id === 4)
|
||||
const proxyWidgets = hostNode?.properties?.proxyWidgets
|
||||
return Array.isArray(proxyWidgets) ? proxyWidgets : []
|
||||
})
|
||||
|
||||
expect(serializedProxyWidgets).toEqual([['3', '3: 2: text']])
|
||||
expect(
|
||||
serializedProxyWidgets.every(
|
||||
(entry) => Array.isArray(entry) && entry.length === 2
|
||||
)
|
||||
).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
test('Multiple instances of the same subgraph keep distinct promoted widget values after load and reload', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const hostNodeIds = ['11', '12', '13']
|
||||
const expectedValues = ['Alpha\n', 'Beta\n', 'Gamma\n']
|
||||
|
||||
await comfyPage.workflow.loadWorkflow(MULTI_INSTANCE_WORKFLOW)
|
||||
|
||||
const initialValues = await getPromotedHostWidgetValues(
|
||||
comfyPage,
|
||||
hostNodeIds
|
||||
)
|
||||
expect(initialValues.map(({ values }) => values[0])).toEqual(expectedValues)
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
const reloadedValues = await getPromotedHostWidgetValues(
|
||||
comfyPage,
|
||||
hostNodeIds
|
||||
)
|
||||
expect(reloadedValues.map(({ values }) => values[0])).toEqual(
|
||||
expectedValues
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -106,54 +106,6 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
})
|
||||
|
||||
test('dialog should not be shown when first-time user opens a shared workflow link', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.route(
|
||||
'**/workflows/published/test-share-id',
|
||||
async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
share_id: 'test-share-id',
|
||||
workflow_id: 'wf-1',
|
||||
name: 'Shared Workflow',
|
||||
listed: true,
|
||||
publish_time: new Date().toISOString(),
|
||||
workflow_json: {
|
||||
version: 0.4,
|
||||
nodes: [],
|
||||
links: [],
|
||||
groups: [],
|
||||
config: {},
|
||||
extra: {}
|
||||
},
|
||||
assets: []
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.TutorialCompleted', false)
|
||||
await comfyPage.page.goto(`${comfyPage.url}/api/users`)
|
||||
await comfyPage.page.evaluate((id) => {
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
localStorage.setItem('Comfy.userId', id)
|
||||
}, comfyPage.id)
|
||||
await comfyPage.page.goto(
|
||||
new URL('/?share=test-share-id', comfyPage.url).toString()
|
||||
)
|
||||
await comfyPage.waitForAppReady()
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.openSharedWorkflowTitle)
|
||||
).toBeVisible()
|
||||
|
||||
await expect(comfyPage.templates.content).toBeHidden()
|
||||
})
|
||||
|
||||
test('Uses proper locale files for templates', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Locale', 'fr')
|
||||
|
||||
@@ -179,51 +131,48 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
|
||||
test('Falls back to English templates when locale file not found', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Pick a shipped LTR locale and simulate its template index returning 404.
|
||||
// (Previously this test used 'de', but unsupported locales are now
|
||||
// clamped to 'en' at boot so they never hit the template fallback path.
|
||||
// 'fa' would also work but flips document.dir to rtl, which can leak
|
||||
// into adjacent specs in the same worker.)
|
||||
const locale = 'tr'
|
||||
// Set locale to a language that doesn't have a template file
|
||||
await comfyPage.settings.setSetting('Comfy.Locale', 'de') // German - no index.de.json exists
|
||||
|
||||
await comfyPage.page.route(
|
||||
`**/templates/index.${locale}.json`,
|
||||
async (route) => {
|
||||
await route.fulfill({
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
body: 'Not Found'
|
||||
})
|
||||
}
|
||||
// Wait for the German request (expected to 404)
|
||||
const germanRequestPromise = comfyPage.page.waitForRequest(
|
||||
'**/templates/index.de.json'
|
||||
)
|
||||
|
||||
await comfyPage.page.route('**/templates/index.json', (route) =>
|
||||
route.continue()
|
||||
)
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.Locale', locale)
|
||||
|
||||
const localeRequestPromise = comfyPage.page.waitForRequest(
|
||||
`**/templates/index.${locale}.json`
|
||||
)
|
||||
// Wait for the fallback English request
|
||||
const englishRequestPromise = comfyPage.page.waitForRequest(
|
||||
'**/templates/index.json'
|
||||
)
|
||||
|
||||
// Intercept the German file to simulate a 404
|
||||
await comfyPage.page.route('**/templates/index.de.json', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
body: 'Not Found'
|
||||
})
|
||||
})
|
||||
|
||||
// Allow the English index to load normally
|
||||
await comfyPage.page.route('**/templates/index.json', (route) =>
|
||||
route.continue()
|
||||
)
|
||||
|
||||
// Load the templates dialog
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
|
||||
const localeRequest = await localeRequestPromise
|
||||
// Verify German was requested first, then English as fallback
|
||||
const germanRequest = await germanRequestPromise
|
||||
const englishRequest = await englishRequestPromise
|
||||
|
||||
expect(localeRequest.url()).toContain(`templates/index.${locale}.json`)
|
||||
expect(germanRequest.url()).toContain('templates/index.de.json')
|
||||
expect(englishRequest.url()).toContain('templates/index.json')
|
||||
|
||||
// Assert on rendered content, not just the container — the container
|
||||
// testid is present even when the dialog body is empty, which would let
|
||||
// a regression where the fallback fetch succeeds but no cards render
|
||||
// pass silently.
|
||||
await expect(comfyPage.templates.allTemplateCards.first()).toBeVisible()
|
||||
// Verify English titles are shown as fallback
|
||||
await expect(
|
||||
comfyPage.page.getByRole('main').getByText('All Templates')
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('template cards are dynamically sized and responsive', async ({
|
||||
|
||||
@@ -75,24 +75,6 @@ test.describe('Vue Node Context Menu', { tag: '@vue-nodes' }, () => {
|
||||
await expect(renamedNode).toBeVisible()
|
||||
})
|
||||
|
||||
test('should open node info in the right side panel via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.RightSidePanel.IsOpen', false)
|
||||
await expect(comfyPage.menu.propertiesPanel.root).toBeHidden()
|
||||
|
||||
await openContextMenu(comfyPage, 'KSampler')
|
||||
await clickExactMenuItem(comfyPage, 'Node Info')
|
||||
|
||||
const panel = comfyPage.menu.propertiesPanel.root
|
||||
await expect(panel).toBeVisible()
|
||||
await expect(panel.getByTestId('panel-tab-info')).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true'
|
||||
)
|
||||
await expect(comfyPage.menu.nodeLibraryTab.selectedTabButton).toBeHidden()
|
||||
})
|
||||
|
||||
test('should copy and paste node via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
@@ -41,19 +39,6 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
|
||||
expect(Math.abs(a.y - b.y)).toBeLessThanOrEqual(tol)
|
||||
}
|
||||
|
||||
const dragFromTabButton = async (comfyPage: ComfyPage, button: Locator) => {
|
||||
const box = await button.boundingBox()
|
||||
if (!box) throw new Error('Tab button has no bounding box')
|
||||
const start = {
|
||||
x: box.x + box.width / 2,
|
||||
y: box.y + box.height * 0.75
|
||||
}
|
||||
await comfyPage.canvasOps.dragAndDrop(start, {
|
||||
x: start.x + 120,
|
||||
y: start.y + 80
|
||||
})
|
||||
}
|
||||
|
||||
test('should allow moving nodes by dragging', async ({ comfyPage }) => {
|
||||
const loadCheckpointHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
await comfyPage.canvasOps.dragAndDrop(loadCheckpointHeaderPos, {
|
||||
@@ -105,63 +90,6 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
|
||||
await expectPosChanged(headerPos, afterPos)
|
||||
})
|
||||
|
||||
test('should not toggle advanced inputs when dragging by the Advanced button', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Node.AlwaysShowAdvancedWidgets',
|
||||
false
|
||||
)
|
||||
await comfyPage.nodeOps.addNode(
|
||||
'ModelSamplingFlux',
|
||||
{},
|
||||
{
|
||||
x: 500,
|
||||
y: 200
|
||||
}
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeByTitle('ModelSamplingFlux')
|
||||
const showButton = node.getByText('Show advanced inputs')
|
||||
const widgets = node.locator('.lg-node-widget')
|
||||
|
||||
await expect(showButton).toBeVisible()
|
||||
await expect(widgets).toHaveCount(2)
|
||||
|
||||
const beforePos = await node.boundingBox()
|
||||
if (!beforePos) throw new Error('Node has no bounding box')
|
||||
|
||||
await dragFromTabButton(comfyPage, showButton)
|
||||
|
||||
await expect(showButton).toBeVisible()
|
||||
await expect(node.getByText('Hide advanced inputs')).toBeHidden()
|
||||
await expect(widgets).toHaveCount(2)
|
||||
|
||||
const afterPos = await node.boundingBox()
|
||||
if (!afterPos) throw new Error('Node missing after drag')
|
||||
await expectPosChanged(beforePos, afterPos)
|
||||
})
|
||||
|
||||
test('should not enter subgraph when dragging by the Enter Subgraph button', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
const beforePos = await subgraphNode.getPosition()
|
||||
|
||||
await dragFromTabButton(
|
||||
comfyPage,
|
||||
comfyPage.vueNodes.getSubgraphEnterButton('2')
|
||||
)
|
||||
|
||||
expect(await comfyPage.subgraph.isInSubgraph()).toBe(false)
|
||||
|
||||
const afterPos = await subgraphNode.getPosition()
|
||||
await expectPosChanged(beforePos, afterPos)
|
||||
})
|
||||
|
||||
test('should move all selected nodes together when dragging one with Meta held', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
cleanupFakeModel,
|
||||
dismissErrorOverlay,
|
||||
@@ -14,9 +13,7 @@ import {
|
||||
ExecutionHelper,
|
||||
buildKSamplerError
|
||||
} from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||
import type { NodeError } from '@/schemas/apiSchema'
|
||||
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
|
||||
import { assetPath } from '@e2e/fixtures/utils/paths'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, webSocketFixture)
|
||||
@@ -25,61 +22,6 @@ const ERROR_CLASS = /ring-destructive-background/
|
||||
const UNKNOWN_NODE_ID = '1'
|
||||
const INNER_EXECUTION_ID = '2:1'
|
||||
const KSAMPLER_MODEL_INPUT_NAME = 'model'
|
||||
const LOAD_IMAGE_INPUT_NAME = 'image'
|
||||
const LOAD_IMAGE_UPLOAD_FILE = 'test_upload_image.png'
|
||||
|
||||
function buildLoadImageRequiredInputError(): NodeError {
|
||||
return {
|
||||
class_type: 'LoadImage',
|
||||
dependent_outputs: [],
|
||||
errors: [
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: `Required input is missing: ${LOAD_IMAGE_INPUT_NAME}`,
|
||||
details: '',
|
||||
extra_info: { input_name: LOAD_IMAGE_INPUT_NAME }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
async function surfaceLoadImageMissingInputError(
|
||||
comfyPage: ComfyPage,
|
||||
loadImageId: string
|
||||
): Promise<void> {
|
||||
const exec = new ExecutionHelper(comfyPage)
|
||||
await exec.mockValidationFailure({
|
||||
[loadImageId]: buildLoadImageRequiredInputError()
|
||||
})
|
||||
await comfyPage.runButton.click()
|
||||
await dismissErrorOverlay(comfyPage)
|
||||
}
|
||||
|
||||
async function selectLoadImageNodeForPaste(
|
||||
comfyPage: ComfyPage,
|
||||
loadImageId: string
|
||||
): Promise<void> {
|
||||
await comfyPage.page.evaluate((nodeId) => {
|
||||
const node = window.app!.graph.getNodeById(Number(nodeId))
|
||||
if (!node) throw new Error(`Load Image node ${nodeId} not found`)
|
||||
window.app!.canvas.selectNode(node)
|
||||
window.app!.canvas.current_node = node
|
||||
}, loadImageId)
|
||||
}
|
||||
|
||||
async function setupLoadImageErrorScenario(comfyPage: ComfyPage) {
|
||||
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
|
||||
const loadImageNode = (
|
||||
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
|
||||
)[0]
|
||||
const loadImageId = String(loadImageNode.id)
|
||||
|
||||
return {
|
||||
loadImageId,
|
||||
innerWrapper: comfyPage.vueNodes.getNodeInnerWrapper(loadImageId),
|
||||
imageWidget: await loadImageNode.getWidgetByName(LOAD_IMAGE_INPUT_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
|
||||
test('should display error state when node is missing (node from workflow is not installed)', async ({
|
||||
@@ -249,74 +191,6 @@ test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
|
||||
|
||||
await expect(innerWrapper).not.toHaveClass(ERROR_CLASS)
|
||||
})
|
||||
|
||||
test('clears error ring when user drops an image file onto Load Image', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { loadImageId, innerWrapper, imageWidget } =
|
||||
await setupLoadImageErrorScenario(comfyPage)
|
||||
|
||||
await test.step('queue with missing image input to surface the error', async () => {
|
||||
await surfaceLoadImageMissingInputError(comfyPage, loadImageId)
|
||||
await expect(innerWrapper).toHaveClass(ERROR_CLASS)
|
||||
})
|
||||
|
||||
await test.step('drop an image onto the Load Image node', async () => {
|
||||
const dropPosition =
|
||||
await comfyPage.canvasOps.getNodeCenterByTitle('Load Image')
|
||||
if (!dropPosition) {
|
||||
throw new Error('Load Image node center must be available for drop')
|
||||
}
|
||||
|
||||
await comfyPage.dragDrop.dragAndDropFile(LOAD_IMAGE_UPLOAD_FILE, {
|
||||
dropPosition,
|
||||
waitForUpload: true
|
||||
})
|
||||
await expect
|
||||
.poll(() => imageWidget.getValue())
|
||||
.toContain(LOAD_IMAGE_UPLOAD_FILE)
|
||||
})
|
||||
|
||||
await expect(innerWrapper).not.toHaveClass(ERROR_CLASS)
|
||||
})
|
||||
|
||||
test('clears error ring when user pastes an image file onto Load Image', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { loadImageId, innerWrapper, imageWidget } =
|
||||
await setupLoadImageErrorScenario(comfyPage)
|
||||
|
||||
await test.step('queue with missing image input to surface the error', async () => {
|
||||
await surfaceLoadImageMissingInputError(comfyPage, loadImageId)
|
||||
await expect(innerWrapper).toHaveClass(ERROR_CLASS)
|
||||
})
|
||||
|
||||
await test.step('paste an image while Load Image is selected', async () => {
|
||||
await comfyPage.canvas.focus()
|
||||
await selectLoadImageNodeForPaste(comfyPage, loadImageId)
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => window.app!.canvas.current_node?.type)
|
||||
)
|
||||
.toBe('LoadImage')
|
||||
|
||||
const uploadResponse = comfyPage.page.waitForResponse(
|
||||
(resp) => resp.url().includes('/upload/') && resp.status() === 200,
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
// File clipboard contents cannot be seeded reliably in Playwright;
|
||||
// use the direct document paste mode to exercise usePaste.
|
||||
await comfyPage.clipboard.pasteFile(assetPath(LOAD_IMAGE_UPLOAD_FILE), {
|
||||
mode: 'direct'
|
||||
})
|
||||
await uploadResponse
|
||||
await expect
|
||||
.poll(() => imageWidget.getValue())
|
||||
.toContain(LOAD_IMAGE_UPLOAD_FILE)
|
||||
})
|
||||
|
||||
await expect(innerWrapper).not.toHaveClass(ERROR_CLASS)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('subgraph propagation', { tag: '@subgraph' }, () => {
|
||||
|
||||
@@ -239,18 +239,19 @@ The design goal is to preserve ECS modularity while keeping render throughput wi
|
||||
|
||||
Companion architecture documents that expand on the design in this ADR:
|
||||
|
||||
| Document | Description |
|
||||
| ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------- |
|
||||
| [Entity Interactions](../architecture/entity-interactions.md) | Maps all current entity relationships and interaction patterns — the ECS migration baseline |
|
||||
| [Entity System Structural Problems](../architecture/entity-problems.md) | Detailed problem catalog with line-level code references motivating the ECS migration |
|
||||
| [Proto-ECS Stores](../architecture/proto-ecs-stores.md) | Inventory of existing Pinia stores that already partially implement ECS patterns |
|
||||
| [ECS Target Architecture](../architecture/ecs-target-architecture.md) | Full target architecture showing how entities and interactions transform under ECS |
|
||||
| [ECS Migration Plan](../architecture/ecs-migration-plan.md) | Phased migration roadmap with shipping milestones and go/no-go criteria |
|
||||
| [ECS Lifecycle Scenarios](../architecture/ecs-lifecycle-scenarios.md) | Before/after walkthroughs of lifecycle operations (node removal, link creation, etc.) |
|
||||
| [World API and Command Layer](../architecture/ecs-world-command-api.md) | How each lifecycle scenario maps to a command in the World API |
|
||||
| [Subgraph Boundaries and Widget Promotion](../architecture/subgraph-boundaries-and-promotion.md) | Design rationale for modeling subgraphs as node components, not separate entities |
|
||||
| [Appendix: Critical Analysis](../architecture/appendix-critical-analysis.md) | Independent verification of the accuracy of the architecture documents |
|
||||
| [Change Tracker](../architecture/change-tracker.md) | Documents the current undo/redo system that ECS cross-cutting concerns will replace |
|
||||
| Document | Description |
|
||||
| ------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------- |
|
||||
| [Entity Interactions](../architecture/entity-interactions.md) | Maps all current entity relationships and interaction patterns — the ECS migration baseline |
|
||||
| [Entity System Structural Problems](../architecture/entity-problems.md) | Detailed problem catalog with line-level code references motivating the ECS migration |
|
||||
| [Proto-ECS Stores](../architecture/proto-ecs-stores.md) | Inventory of existing Pinia stores that already partially implement ECS patterns |
|
||||
| [ECS Target Architecture](../architecture/ecs-target-architecture.md) | Full target architecture showing how entities and interactions transform under ECS |
|
||||
| [ECS Migration Plan](../architecture/ecs-migration-plan.md) | Phased migration roadmap with shipping milestones and go/no-go criteria |
|
||||
| [ECS Lifecycle Scenarios](../architecture/ecs-lifecycle-scenarios.md) | Before/after walkthroughs of lifecycle operations (node removal, link creation, etc.) |
|
||||
| [World API and Command Layer](../architecture/ecs-world-command-api.md) | How each lifecycle scenario maps to a command in the World API |
|
||||
| [Subgraph Boundaries and Widget Promotion](../architecture/subgraph-boundaries-and-promotion.md) | Design rationale for modeling subgraphs as node components, not separate entities |
|
||||
| [Appendix: Critical Analysis](../architecture/appendix-critical-analysis.md) | Independent verification of the accuracy of the architecture documents |
|
||||
| [Appendix: ECS Pattern Survey](../architecture/appendix-ecs-pattern-survey.md) | Survey of bitECS, miniplex, koota, ECSY, and Bevy — patterns adopted, departed, when to revisit |
|
||||
| [Change Tracker](../architecture/change-tracker.md) | Documents the current undo/redo system that ECS cross-cutting concerns will replace |
|
||||
|
||||
## Notes
|
||||
|
||||
|
||||
279
docs/architecture/appendix-ecs-pattern-survey.md
Normal file
@@ -0,0 +1,279 @@
|
||||
# Appendix: ECS Pattern Survey
|
||||
|
||||
_A survey of mainstream Entity Component System libraries — bitECS, miniplex,
|
||||
koota, ECSY, and Bevy — captured during the world-consolidation analysis that
|
||||
shipped slice 1 of [ADR 0008](../adr/0008-entity-component-system.md). This
|
||||
appendix records which structural patterns our `src/world/` substrate adopts,
|
||||
which it deliberately departs from, and where the trade-offs are load-bearing
|
||||
rather than incidental._
|
||||
|
||||
The in-code anchors for the load-bearing constraints discussed below are the
|
||||
doc-comments in [src/world/world.ts](../../src/world/world.ts) (storage
|
||||
strategy) and [src/world/entityIds.ts](../../src/world/entityIds.ts) (identity
|
||||
contract) — see §3 below.
|
||||
|
||||
---
|
||||
|
||||
## 1. Survey Comparison
|
||||
|
||||
Five libraries were sampled for structural patterns: where component
|
||||
definitions live relative to the substrate, how components are declared,
|
||||
how entities are identified, and roughly how large the substrate's public
|
||||
surface is. Sources: the linked READMEs and docs.
|
||||
|
||||
| Library | Component placement | Component definition style | Entity ID type | Approx. # core exports |
|
||||
| ------------------------------------------------- | ------------------------------------ | ----------------------------- | -------------------- | ----------------------: |
|
||||
| [bitECS](https://github.com/NateTheGreatt/bitECS) | Outside the substrate; user's choice | plain arrays / objects | `number` (unbranded) | ~12 |
|
||||
| [miniplex](https://github.com/hmans/miniplex) | Colocated with the `Entity` type | properties on a TS type | plain object ref | ~5 |
|
||||
| [koota](https://github.com/pmndrs/koota) | Colocated with the consumer | `trait({...})` factory | numeric `.id()` | ~15 (core) + ~8 (react) |
|
||||
| [ECSY](https://github.com/ecsyjs/ecsy) | User's choice | `class extends Component` | `Entity` object | ~10 |
|
||||
| [Bevy](https://bevyengine.org/) (Rust, for shape) | Plugin-owned (industry std) | `#[derive(Component)] struct` | `Entity(u64)` | n/a |
|
||||
|
||||
Two structural patterns are unanimous across the surveyed libraries:
|
||||
|
||||
1. **Component definitions live with the code that owns the data**, not
|
||||
inside the substrate package. Whether by explicit recommendation
|
||||
(Bevy plugins, koota's colocation guidance) or by default (bitECS,
|
||||
miniplex), no surveyed substrate ships pre-defined component types.
|
||||
2. **Substrate surface area is small** — bitECS at ~12 exports, koota at
|
||||
~15, miniplex at ~5. ECSY is the outlier with a wider class hierarchy.
|
||||
|
||||
Our slice-1 end state — five source files under
|
||||
[src/world/](../../src/world/), ~14 exported names total — sits squarely in
|
||||
this band.
|
||||
|
||||
---
|
||||
|
||||
## 2. Patterns We Adopt
|
||||
|
||||
### 2.1 Substrate is deep; components live in domain code
|
||||
|
||||
The mainstream convention is that the ECS substrate exposes only the
|
||||
machinery — entities, component keys, a World — and component definitions
|
||||
live next to the system, store, or feature module that owns the data.
|
||||
This is the Bevy / miniplex / koota convention by design and the bitECS /
|
||||
ECSY convention by default.
|
||||
|
||||
Our substrate follows the same shape: `src/world/` contains entity-ID
|
||||
brands, the `ComponentKey` definition primitive, and the `World`
|
||||
interface, but no domain-specific component types. Slice 1 places
|
||||
`WidgetValueComponent` and `WidgetContainerComponent` in
|
||||
[src/stores/widgetComponents.ts](../../src/stores/widgetComponents.ts),
|
||||
next to [widgetValueStore.ts](../../src/stores/widgetValueStore.ts) — the
|
||||
module that already owns widget value state.
|
||||
|
||||
This keeps the substrate / domain seam crisp: the World knows how to store
|
||||
and look up arbitrary components keyed by entity ID; the domain layer
|
||||
knows what a "widget value" is. It also aligns with the AGENTS.md DDD
|
||||
guidance to group code by bounded context. Future components follow the
|
||||
same rule — `PositionComponent`, when it lands, will live with the layout
|
||||
domain rather than inside the substrate.
|
||||
|
||||
### 2.2 Small public API
|
||||
|
||||
The substrate exports ~14 names — comparable to bitECS (~12) and koota
|
||||
(~15), much smaller than ECSY's class hierarchy. This is a deliberate
|
||||
target: every exported name is a contract a contributor must understand
|
||||
before extending the World, and every export is a potential migration
|
||||
cost when the substrate evolves.
|
||||
|
||||
The `Brand` / `EntityId` / `ComponentKey` / `World` / `worldInstance`
|
||||
split keeps each module single-purpose. `Brand<T,Tag>` is 5
|
||||
LOC and shared across all branded ID kinds. `ComponentKey<TData,TEntity>`
|
||||
carries a two-parameter phantom that enables cross-kind compile-time
|
||||
checking. `asGraphId` is a single named boundary cast. The two explicit
|
||||
factories `nodeEntityId` / `widgetEntityId` are kept rather than collapsed
|
||||
into a parameterized helper because slice 2/3/4 will add factories with
|
||||
different parameter tuples (`rerouteEntityId`, `linkEntityId`,
|
||||
`slotEntityId`); the explicit-factory pattern scales linearly with new
|
||||
entity kinds without growing the helper's signature.
|
||||
|
||||
### 2.3 Reactive bridging via existing storage proxy
|
||||
|
||||
bitECS, koota, and miniplex bolt on a separate `onChange` event bus when
|
||||
a consumer wants reactive notifications. koota's React layer
|
||||
(`useTrait(entity, ComponentKey)`) is the closest analog to what
|
||||
`useUpstreamValue` and future composables want.
|
||||
|
||||
Because our World stores values inside Vue's `reactive(Map<EntityId, ...>)`,
|
||||
a plain `computed(() => world.getComponent(id, key))` already provides
|
||||
fine-grained per-`(entity, component)` tracking — no separate event bus
|
||||
is needed. **This is a real Vue-specific advantage.** The Vue tracker and
|
||||
the ECS storage are the same mechanism, so reactivity falls out of the
|
||||
storage choice rather than being layered on top.
|
||||
|
||||
### 2.4 Brand-typed entity IDs
|
||||
|
||||
No surveyed TypeScript ECS uses branded IDs. bitECS uses unbranded
|
||||
`number`, miniplex uses plain object references, koota uses a numeric
|
||||
`.id()`. Our `Brand<T, Tag>` over each entity kind enables the
|
||||
type-level cross-kind isolation assertion in
|
||||
[world.test.ts](../../src/world/world.test.ts) and documents slice-2/3/4
|
||||
entity kinds at compile time.
|
||||
|
||||
This is a deliberate departure rather than an accident. It earns its keep
|
||||
once `Position` lands on `NodeEntityId | RerouteEntityId` (slice 2) and
|
||||
`Connectivity` lands on `SlotEntityId` (slice 4); without brands, those
|
||||
component-key declarations would accept any numeric ID and silently allow
|
||||
cross-kind misuse.
|
||||
|
||||
---
|
||||
|
||||
## 3. Patterns We Explicitly Do NOT Adopt
|
||||
|
||||
Each of the following is a real industry idiom we considered and rejected
|
||||
on load-bearing grounds. None of these are pure performance trade-offs.
|
||||
|
||||
### 3.1 Replace-on-write usage idioms
|
||||
|
||||
koota's `entity.set(Position, {...})` and miniplex's `world.add(entity)`
|
||||
**replace** component values with new objects on each write. Adopting
|
||||
either would break
|
||||
[BaseWidget.\_state](../../src/lib/litegraph/src/widgets/BaseWidget.ts)
|
||||
shared reactive identity — the contract that lets DOM widget overrides,
|
||||
`useProcessedWidgets` memoization, and the 40+ extension ecosystem all
|
||||
read the same proxy. Our `setComponent(id, key, ref)` stores by reference
|
||||
and the inner `reactive(Map)` keeps a stable cached proxy per
|
||||
entity-component pair: every `getComponent` returns the same proxy,
|
||||
regardless of how many writes intervene. `widgetValueStore.registerWidget`
|
||||
returns that proxy (not the caller's input ref), so `BaseWidget._state`
|
||||
and every other reader observe the same object. Replace-on-write idioms
|
||||
would swap the cached proxy on each write and break that stability —
|
||||
the reactive-identity test in
|
||||
[widgetValueStore.test.ts](../../src/stores/widgetValueStore.test.ts)
|
||||
locks in the contract.
|
||||
|
||||
### 3.2 SoA / archetype storage
|
||||
|
||||
bitECS, koota, and miniplex use sparse-set / archetype storage internally
|
||||
for cache locality. Our `reactive(Map<EntityId, unknown>)` is closer to
|
||||
ECSY's AoS — slower iteration but **integrates natively with Vue's
|
||||
tracking**.
|
||||
|
||||
The surface trade-off is performance; the deeper trade-off is identity.
|
||||
SoA storage spreads each component's fields across parallel typed arrays,
|
||||
so the per-entity "row object" is reconstructed on read. **A future
|
||||
migration to SoA would lose the proxy on the row object** — and with it
|
||||
the shared-reactive-identity contract that `BaseWidget._state` and the
|
||||
`widgetValueStore` facade rely on. This is a load-bearing constraint, not
|
||||
just a perf optimization decision.
|
||||
|
||||
The contract is pinned in the doc-comment at the top of
|
||||
[src/world/world.ts](../../src/world/world.ts) — copied here for
|
||||
proximity:
|
||||
|
||||
```ts
|
||||
/**
|
||||
* `setComponent` stores values by reference (no clone). The inner
|
||||
* `reactive(Map)` produces a single cached Vue proxy per entity-component
|
||||
* pair: every `getComponent` call returns the same proxy, and mutations
|
||||
* through it propagate to all readers. Note that the proxy is NOT `===`
|
||||
* to the raw object passed to `setComponent` — read through `getComponent`
|
||||
* (or a `registerWidget`-style helper that does so internally) and treat
|
||||
* that proxy as canonical.
|
||||
*
|
||||
* `BaseWidget._state` and `widgetValueStore` rely on this stable-proxy
|
||||
* invariant. Replace-on-write idioms (koota's `entity.set(...)`,
|
||||
* miniplex's `world.add(entity)`) would swap the cached proxy on each
|
||||
* write and break the contract; revisiting either consumer is required
|
||||
* before changing storage semantics.
|
||||
*/
|
||||
```
|
||||
|
||||
### 3.3 Auto-generated opaque entity IDs
|
||||
|
||||
bitECS and koota assume IDs are opaque numbers — `lastId++`, with no
|
||||
external structure. miniplex uses plain object references with the same
|
||||
property.
|
||||
|
||||
Our `widgetEntityId(rootGraphId, nodeId, name)` is **deterministic and
|
||||
content-addressed**. Consumers consistently pass `rootGraph.id`, so a
|
||||
widget viewed at different subgraph depths shares identity with itself.
|
||||
Migrating to opaque numeric IDs would break cross-subgraph value sharing —
|
||||
the same widget at depth 0 and depth 2 would receive different IDs and
|
||||
diverge.
|
||||
|
||||
The contract is pinned in the doc-comment at the top of
|
||||
[src/world/entityIds.ts](../../src/world/entityIds.ts):
|
||||
|
||||
```ts
|
||||
/**
|
||||
* Entity IDs are deterministic, content-addressed, and string-prefix
|
||||
* encoded — NOT opaque numeric IDs (cf. bitECS, koota, miniplex).
|
||||
*
|
||||
* `widgetEntityId(rootGraphId, nodeId, name)` is load-bearing:
|
||||
* consumers consistently pass `rootGraph.id` so widgets viewed at
|
||||
* different subgraph depths share identity. Migrating to numeric IDs
|
||||
* would break cross-subgraph value sharing. See ADR 0008 and
|
||||
* widgetValueStore for the canonical keying contract.
|
||||
*/
|
||||
```
|
||||
|
||||
### 3.4 Substrate-side parent/child relations
|
||||
|
||||
Bevy ships `Parent` / `Children` components at the substrate layer; Flecs
|
||||
ships first-class relations. These are useful when many subsystems need
|
||||
hierarchical traversal at storage-near speeds.
|
||||
|
||||
We treat hierarchical traversal as a domain-layer concern instead. The
|
||||
only structural relation slice 1 needs is `node → widgets` forward
|
||||
lookup, expressed as a domain component (`WidgetContainer.widgetIds` in
|
||||
[src/stores/widgetComponents.ts](../../src/stores/widgetComponents.ts))
|
||||
and surfaced through `getNodeWidgets()` on the
|
||||
[widget value store](../../src/stores/widgetValueStore.ts). Reverse
|
||||
`widget → node` lookup is not modeled in the World at all today —
|
||||
existing call sites already hold a widget object and read `widget.node`
|
||||
directly via the `BaseWidget` back-reference, so no substrate-side
|
||||
parent component earns its keep yet. We may revisit this if multiple
|
||||
slices need a shared traversal API; until then, keeping hierarchy
|
||||
domain-local preserves the substrate's "no domain knowledge" property.
|
||||
|
||||
---
|
||||
|
||||
## 4. When to Revisit
|
||||
|
||||
The choices in §3 are deliberate but not eternal. Each has a revisit
|
||||
threshold.
|
||||
|
||||
**SoA / archetype storage.** The break-even point against `reactive(Map)`
|
||||
iteration is roughly **>10k entities per component** in steady-state hot
|
||||
paths. ComfyUI's projected widget count through slice 4 stays well under
|
||||
that. The watch signal is whether a render-loop or solver-loop pass
|
||||
demonstrably dominates frame time on `entitiesWith(WidgetValueComponent)`
|
||||
or any successor query — not just micro-benchmarks of `Map.get`.
|
||||
|
||||
If we cross that threshold, the migration is non-trivial: SoA loses the
|
||||
proxy on the row object (see §3.2), so a SoA World must either
|
||||
reconstruct proxies on read (defeating the perf gain) or move
|
||||
shared-identity reads back to a domain-side cache. ADR 0008's
|
||||
"Render-Loop Performance Implications and Mitigations" section already
|
||||
enumerates the planned mitigations (frame-stable query caches, archetype
|
||||
buckets, profiling-gated storage upgrades behind the World API).
|
||||
|
||||
**Replace-on-write idioms.** Revisitable only if the 40+ extension
|
||||
ecosystem moves off `BaseWidget._state` shared identity entirely — a
|
||||
separate, larger slice with explicit cost analysis (re-entry, DOM widget
|
||||
options.getValue overrides, `linkedWidgets` fan-out,
|
||||
`useProcessedWidgets` memoization invalidation), out of scope for the
|
||||
current ADR 0008 implementation.
|
||||
|
||||
**Opaque entity IDs.** Revisitable only if the cross-subgraph identity
|
||||
contract is dropped. Today widget value sharing across subgraph depths
|
||||
depends on it; slice 2 may extend the same contract to `nodeEntityId`
|
||||
for spatial reads. Until the product requirement changes, opaque IDs
|
||||
would be a regression.
|
||||
|
||||
**Substrate-side parent/child relations.** Revisitable when ≥2 subsystems
|
||||
need parent traversal. At one consumer it stays domain-local.
|
||||
|
||||
---
|
||||
|
||||
## 5. Cross-References
|
||||
|
||||
- [ADR 0008 — Entity Component System](../adr/0008-entity-component-system.md)
|
||||
for the full target taxonomy and migration strategy.
|
||||
- [ECS Target Architecture](./ecs-target-architecture.md) for the full
|
||||
end-state shape.
|
||||
- [ECS Migration Plan](./ecs-migration-plan.md) for shipping milestones.
|
||||
- [Appendix: Critical Analysis](./appendix-critical-analysis.md) for the
|
||||
independent verification of the architecture documents.
|
||||
637
docs/architecture/entity-id-strategy.md
Normal file
@@ -0,0 +1,637 @@
|
||||
# Entity ID Strategy: Opaque IDs and Normalized Identity Components
|
||||
|
||||
_A design proposal for migrating all six entity kinds in the ECS taxonomy
|
||||
of [ADR 0008](../adr/0008-entity-component-system.md) — Node, Link, Widget,
|
||||
Slot, Reroute, Group — from primitive or content-addressed identifiers to
|
||||
**opaque UUIDs**, with identity data normalized into per-entity components
|
||||
and the necessary secondary indices owned by domain stores. This document
|
||||
is a follow-on to ADR 0008 and the [ECS Pattern Survey](appendix-ecs-pattern-survey.md).
|
||||
Status: proposed, pending sign-off before implementation._
|
||||
|
||||
The motivating defect is in
|
||||
[src/world/entityIds.ts](../../src/world/entityIds.ts) (the slice-1
|
||||
identity contract for widgets and node containers), but the design
|
||||
question generalizes: **identity should be a component, not encoded into
|
||||
the entity-ID string, and entity IDs should be opaque across all kinds.**
|
||||
This document defines that strategy uniformly so subsequent ECS migration
|
||||
slices (per the [migration plan](ecs-migration-plan.md)) extend the same
|
||||
pattern instead of recreating the choice for each kind.
|
||||
|
||||
---
|
||||
|
||||
## 1. Context
|
||||
|
||||
### 1.1 What ADR 0008 specifies
|
||||
|
||||
ADR 0008 §"Branded ID Design" specifies branded **numeric** IDs for all
|
||||
six entity kinds:
|
||||
|
||||
```ts
|
||||
type NodeEntityId = number & { readonly __brand: 'NodeEntityId' }
|
||||
type LinkEntityId = number & { readonly __brand: 'LinkEntityId' }
|
||||
type WidgetEntityId = number & { readonly __brand: 'WidgetEntityId' }
|
||||
type SlotEntityId = number & { readonly __brand: 'SlotEntityId' }
|
||||
type RerouteEntityId = number & { readonly __brand: 'RerouteEntityId' }
|
||||
type GroupEntityId = number & { readonly __brand: 'GroupEntityId' }
|
||||
type GraphId = string & { readonly __brand: 'GraphId' }
|
||||
```
|
||||
|
||||
The ADR notes that widgets and slots currently lack independent IDs and
|
||||
proposes to assign synthetic IDs at entity creation time via an
|
||||
auto-incrementing counter (matching the pattern used by `lastNodeId`,
|
||||
`lastLinkId` in `LGraphState`).
|
||||
|
||||
### 1.2 What the slice-1 implementation actually did
|
||||
|
||||
The first ECS slice ([src/world/](../../src/world/)) covers **widgets**
|
||||
and **node containers**, and it departed from ADR 0008's "numeric ID"
|
||||
contract in two ways:
|
||||
|
||||
1. IDs are **strings**, not numbers (`Brand<string, ...>`).
|
||||
2. IDs are **content-addressed** — `widgetEntityId(g, n, w)` is computed
|
||||
from `(graphId, nodeId, widgetName)`, not minted from a counter:
|
||||
|
||||
```
|
||||
node:${graphId}:${nodeId}
|
||||
widget:${graphId}:${nodeId}:${name}
|
||||
```
|
||||
|
||||
The departure was deliberate — content-addressing gives "an entity viewed
|
||||
at different subgraph depths shares state" for free — but the rationale
|
||||
was never recorded in an architecture doc, and it leaves the wire format
|
||||
unauthorized by ADR 0008.
|
||||
|
||||
### 1.3 Three problems with the slice-1 scheme
|
||||
|
||||
1. **Silent corruption when nodeId contains a colon.**
|
||||
`parseWidgetEntityId`'s regex `/^widget:([^:]+):([^:]+):(.*)$/` captures
|
||||
`nodeId` as `[^:]+`, so a string nodeId like `"sg:42"` produces
|
||||
`widget:g:sg:42:name`, which the regex mis-parses as `(g, sg, 42:name)`.
|
||||
`widgetValueStore.getNodeWidgetsByName` then keys the returned `Map` by
|
||||
the wrong name. The defect is latent today — verified: every production
|
||||
producer stringifies a bare local NodeId — but the type
|
||||
`NodeId = number | string` makes no runtime guarantee, and any future
|
||||
migration toward `NodeLocatorId` (e.g. `<subgraph-uuid>:<id>`) trips
|
||||
the bug.
|
||||
|
||||
2. **Identity is the address.** Renaming a widget mints a new entity
|
||||
(different name → different `widgetEntityId`); rewriting `nodeId`
|
||||
mints a new entity. There is no concept of "the widget formerly known
|
||||
as X" — per-aspect components attach to the address-derived ID and
|
||||
are abandoned at rename. For features that need stable identity
|
||||
across relabels (promoted widgets, subgraph reparenting, CRDT
|
||||
replication of label changes), this is a structural mismatch.
|
||||
|
||||
3. **Identity is recovered by parsing.** The regex is the only path by
|
||||
which `getNodeWidgetsByName` knows which widget has which name. The
|
||||
data has no representation in the World — it lives only in the string.
|
||||
Every consumer that needs the name must round-trip through the
|
||||
parser.
|
||||
|
||||
### 1.4 Why this generalizes
|
||||
|
||||
The same three problems apply, in principle, to every other entity kind
|
||||
that lacks an independent ID. **Slot** identity today is `(parent node,
|
||||
direction, index)`; if we encoded that as a colon-joined string, the same
|
||||
parser hazards reappear. **Link** identity is `LinkId = number` (already
|
||||
opaque), but its endpoints `(origin_id, origin_slot, target_id,
|
||||
target_slot)` are content-shaped — and any identity migration will face
|
||||
the same "do we encode this into the ID, or normalize it into a
|
||||
component?" choice. The design decision is the same across all six
|
||||
kinds; this document makes it once.
|
||||
|
||||
---
|
||||
|
||||
## 2. Decision
|
||||
|
||||
Adopt the **opaque-entity-id + identity-as-component** pattern for all
|
||||
six entity kinds. The pattern matches what mainstream ECS libraries
|
||||
converge on (Flecs `(ChildOf, parent)` pairs, Bevy `Parent`+`Children`,
|
||||
koota `relation()`, EnTT `relationship.parent`, miniplex
|
||||
`entity.livesOn`); see §5 for the survey citations.
|
||||
|
||||
### 2.1 Entity IDs are opaque UUIDs across all kinds
|
||||
|
||||
```ts
|
||||
// src/world/entityIds.ts (proposed)
|
||||
export type NodeEntityId = Brand<string, 'NodeEntityId'>
|
||||
export type LinkEntityId = Brand<string, 'LinkEntityId'>
|
||||
export type WidgetEntityId = Brand<string, 'WidgetEntityId'>
|
||||
export type SlotEntityId = Brand<string, 'SlotEntityId'>
|
||||
export type RerouteEntityId = Brand<string, 'RerouteEntityId'>
|
||||
export type GroupEntityId = Brand<string, 'GroupEntityId'>
|
||||
export type EntityId =
|
||||
| NodeEntityId
|
||||
| LinkEntityId
|
||||
| WidgetEntityId
|
||||
| SlotEntityId
|
||||
| RerouteEntityId
|
||||
| GroupEntityId
|
||||
|
||||
export const mintNodeId = (): NodeEntityId =>
|
||||
crypto.randomUUID() as NodeEntityId
|
||||
export const mintLinkId = (): LinkEntityId =>
|
||||
crypto.randomUUID() as LinkEntityId
|
||||
export const mintWidgetId = (): WidgetEntityId =>
|
||||
crypto.randomUUID() as WidgetEntityId
|
||||
export const mintSlotId = (): SlotEntityId =>
|
||||
crypto.randomUUID() as SlotEntityId
|
||||
export const mintRerouteId = (): RerouteEntityId =>
|
||||
crypto.randomUUID() as RerouteEntityId
|
||||
export const mintGroupId = (): GroupEntityId =>
|
||||
crypto.randomUUID() as GroupEntityId
|
||||
```
|
||||
|
||||
UUIDs (rather than numeric counters) for three reasons. First, no
|
||||
coordination required — `crypto.randomUUID` is universally available and
|
||||
collision-resistant without `LGraphState.lastWidgetId++` bookkeeping.
|
||||
Second, they're stable across CRDT replication (per ADR 0003) without an
|
||||
ID-mapping layer. Third, the `Brand<string, ...>` type machinery in
|
||||
[src/world/brand.ts](../../src/world/brand.ts) already phantom-types over
|
||||
`string`, so retaining string concrete types keeps zero friction with
|
||||
existing component declarations.
|
||||
|
||||
This **amends ADR 0008 §"Branded ID Design"**: the `& { readonly __brand: ... }`
|
||||
discipline is preserved, but the underlying type is `string` (UUID) for
|
||||
all kinds, not `number`.
|
||||
|
||||
**Deletions from current code:** `nodeEntityId`, `widgetEntityId`,
|
||||
`parseWidgetEntityId`, `graphNodePrefix`, `graphWidgetPrefix`,
|
||||
`isNodeIdForGraph`, `isWidgetIdForGraph`, the regex. The wire-format
|
||||
concept goes away.
|
||||
|
||||
**Litegraph-numeric IDs are preserved as data** — `LGraphState.lastNodeId`
|
||||
and friends continue to assign legacy numeric IDs that workflow JSON
|
||||
serialization depends on (per ADR 0008 §"The internal ECS model and the
|
||||
serialization format are deliberately separate concerns"). The legacy
|
||||
numeric ID becomes a field on the entity's identity component (§2.2),
|
||||
not the entity's identity itself.
|
||||
|
||||
### 2.2 Identity components per entity kind
|
||||
|
||||
Each entity kind gets a "BelongsTo"-style component carrying its
|
||||
structural relationships (parent pointers) and any legacy identifiers
|
||||
needed for serialization parity. This is the canonical "parent pointer
|
||||
on child" pattern; see §3.1 and §5.
|
||||
|
||||
#### Node
|
||||
|
||||
```ts
|
||||
export const NodeBelongsTo = defineComponentKey<
|
||||
{
|
||||
graphId: GraphId
|
||||
litegraphNodeId: NodeId // legacy `LGraphNode.id`, kept for serialization
|
||||
},
|
||||
NodeEntityId
|
||||
>('NodeBelongsTo')
|
||||
```
|
||||
|
||||
Nodes belong to a graph (their containing `LGraph` or `Subgraph`).
|
||||
`litegraphNodeId` preserves the local-to-graph numeric ID that
|
||||
`workflowSchema.json` round-tripping requires.
|
||||
|
||||
#### Link
|
||||
|
||||
```ts
|
||||
export const LinkBelongsTo = defineComponentKey<
|
||||
{
|
||||
graphId: GraphId
|
||||
litegraphLinkId: LinkId // legacy `LLink.id`
|
||||
},
|
||||
LinkEntityId
|
||||
>('LinkBelongsTo')
|
||||
|
||||
export const LinkEndpoints = defineComponentKey<
|
||||
{
|
||||
origin: SlotEntityId
|
||||
target: SlotEntityId
|
||||
type: ISlotType
|
||||
},
|
||||
LinkEntityId
|
||||
>('LinkEndpoints')
|
||||
```
|
||||
|
||||
`LinkEndpoints` (already proposed in ADR 0008 §"Component Decomposition")
|
||||
becomes the structural relationship; under the new scheme its fields are
|
||||
opaque `SlotEntityId`s, not `(node_id, slot_index)` tuples.
|
||||
|
||||
#### Widget
|
||||
|
||||
```ts
|
||||
export const WidgetBelongsTo = defineComponentKey<
|
||||
{
|
||||
graphId: GraphId
|
||||
nodeId: NodeEntityId // parent pointer
|
||||
name: string // identity within the parent
|
||||
},
|
||||
WidgetEntityId
|
||||
>('WidgetBelongsTo')
|
||||
```
|
||||
|
||||
The widget-on-node parent pointer is the bug-relevant case. `name`
|
||||
identifies the widget within its parent node and replaces the
|
||||
parser-recovered name in `widgetEntityId`'s wire format.
|
||||
|
||||
#### Slot
|
||||
|
||||
```ts
|
||||
export const SlotBelongsTo = defineComponentKey<
|
||||
{
|
||||
graphId: GraphId
|
||||
nodeId: NodeEntityId // parent pointer
|
||||
direction: 'input' | 'output'
|
||||
index: number // ordinal within the parent's input/output array
|
||||
name: string
|
||||
},
|
||||
SlotEntityId
|
||||
>('SlotBelongsTo')
|
||||
```
|
||||
|
||||
Slots are an extreme case of "identity is currently the address" —
|
||||
today's identity is literally `(node, direction, index)`, with the index
|
||||
shifting whenever a slot is inserted or removed. Under opaque UUIDs, slot
|
||||
identity persists across reorderings; the `index` field becomes a
|
||||
position in an ordered children list, not a name.
|
||||
|
||||
#### Reroute
|
||||
|
||||
```ts
|
||||
export const RerouteBelongsTo = defineComponentKey<
|
||||
{
|
||||
graphId: GraphId
|
||||
parentRerouteId: RerouteEntityId | null // optional reroute parent
|
||||
litegraphRerouteId: RerouteId // legacy `Reroute.id`
|
||||
},
|
||||
RerouteEntityId
|
||||
>('RerouteBelongsTo')
|
||||
```
|
||||
|
||||
Reroutes can chain (`Reroute.parentId`); the parent-pointer pattern
|
||||
applies recursively. Reroutes also reference the link they belong to via
|
||||
`RerouteLinks` (ADR 0008 §"Reroute"); under opaque IDs that becomes a
|
||||
list of `LinkEntityId`s.
|
||||
|
||||
#### Group
|
||||
|
||||
```ts
|
||||
export const GroupBelongsTo = defineComponentKey<
|
||||
{
|
||||
graphId: GraphId
|
||||
litegraphGroupId: number // legacy `LGraphGroup` numeric id
|
||||
},
|
||||
GroupEntityId
|
||||
>('GroupBelongsTo')
|
||||
|
||||
export const GroupChildren = defineComponentKey<
|
||||
{ entityIds: (NodeEntityId | RerouteEntityId)[] },
|
||||
GroupEntityId
|
||||
>('GroupChildren')
|
||||
```
|
||||
|
||||
Groups own a heterogeneous children list (nodes and reroutes within
|
||||
their bounds). `GroupChildren` is the denormalized cache (§2.3); each
|
||||
member also carries an optional `MemberOf` back-pointer if downward
|
||||
iteration is hot (it isn't currently).
|
||||
|
||||
### 2.3 Children lists on parents — denormalized caches
|
||||
|
||||
Where downward iteration is hot, the parent gets a denormalized
|
||||
children-list component. The existing
|
||||
[`WidgetComponentContainer`](../../src/world/widgets/widgetComponents.ts)
|
||||
on the node side is the canonical example; this generalizes to:
|
||||
|
||||
| Parent | Children component | Hot path justifying it |
|
||||
| ------ | ------------------------------------------------------------------------------------- | ---------------------------------- |
|
||||
| Node | `WidgetComponentContainer { widgetIds: [...] }` | Per-frame Vue-node rendering |
|
||||
| Node | `SlotChildren { inputs: [...], outputs: [...] }` | Connection drawing, hit-testing |
|
||||
| Graph | `GraphMembers { nodeIds: [...], linkIds: [...], rerouteIds: [...], groupIds: [...] }` | Top-level rendering, `clearGraph` |
|
||||
| Group | `GroupChildren { entityIds: [...] }` | Group-bounding-box render |
|
||||
| Link | `RerouteLinks { rerouteIds: [...] }` | Path rendering along reroute chain |
|
||||
|
||||
These are caches **derived from** the per-child `*BelongsTo` parent
|
||||
pointer. They exist for performance and are maintained by the same
|
||||
mutation API that owns the parent pointer (§2.4); callers outside that
|
||||
API never write to them directly. This is the Bevy
|
||||
`Parent`+`Children`-symmetric-management discipline.
|
||||
|
||||
### 2.4 Secondary indices live in domain stores
|
||||
|
||||
Under opaque UUIDs, the question "given some content-shaped key, find
|
||||
the entity" is no longer answered by computing a string. Each domain
|
||||
store keeps a private forward index whose key is built with
|
||||
`makeCompositeKey` (already in
|
||||
[src/utils/compositeKey.ts](../../src/utils/compositeKey.ts)) so the
|
||||
encoding is injective by construction:
|
||||
|
||||
| Store | Forward index | Key shape |
|
||||
| --------------------- | -------------------- | ------------------------------------------- |
|
||||
| `widgetValueStore` | `widgetByAddress` | `ckey([graphId, nodeId, widgetName])` |
|
||||
| `widgetValueStore` | `nodeByAddress` | `ckey([graphId, litegraphNodeId])` |
|
||||
| (slot store, future) | `slotByAddress` | `ckey([graphId, nodeId, direction, index])` |
|
||||
| (link store, future) | `linkByLitegraphId` | `ckey([graphId, litegraphLinkId])` |
|
||||
| (group store, future) | `groupByLitegraphId` | `ckey([graphId, litegraphGroupId])` |
|
||||
|
||||
Plus per-graph entity sets for O(set-size) bulk clear, replacing the
|
||||
slice-1 `isWidgetIdForGraph` startsWith-scan:
|
||||
|
||||
```ts
|
||||
const widgetsByGraph = new Map<GraphId, Set<WidgetEntityId>>()
|
||||
const nodesByGraph = new Map<GraphId, Set<NodeEntityId>>()
|
||||
// (and analogous per-kind sets in each store as kinds migrate)
|
||||
```
|
||||
|
||||
All indices are private and mutated only by the owning store's public
|
||||
methods (`registerWidget` / `clearGraph` / a future `unregisterWidget`).
|
||||
This is the centralized-mutation discipline that Bevy's hierarchy
|
||||
plugin enforces through `Commands` extension methods — and that the ECS
|
||||
literature unanimously identifies as the precondition for safely
|
||||
denormalizing hierarchy state.
|
||||
|
||||
### 2.5 Surface area summary
|
||||
|
||||
| Layer | Primitive | Role |
|
||||
| ----------------------------- | --------------------------------------------- | -------------------------------------- |
|
||||
| `entityIds.ts` | `mint{Node,Link,Widget,Slot,Reroute,Group}Id` | Opaque ID generation |
|
||||
| domain `*Components.ts` files | `*BelongsTo` components | Parent pointers (identity) |
|
||||
| domain `*Components.ts` files | `*Children` / `*Container` components | Denormalized caches |
|
||||
| domain stores | `*ByAddress` indices | Forward content-address lookup |
|
||||
| domain stores | `*ByGraph` indices | Per-graph bulk clear |
|
||||
| domain store public methods | `register*` / `clearGraph` / `unregister*` | Sole writers to indices and components |
|
||||
|
||||
---
|
||||
|
||||
## 3. Why this is the right shape
|
||||
|
||||
### 3.1 It's what the ECS literature converges on
|
||||
|
||||
Three independent surveys (Flecs/Bevy/EnTT, koota/miniplex, and
|
||||
normalization-tradeoff literature) agree on the same finding:
|
||||
**store the parent pointer on the child, add a denormalized children
|
||||
list on the parent only when downward iteration is hot, and centralize
|
||||
all mutations behind a small API**. This document proposes exactly that
|
||||
pattern — uniformly across all six entity kinds.
|
||||
|
||||
### 3.2 It removes parsers as an attack surface — for every kind
|
||||
|
||||
`parseWidgetEntityId` does not exist in the new scheme. The temptation
|
||||
to write a `parseSlotEntityId` (which would face the same hazards on
|
||||
slot names) is removed before it appears. Identity is read structurally
|
||||
from components.
|
||||
|
||||
### 3.3 It decouples address from identity
|
||||
|
||||
Today, `widget(g, n, "old-name")` and `widget(g, n, "new-name")` are
|
||||
different entities. Slot reordering today changes slot identity
|
||||
(because identity = ordinal index). Reparenting today rotates entity IDs
|
||||
across graphs. Under opaque UUIDs none of these mutations rotates
|
||||
identity — they're component edits. This unlocks features that need
|
||||
identity continuity (promoted-widget identity preservation,
|
||||
slot-reorder-without-reconnection, label-swapping in CRDT replication)
|
||||
without touching the substrate.
|
||||
|
||||
### 3.4 It aligns with ADRs 0003 and 0008
|
||||
|
||||
ADR 0003's command pattern requires that all mutations be serializable
|
||||
and replayable. Opaque UUIDs are easier to serialize coherently — they
|
||||
don't carry a graphId-prefix that needs rewriting on subgraph remount,
|
||||
and they don't depend on the serialization format encoding identity
|
||||
tuples identically across versions. Component-resident identity makes
|
||||
command shapes ("set `WidgetBelongsTo.name` on `<uuid>`") trivially
|
||||
expressible.
|
||||
|
||||
ADR 0008's "world is the source of truth, serialization is a
|
||||
translation" principle is reinforced: the World holds opaque UUIDs and
|
||||
identity components; the SerializationSystem maps them to/from the
|
||||
`workflowSchema.json` format that uses litegraph-numeric IDs.
|
||||
|
||||
### 3.5 It absorbs the colon-collision fix as a side effect
|
||||
|
||||
The bug that motivates this document is solved twice over by the design:
|
||||
the parser is gone, AND the address index uses `makeCompositeKey`
|
||||
(already injective). No assert, no regex, no caveat.
|
||||
|
||||
---
|
||||
|
||||
## 4. Costs and risks
|
||||
|
||||
### 4.1 Index hygiene — across more stores
|
||||
|
||||
For each migrated kind, the per-store invariant is the same: indices
|
||||
must stay in lockstep with the World. The discipline that makes this
|
||||
safe is API-confinement: **all mutation goes through the owning store's
|
||||
public methods**. Direct `world.setComponent` calls bypass the indices
|
||||
and corrupt them.
|
||||
|
||||
Mitigations (apply per-store):
|
||||
|
||||
- Document the contract in the file's top doc-comment.
|
||||
- Add a unit test that asserts the indices match the World contents
|
||||
after a randomized sequence of register/clear operations.
|
||||
- Optionally, add a debug-build-only `world.assertConsistentIndices()`
|
||||
check.
|
||||
|
||||
### 4.2 Debuggability of opaque IDs
|
||||
|
||||
UUIDs in stack traces require reading the corresponding `*BelongsTo`
|
||||
component to recover the human-meaningful identity. This applies to
|
||||
every kind, not just widgets.
|
||||
|
||||
Mitigations:
|
||||
|
||||
- Provide a `describeEntity(id)` helper per store that returns the
|
||||
identity tuple for logging.
|
||||
- Vue DevTools / Pinia inspector should surface the `*BelongsTo`
|
||||
components for registered entities.
|
||||
|
||||
### 4.3 Migration effort, by kind
|
||||
|
||||
The slices below mirror the [migration plan](ecs-migration-plan.md)'s
|
||||
incremental approach. Each kind ships independently:
|
||||
|
||||
| Kind | Status today | Migration cost |
|
||||
| -------------- | ---------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
|
||||
| Widget | In World (slice 1, content-addressed strings) | High: rewrite `entityIds.ts`, `widgetValueStore.ts`, ~6 test files. ~+250/-80 prod, ~+100 test. |
|
||||
| Node container | In World (slice 1, content-addressed strings) | Bundled with widget migration; same surface area. |
|
||||
| Slot | Not in World; lives on `LGraphNode.{inputs,outputs}` | Per ADR 0008 migration plan. New: `slotEntityId`, `SlotBelongsTo`, slot store. |
|
||||
| Link | Not in World; lives on `LGraph.links` | New: `linkEntityId`, `LinkBelongsTo`, `LinkEndpoints` (slot-keyed). |
|
||||
| Reroute | Not in World; lives on `LGraph.reroutes` | New: `rerouteEntityId`, `RerouteBelongsTo`, `RerouteLinks`. |
|
||||
| Group | Not in World; lives on `LGraph._groups` | New: `groupEntityId`, `GroupBelongsTo`, `GroupChildren`. |
|
||||
|
||||
The widget+node migration is the only slice that touches existing World
|
||||
code and existing tests. All other kinds are greenfield extensions — the
|
||||
strategy is fixed by this document so each slice executes mechanically
|
||||
without re-litigating identity-format choices.
|
||||
|
||||
### 4.4 Re-registration after `clearGraph`
|
||||
|
||||
If `clearGraph(g)` cleans World components but leaves stale entries in
|
||||
the per-store address indices, a subsequent `register*(g, ...)` returns
|
||||
an old UUID and re-binds components on a "ghost" entity. This is
|
||||
functionally equivalent to today's content-addressed re-use, but the
|
||||
discipline must be enforced: **`clearGraph(g)` MUST clear all index
|
||||
entries for graph `g` before returning.** Per-store unit tests should
|
||||
include a `clear → re-register` round-trip case.
|
||||
|
||||
### 4.5 Tests that hand-construct entity IDs
|
||||
|
||||
Slice-1 tests synthesize `WidgetEntityId`/`NodeEntityId` strings directly
|
||||
(e.g. `defineComponentKey<...>` typing assertions in
|
||||
[world.test.ts](../../src/world/world.test.ts)). These will need to mint
|
||||
UUIDs instead. The change is mechanical but touches several test files
|
||||
and will recur for each subsequent slice.
|
||||
|
||||
### 4.6 Litegraph-numeric ID coexistence
|
||||
|
||||
`workflowSchema.json` round-tripping requires preserving the numeric IDs
|
||||
emitted by `LGraphState.lastNodeId++` etc. Under opaque-UUID identity
|
||||
those numbers become **data** on the `*BelongsTo` component
|
||||
(`litegraphNodeId`, `litegraphLinkId`, `litegraphGroupId`,
|
||||
`litegraphRerouteId`). The SerializationSystem reads them when emitting
|
||||
JSON and assigns matching values when re-hydrating. This is the same
|
||||
"World ID ≠ wire ID" decoupling that ADR 0008 requires.
|
||||
|
||||
---
|
||||
|
||||
## 5. Cross-references
|
||||
|
||||
### 5.1 ECS library survey
|
||||
|
||||
Detailed comparison in [appendix-ecs-pattern-survey.md](appendix-ecs-pattern-survey.md).
|
||||
The pattern this document proposes — opaque IDs + parent-pointer-on-child
|
||||
|
||||
- denormalized children + centralized mutation — is what koota,
|
||||
miniplex, EnTT, and Bevy all converge on for hierarchy modeling. Flecs
|
||||
goes further by encoding the parent into the archetype, which JS ECSes
|
||||
don't have access to without significant substrate work.
|
||||
|
||||
### 5.2 Source citations for §2 and §3.1
|
||||
|
||||
- Flecs hierarchies and pairs:
|
||||
[Hierarchies Manual](https://www.flecs.dev/flecs/md_docs_2HierarchiesManual.html),
|
||||
[Relationships Manual](https://www.flecs.dev/flecs/md_docs_2Relationships.html)
|
||||
- Bevy parent/children components:
|
||||
[bevy_hierarchy docs](https://docs.rs/bevy_hierarchy),
|
||||
[Bevy Cheat Book hierarchy](https://bevy-cheatbook.github.io/fundamentals/hierarchy.html)
|
||||
- EnTT relationship guidance:
|
||||
skypjack, [ECS back and forth, part 4](https://skypjack.github.io/2019-09-25-ecs_baf_part_4/)
|
||||
- koota relation primitive:
|
||||
[koota/relation source](https://github.com/pmndrs/koota/blob/main/packages/core/src/relation/relation.ts)
|
||||
- miniplex entity-reference pattern:
|
||||
[miniplex README](https://github.com/hmans/miniplex)
|
||||
- Normalization tradeoffs:
|
||||
Mertens, [Building Games in ECS with Entity Relationships](https://ajmmertens.medium.com/building-games-in-ecs-with-entity-relationships-657275ba2c6c);
|
||||
IceFall Games,
|
||||
[Managing game object hierarchy in an ECS](https://mtnphil.wordpress.com/2014/06/09/managing-game-object-hierarchy-in-an-entity-component-system/)
|
||||
|
||||
### 5.3 Related ADRs and architecture docs
|
||||
|
||||
- [ADR 0003: Centralized Layout Management with CRDT](../adr/0003-crdt-based-layout-system.md) —
|
||||
command-pattern requirement that opaque IDs simplify
|
||||
- [ADR 0008: Entity Component System](../adr/0008-entity-component-system.md) —
|
||||
this document amends §"Branded ID Design" by reinterpreting "numeric"
|
||||
as "opaque UUID string" across all six entity kinds, and adds
|
||||
`*BelongsTo` identity components to §"Component Decomposition"
|
||||
- [ECS Pattern Survey](appendix-ecs-pattern-survey.md) — substrate-level
|
||||
comparison
|
||||
- [ECS Lifecycle Scenarios](ecs-lifecycle-scenarios.md) — concrete
|
||||
before/after walkthroughs that this document's identity model affects
|
||||
- [ECS Migration Plan](ecs-migration-plan.md) — the slice progression
|
||||
this document's per-kind sections track against
|
||||
- [ECS Target Architecture](ecs-target-architecture.md) — the world-shape
|
||||
this document refines for identity
|
||||
|
||||
---
|
||||
|
||||
## 6. Migration plan
|
||||
|
||||
Two horizons: a near-term implementation slice that ships now, and a
|
||||
strategy locked in for subsequent ADR 0008 slices.
|
||||
|
||||
### 6.1 Near-term: widgets and node containers (slice 1.5)
|
||||
|
||||
Ship as a single PR after the colon-collision consolidation
|
||||
([entityIds-consolidation.md](../../temp/plans/entityIds-consolidation.md))
|
||||
lands and is verified. Sequencing rationale: the consolidation is a
|
||||
defensive 1-file fix that does not commit to this larger architectural
|
||||
direction. If this proposal is rejected or amended, the consolidation
|
||||
still stands as a strict improvement over the current state.
|
||||
|
||||
Phases inside the PR (single commit boundary, but reviewable as separate
|
||||
hunks):
|
||||
|
||||
1. Add `WidgetBelongsTo`, `NodeBelongsTo` components and the four
|
||||
indices in `widgetValueStore`. Populate them in `registerWidget`
|
||||
alongside the existing entity-ID derivation. The string-format
|
||||
`widgetEntityId` continues to exist; the new components are written
|
||||
redundantly. All tests still pass.
|
||||
2. Switch `getWidget` / `getNodeWidgets` / `getNodeWidgetsByName` to
|
||||
read identity from `WidgetBelongsTo` instead of parsing the entity
|
||||
ID. Delete `parseWidgetEntityId`. Delete the regex.
|
||||
3. Switch `clearGraph` to consume `widgetsByGraph` / `nodesByGraph`
|
||||
instead of `entitiesWith` + `isWidgetIdForGraph`. Delete the
|
||||
`isXForGraph` helpers.
|
||||
4. Replace `widgetEntityId` / `nodeEntityId` constructors with
|
||||
`mintWidgetId` / `mintNodeId`. Switch the entity-ID format from
|
||||
content-addressed strings to UUIDs.
|
||||
5. Update test fixtures that hand-construct entity-ID strings.
|
||||
|
||||
Each phase ends in a green test suite. Quality gates:
|
||||
`pnpm test:unit && pnpm typecheck && pnpm lint && pnpm knip`.
|
||||
|
||||
### 6.2 Subsequent slices: slots, links, reroutes, groups
|
||||
|
||||
Per the [migration plan](ecs-migration-plan.md), each remaining kind
|
||||
moves into the World in its own slice. For each, the strategy is fixed
|
||||
by this document:
|
||||
|
||||
1. Add the kind's `mint*Id` to `entityIds.ts` and the `*EntityId` brand.
|
||||
2. Define the `*BelongsTo` identity component plus any `*Children` /
|
||||
`*Endpoints` structural components per §2.2.
|
||||
3. Create (or extend) the domain store that owns the per-graph and
|
||||
forward-address indices, with the centralized-mutation API discipline.
|
||||
4. Add the bridge layer (per ADR 0008 §"Migration Strategy" step 2) so
|
||||
existing OOP consumers continue to read from the litegraph class
|
||||
while ECS readers read from the World.
|
||||
5. Migrate consumers piecewise per ADR 0008 §"Migration Strategy"
|
||||
steps 3–5.
|
||||
|
||||
No new identity-format decisions are made per slice. The colon-collision
|
||||
risk class is closed for the entire ECS, not just for widgets.
|
||||
|
||||
---
|
||||
|
||||
## 7. Open questions
|
||||
|
||||
1. **Should widgets and node containers ship in one PR or two?** Widget
|
||||
identity is the bug-relevant case; node identity is symmetric but not
|
||||
load-bearing. Splitting reduces review surface but leaves the
|
||||
substrate temporarily inconsistent (widgets opaque, node containers
|
||||
content-addressed). Recommendation: one PR, since both live in
|
||||
`widgetValueStore` and the indices interlock.
|
||||
|
||||
2. **Should `*BelongsTo.name` / `*BelongsTo.index` be mutable post-creation?**
|
||||
Today, rename mints a new entity; reorder rotates slot identity.
|
||||
Under opaque IDs these mutations could either (a) preserve identity
|
||||
(mutate component, rewrite address index) or (b) preserve current
|
||||
semantics (delete + remint). (a) is the new capability this refactor
|
||||
unlocks, but adopting it changes downstream behavior; should be a
|
||||
deliberate follow-up per kind.
|
||||
|
||||
3. **Do we want explicit `Is{Widget,Node,Slot,…}` marker components?**
|
||||
koota uses tag traits this way so `world.entitiesWith(IsWidget)` is
|
||||
the canonical "iterate all widgets" query, replacing the implicit
|
||||
`entitiesWith(WidgetComponentValue)` pattern. Optional ergonomic
|
||||
improvement, not required by the bug fix; decide per slice.
|
||||
|
||||
4. **Where does name/ordinal uniqueness within a parent belong?**
|
||||
For widgets: `(graphId, nodeId, name)` uniqueness is enforced by
|
||||
`widgetByAddress`. For slots: `(graphId, nodeId, direction, index)`
|
||||
uniqueness is enforced by `slotByAddress`. We should decide per kind
|
||||
whether second-registration is a no-op (`getOrRegister` semantics),
|
||||
an overwrite, or an error.
|
||||
|
||||
5. **Counter-based vs UUID-based minting.** ADR 0008 originally proposed
|
||||
counter-based (`lastWidgetId++`). UUIDs are simpler (no shared state,
|
||||
no CRDT mapping) but slightly larger and unpleasant in stack traces.
|
||||
Recommendation: UUIDs for now (matching this document's §2.1); revisit
|
||||
if profiling or debuggability demands otherwise.
|
||||
@@ -26,10 +26,6 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
/* Disable trackpad two-finger horizontal swipe back/forward navigation
|
||||
and other overscroll gestures. ComfyUI is a full-screen editor; the
|
||||
browser's overscroll behaviors only ever leave or break the workflow. */
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
body {
|
||||
display: grid;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.44.18",
|
||||
"version": "1.44.17",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
13
packages/ingest-types/src/types.gen.ts
generated
@@ -524,18 +524,9 @@ export type ImportPublishedAssetsRequest = {
|
||||
*/
|
||||
published_asset_ids: Array<string>
|
||||
/**
|
||||
* Optional. Share ID of the published workflow these assets belong to.
|
||||
* When provided (non-null, non-empty): all published_asset_ids must
|
||||
* belong to this share's workflow version; returns
|
||||
* 400/CodeInvalidAssets if the share is not found or any asset does
|
||||
* not belong to it.
|
||||
* When omitted, null, or empty string: no share-scoped validation is
|
||||
* performed and the assets are validated only against global rules
|
||||
* (legacy behaviour, preserved for clients that have not yet adopted
|
||||
* share_id).
|
||||
*
|
||||
* The share ID of the published workflow these assets belong to. Required for authorization.
|
||||
*/
|
||||
share_id?: string | null
|
||||
share_id: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
4
packages/ingest-types/src/zod.gen.ts
generated
@@ -310,8 +310,8 @@ export const zImportPublishedAssetsResponse = z.object({
|
||||
* Request body for importing assets from a published workflow.
|
||||
*/
|
||||
export const zImportPublishedAssetsRequest = z.object({
|
||||
published_asset_ids: z.array(z.string()),
|
||||
share_id: z.string().nullish()
|
||||
published_asset_ids: z.array(z.string().min(1).max(64)).max(1000),
|
||||
share_id: z.string().min(1).max(64)
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -12575,16 +12575,12 @@ export interface components {
|
||||
Rodin3DGenerateRequest: {
|
||||
/** @description The reference images to generate 3D Assets. */
|
||||
images: string;
|
||||
/** @description Text prompt used by the upstream Rodin API. Required by upstream for text-to-3D requests (no images uploaded); optional for image-to-3D requests where it acts as additional guidance. */
|
||||
prompt?: string;
|
||||
/** @description Seed. */
|
||||
seed?: number;
|
||||
tier?: components["schemas"]["RodinTierType"];
|
||||
material?: components["schemas"]["RodinMaterialType"];
|
||||
quality?: components["schemas"]["RodinQualityType"];
|
||||
mesh_mode?: components["schemas"]["RodinMeshModeType"];
|
||||
/** @description Optional list of upstream addon flags (e.g. "HighPack"). */
|
||||
addons?: string[];
|
||||
};
|
||||
/**
|
||||
* @description Rodin Tier para options
|
||||
|
||||
@@ -3,14 +3,12 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
appendWorkflowJsonExt,
|
||||
ensureWorkflowSuffix,
|
||||
getFilePathSeparatorVariants,
|
||||
getFilenameDetails,
|
||||
getMediaTypeFromFilename,
|
||||
getPathDetails,
|
||||
highlightQuery,
|
||||
isCivitaiModelUrl,
|
||||
isPreviewableMediaType,
|
||||
joinFilePath,
|
||||
truncateFilename
|
||||
} from './formatUtil'
|
||||
|
||||
@@ -301,42 +299,6 @@ describe('formatUtil', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('joinFilePath', () => {
|
||||
it('joins subfolder and filename with normalized slash separators', () => {
|
||||
expect(joinFilePath('nested\\folder', 'child\\file.png')).toBe(
|
||||
'nested/folder/child/file.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('trims boundary separators without changing the filename body', () => {
|
||||
expect(joinFilePath('/nested/folder/', '/file.png')).toBe(
|
||||
'nested/folder/file.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns the normalized filename when no subfolder is provided', () => {
|
||||
expect(joinFilePath('', 'nested\\file.png')).toBe('nested/file.png')
|
||||
})
|
||||
|
||||
it('returns the normalized subfolder without a trailing slash when no filename is provided', () => {
|
||||
expect(joinFilePath('nested\\folder', '')).toBe('nested/folder')
|
||||
expect(joinFilePath('nested\\folder', null)).toBe('nested/folder')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFilePathSeparatorVariants', () => {
|
||||
it('returns slash and backslash variants for nested paths', () => {
|
||||
expect(getFilePathSeparatorVariants('nested\\folder/file.png')).toEqual([
|
||||
'nested/folder/file.png',
|
||||
'nested\\folder\\file.png'
|
||||
])
|
||||
})
|
||||
|
||||
it('returns a single value when no separator is present', () => {
|
||||
expect(getFilePathSeparatorVariants('file.png')).toEqual(['file.png'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('appendWorkflowJsonExt', () => {
|
||||
it('appends .app.json when isApp is true', () => {
|
||||
expect(appendWorkflowJsonExt('test', true)).toBe('test.app.json')
|
||||
|
||||
@@ -256,31 +256,6 @@ export function isValidUrl(url: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
export function joinFilePath(
|
||||
subfolder: string | null | undefined,
|
||||
filename: string | null | undefined
|
||||
): string {
|
||||
const normalizedSubfolder = normalizeFilePathSeparators(
|
||||
subfolder ?? ''
|
||||
).replace(/^\/+|\/+$/g, '')
|
||||
const normalizedFilename = normalizeFilePathSeparators(
|
||||
filename ?? ''
|
||||
).replace(/^\/+/g, '')
|
||||
if (!normalizedSubfolder) return normalizedFilename
|
||||
if (!normalizedFilename) return normalizedSubfolder
|
||||
return `${normalizedSubfolder}/${normalizedFilename}`
|
||||
}
|
||||
|
||||
export function getFilePathSeparatorVariants(filepath: string): string[] {
|
||||
const slashPath = normalizeFilePathSeparators(filepath)
|
||||
const backslashPath = slashPath.replace(/\//g, '\\')
|
||||
return slashPath === backslashPath ? [slashPath] : [slashPath, backslashPath]
|
||||
}
|
||||
|
||||
function normalizeFilePathSeparators(filepath: string): string {
|
||||
return filepath.replace(/[\\/]+/g, '/')
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a filepath into its filename and subfolder components.
|
||||
*
|
||||
@@ -299,7 +274,8 @@ export function parseFilePath(filepath: string): {
|
||||
} {
|
||||
if (!filepath?.trim()) return { filename: '', subfolder: '' }
|
||||
|
||||
const normalizedPath = normalizeFilePathSeparators(filepath)
|
||||
const normalizedPath = filepath
|
||||
.replace(/[\\/]+/g, '/') // Normalize path separators
|
||||
.replace(/^\//, '') // Remove leading slash
|
||||
.replace(/\/$/, '') // Remove trailing slash
|
||||
|
||||
|
||||
28
pnpm-lock.yaml
generated
@@ -160,8 +160,8 @@ catalogs:
|
||||
specifier: ^7.7.0
|
||||
version: 7.7.0
|
||||
'@types/three':
|
||||
specifier: ^0.170.0
|
||||
version: 0.170.0
|
||||
specifier: ^0.169.0
|
||||
version: 0.169.0
|
||||
'@vee-validate/zod':
|
||||
specifier: ^4.15.1
|
||||
version: 4.15.1
|
||||
@@ -339,9 +339,6 @@ catalogs:
|
||||
tailwindcss-primeui:
|
||||
specifier: ^0.6.1
|
||||
version: 0.6.1
|
||||
three:
|
||||
specifier: ^0.170.0
|
||||
version: 0.170.0
|
||||
tsx:
|
||||
specifier: ^4.15.6
|
||||
version: 4.19.4
|
||||
@@ -701,7 +698,7 @@ importers:
|
||||
version: 7.7.0
|
||||
'@types/three':
|
||||
specifier: 'catalog:'
|
||||
version: 0.170.0
|
||||
version: 0.169.0
|
||||
'@vitejs/plugin-vue':
|
||||
specifier: 'catalog:'
|
||||
version: 6.0.3(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))
|
||||
@@ -967,9 +964,6 @@ importers:
|
||||
posthog-js:
|
||||
specifier: 'catalog:'
|
||||
version: 1.358.1
|
||||
three:
|
||||
specifier: 'catalog:'
|
||||
version: 0.170.0
|
||||
vue:
|
||||
specifier: 'catalog:'
|
||||
version: 3.5.13(typescript@5.9.3)
|
||||
@@ -4514,8 +4508,8 @@ packages:
|
||||
'@types/stats.js@0.17.3':
|
||||
resolution: {integrity: sha512-pXNfAD3KHOdif9EQXZ9deK82HVNaXP5ZIF5RP2QG6OQFNTaY2YIetfrE9t528vEreGQvEPRDDc8muaoYeK0SxQ==}
|
||||
|
||||
'@types/three@0.170.0':
|
||||
resolution: {integrity: sha512-CUm2uckq+zkCY7ZbFpviRttY+6f9fvwm6YqSqPfA5K22s9w7R4VnA3rzJse8kHVvuzLcTx+CjNCs2NYe0QFAyg==}
|
||||
'@types/three@0.169.0':
|
||||
resolution: {integrity: sha512-oan7qCgJBt03wIaK+4xPWclYRPG9wzcg7Z2f5T8xYTNEF95kh0t0lklxLLYBDo7gQiGLYzE6iF4ta7nXF2bcsw==}
|
||||
|
||||
'@types/tough-cookie@4.0.5':
|
||||
resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==}
|
||||
@@ -9889,8 +9883,8 @@ packages:
|
||||
vue-component-type-helpers@3.2.6:
|
||||
resolution: {integrity: sha512-O02tnvIfOQVmnvoWwuSydwRoHjZVt8UEBR+2p4rT35p8GAy5VTlWP8o5qXfJR/GWCN0nVZoYWsVUvx2jwgdBmQ==}
|
||||
|
||||
vue-component-type-helpers@3.2.8:
|
||||
resolution: {integrity: sha512-9689efAXhN/EV86plgkL/XFiJSXhGtWPG6JDboZ+QnjlUWUUQrQ0ILKQtw4iQsuwIwu5k6Aw+JnehDe7161e7A==}
|
||||
vue-component-type-helpers@3.2.7:
|
||||
resolution: {integrity: sha512-+gPp5YGmhfsj1IN+xUo7y0fb4clfnOiiUA39y07yW1VzCRjzVgwLbtmdWlghh7mXrPsEaYc7rrIir/HT6C8vYQ==}
|
||||
|
||||
vue-demi@0.14.10:
|
||||
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
|
||||
@@ -13411,7 +13405,7 @@ snapshots:
|
||||
storybook: 10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
type-fest: 2.19.0
|
||||
vue: 3.5.13(typescript@5.9.3)
|
||||
vue-component-type-helpers: 3.2.8
|
||||
vue-component-type-helpers: 3.2.7
|
||||
|
||||
'@swc/helpers@0.5.17':
|
||||
dependencies:
|
||||
@@ -13840,7 +13834,7 @@ snapshots:
|
||||
|
||||
'@types/stats.js@0.17.3': {}
|
||||
|
||||
'@types/three@0.170.0':
|
||||
'@types/three@0.169.0':
|
||||
dependencies:
|
||||
'@tweenjs/tween.js': 23.1.3
|
||||
'@types/stats.js': 0.17.3
|
||||
@@ -14195,7 +14189,7 @@ snapshots:
|
||||
sirv: 3.0.2
|
||||
tinyglobby: 0.2.15
|
||||
tinyrainbow: 3.0.3
|
||||
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
|
||||
'@vitest/utils@3.2.4':
|
||||
dependencies:
|
||||
@@ -20536,7 +20530,7 @@ snapshots:
|
||||
|
||||
vue-component-type-helpers@3.2.6: {}
|
||||
|
||||
vue-component-type-helpers@3.2.8: {}
|
||||
vue-component-type-helpers@3.2.7: {}
|
||||
|
||||
vue-demi@0.14.10(vue@3.5.13(typescript@5.9.3)):
|
||||
dependencies:
|
||||
|
||||
@@ -54,7 +54,7 @@ catalog:
|
||||
'@types/jsdom': ^21.1.7
|
||||
'@types/node': ^24.1.0
|
||||
'@types/semver': ^7.7.0
|
||||
'@types/three': ^0.170.0
|
||||
'@types/three': ^0.169.0
|
||||
'@vee-validate/zod': ^4.15.1
|
||||
'@vercel/analytics': ^2.0.1
|
||||
'@vitejs/plugin-vue': ^6.0.0
|
||||
@@ -113,7 +113,6 @@ catalog:
|
||||
storybook: ^10.2.10
|
||||
stylelint: ^16.26.1
|
||||
tailwindcss: ^4.2.0
|
||||
three: ^0.170.0
|
||||
tailwindcss-primeui: ^0.6.1
|
||||
tsx: ^4.15.6
|
||||
tw-animate-css: ^1.3.8
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
/**
|
||||
* Wheel events whose browser default would break the editing experience.
|
||||
* On macOS trackpads:
|
||||
* - `ctrl/meta + wheel` (pinch-zoom) triggers page-level zoom, which
|
||||
* pushes fixed-position UI (e.g. ComfyActionbar) off-screen with no
|
||||
* recovery short of a page reload.
|
||||
* - Horizontal-dominant wheel (two-finger horizontal swipe) triggers
|
||||
* back/forward navigation, which leaves the workflow.
|
||||
*
|
||||
* Equal `|deltaX| == |deltaY|` (including idle 0/0 frames between meaningful
|
||||
* trackpad samples) intentionally falls on the false branch so native
|
||||
* vertical scroll wins on a tie.
|
||||
*
|
||||
* Components that intercept wheel events should suppress the default for
|
||||
* these gestures even when they otherwise let the browser scroll natively.
|
||||
*/
|
||||
export const isCanvasGestureWheel = (event: WheelEvent): boolean =>
|
||||
event.ctrlKey ||
|
||||
event.metaKey ||
|
||||
Math.abs(event.deltaX) > Math.abs(event.deltaY)
|
||||
@@ -40,10 +40,7 @@
|
||||
|
||||
<template #contentFilter>
|
||||
<div class="relative flex flex-wrap justify-between gap-2 px-6 pb-4">
|
||||
<div
|
||||
:ref="primeVueOverlay.overlayScopeRef"
|
||||
class="flex flex-wrap gap-2"
|
||||
>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<!-- Model Filter -->
|
||||
<MultiSelect
|
||||
v-model="selectedModelObjects"
|
||||
@@ -51,7 +48,6 @@
|
||||
class="w-[250px]"
|
||||
:label="modelFilterLabel"
|
||||
:options="modelOptions"
|
||||
:content-style="selectContentStyle"
|
||||
:show-search-box="true"
|
||||
:show-selected-count="true"
|
||||
:show-clear-button="true"
|
||||
@@ -66,7 +62,6 @@
|
||||
v-model="selectedUseCaseObjects"
|
||||
:label="useCaseFilterLabel"
|
||||
:options="useCaseOptions"
|
||||
:content-style="selectContentStyle"
|
||||
:show-search-box="true"
|
||||
:show-selected-count="true"
|
||||
:show-clear-button="true"
|
||||
@@ -81,7 +76,6 @@
|
||||
v-model="selectedRunsOnObjects"
|
||||
:label="runsOnFilterLabel"
|
||||
:options="runsOnOptions"
|
||||
:content-style="selectContentStyle"
|
||||
:show-search-box="true"
|
||||
:show-selected-count="true"
|
||||
:show-clear-button="true"
|
||||
@@ -98,7 +92,6 @@
|
||||
v-model="sortBy"
|
||||
:label="$t('templateWorkflows.sorting', 'Sort by')"
|
||||
:options="sortOptions"
|
||||
:content-style="selectContentStyle"
|
||||
class="w-62.5"
|
||||
>
|
||||
<template #icon>
|
||||
@@ -423,7 +416,6 @@ import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
|
||||
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
|
||||
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
|
||||
import { useLazyPagination } from '@/composables/useLazyPagination'
|
||||
import { usePrimeVueOverlayChildStyle } from '@/composables/usePopoverSizing'
|
||||
import { useTemplateFiltering } from '@/composables/useTemplateFiltering'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
@@ -640,8 +632,6 @@ const selectedRunsOnObjects = computed({
|
||||
const loadingTemplate = ref<string | null>(null)
|
||||
const hoveredTemplate = ref<string | null>(null)
|
||||
const cardRefs = ref<HTMLElement[]>([])
|
||||
const primeVueOverlay = usePrimeVueOverlayChildStyle()
|
||||
const selectContentStyle = primeVueOverlay.contentStyle
|
||||
|
||||
// Force re-render key for templates when sorting changes
|
||||
const templateListKey = ref(0)
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { cleanup, render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: { g: { close: 'Close' } } },
|
||||
missingWarn: false,
|
||||
fallbackWarn: false
|
||||
})
|
||||
|
||||
const Body = defineComponent({
|
||||
name: 'Body',
|
||||
setup: () => () => h('p', { 'data-testid': 'body' }, 'body content')
|
||||
})
|
||||
|
||||
function mountDialog() {
|
||||
return render(GlobalDialog, {
|
||||
global: { plugins: [PrimeVue, i18n] }
|
||||
})
|
||||
}
|
||||
|
||||
describe('GlobalDialog renderer branching', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('renders the PrimeVue branch when renderer is omitted', async () => {
|
||||
mountDialog()
|
||||
const store = useDialogStore()
|
||||
|
||||
store.showDialog({
|
||||
key: 'primevue-default',
|
||||
title: 'PrimeVue dialog',
|
||||
component: Body
|
||||
})
|
||||
|
||||
const dialogs = await screen.findAllByRole('dialog')
|
||||
expect(dialogs.some((el) => el.classList.contains('p-dialog'))).toBe(true)
|
||||
})
|
||||
|
||||
it('renders the Reka branch when renderer is reka', async () => {
|
||||
mountDialog()
|
||||
const store = useDialogStore()
|
||||
|
||||
store.showDialog({
|
||||
key: 'reka-opt-in',
|
||||
title: 'Reka dialog',
|
||||
component: Body,
|
||||
dialogComponentProps: { renderer: 'reka' }
|
||||
})
|
||||
|
||||
const dialogs = await screen.findAllByRole('dialog')
|
||||
expect(dialogs.length).toBeGreaterThan(0)
|
||||
expect(dialogs.some((el) => el.classList.contains('p-dialog'))).toBe(false)
|
||||
})
|
||||
|
||||
it('preserves the renderer flag on the dialog stack item', async () => {
|
||||
mountDialog()
|
||||
const store = useDialogStore()
|
||||
|
||||
store.showDialog({
|
||||
key: 'reka-flag-check',
|
||||
title: 'Reka',
|
||||
component: Body,
|
||||
dialogComponentProps: { renderer: 'reka' }
|
||||
})
|
||||
|
||||
await screen.findByRole('dialog')
|
||||
const item = store.dialogStack.find((d) => d.key === 'reka-flag-check')
|
||||
expect(item?.dialogComponentProps.renderer).toBe('reka')
|
||||
})
|
||||
})
|
||||
|
||||
describe('GlobalDialog Reka parity with PrimeVue', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('omits the close button when closable is false', async () => {
|
||||
mountDialog()
|
||||
const store = useDialogStore()
|
||||
|
||||
store.showDialog({
|
||||
key: 'reka-not-closable',
|
||||
title: 'No close',
|
||||
component: Body,
|
||||
dialogComponentProps: { renderer: 'reka', closable: false }
|
||||
})
|
||||
|
||||
await screen.findByRole('dialog')
|
||||
expect(screen.queryByRole('button', { name: 'Close' })).toBeNull()
|
||||
})
|
||||
|
||||
it('renders the close button by default', async () => {
|
||||
mountDialog()
|
||||
const store = useDialogStore()
|
||||
|
||||
store.showDialog({
|
||||
key: 'reka-closable',
|
||||
title: 'Closable',
|
||||
component: Body,
|
||||
dialogComponentProps: { renderer: 'reka' }
|
||||
})
|
||||
|
||||
await screen.findByRole('dialog')
|
||||
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('omits the title when headless is true', async () => {
|
||||
mountDialog()
|
||||
const store = useDialogStore()
|
||||
|
||||
store.showDialog({
|
||||
key: 'reka-headless',
|
||||
title: 'Hidden title',
|
||||
component: Body,
|
||||
dialogComponentProps: { renderer: 'reka', headless: true }
|
||||
})
|
||||
|
||||
await screen.findByRole('dialog')
|
||||
expect(screen.queryByText('Hidden title')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders the title when headless is omitted', async () => {
|
||||
mountDialog()
|
||||
const store = useDialogStore()
|
||||
|
||||
store.showDialog({
|
||||
key: 'reka-titled',
|
||||
title: 'Visible title',
|
||||
component: Body,
|
||||
dialogComponentProps: { renderer: 'reka' }
|
||||
})
|
||||
|
||||
await screen.findByRole('dialog')
|
||||
expect(screen.getByText('Visible title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('closes the dialog on Escape by default', async () => {
|
||||
mountDialog()
|
||||
const store = useDialogStore()
|
||||
const user = userEvent.setup()
|
||||
|
||||
store.showDialog({
|
||||
key: 'reka-esc-default',
|
||||
title: 'Esc closes',
|
||||
component: Body,
|
||||
dialogComponentProps: { renderer: 'reka' }
|
||||
})
|
||||
|
||||
await screen.findByRole('dialog')
|
||||
await user.keyboard('{Escape}')
|
||||
|
||||
expect(store.isDialogOpen('reka-esc-default')).toBe(false)
|
||||
})
|
||||
|
||||
it('does not close on Escape when closable is false', async () => {
|
||||
mountDialog()
|
||||
const store = useDialogStore()
|
||||
const user = userEvent.setup()
|
||||
|
||||
store.showDialog({
|
||||
key: 'reka-esc-blocked',
|
||||
title: 'Esc blocked',
|
||||
component: Body,
|
||||
dialogComponentProps: { renderer: 'reka', closable: false }
|
||||
})
|
||||
|
||||
await screen.findByRole('dialog')
|
||||
await user.keyboard('{Escape}')
|
||||
|
||||
expect(store.isDialogOpen('reka-esc-blocked')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,106 +1,49 @@
|
||||
<!-- The main global dialog to show various things -->
|
||||
<template>
|
||||
<template v-for="item in dialogStore.dialogStack" :key="item.key">
|
||||
<Dialog
|
||||
v-if="isRekaItem(item)"
|
||||
:open="item.visible"
|
||||
:modal="item.dialogComponentProps.modal ?? true"
|
||||
@update:open="(open) => onRekaOpenChange(item.key, open)"
|
||||
>
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogContent
|
||||
:size="item.dialogComponentProps.size ?? 'md'"
|
||||
:aria-labelledby="item.key"
|
||||
@escape-key-down="
|
||||
(e) =>
|
||||
item.dialogComponentProps.closeOnEscape === false &&
|
||||
e.preventDefault()
|
||||
"
|
||||
@pointer-down-outside="
|
||||
(e) =>
|
||||
item.dialogComponentProps.dismissableMask === false &&
|
||||
e.preventDefault()
|
||||
"
|
||||
@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">
|
||||
<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>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
<PrimeDialog
|
||||
v-else
|
||||
v-model:visible="item.visible"
|
||||
class="global-dialog"
|
||||
v-bind="item.dialogComponentProps"
|
||||
:pt="getDialogPt(item)"
|
||||
:aria-labelledby="item.key"
|
||||
>
|
||||
<template #header>
|
||||
<div v-if="!item.dialogComponentProps?.headless">
|
||||
<component
|
||||
:is="item.headerComponent"
|
||||
v-if="item.headerComponent"
|
||||
v-bind="item.headerProps"
|
||||
:id="item.key"
|
||||
/>
|
||||
<h3 v-else :id="item.key">
|
||||
{{ item.title || ' ' }}
|
||||
</h3>
|
||||
</div>
|
||||
</template>
|
||||
<Dialog
|
||||
v-for="item in dialogStore.dialogStack"
|
||||
:key="item.key"
|
||||
v-model:visible="item.visible"
|
||||
class="global-dialog"
|
||||
v-bind="item.dialogComponentProps"
|
||||
:pt="getDialogPt(item)"
|
||||
:aria-labelledby="item.key"
|
||||
>
|
||||
<template #header>
|
||||
<div v-if="!item.dialogComponentProps?.headless">
|
||||
<component
|
||||
:is="item.headerComponent"
|
||||
v-if="item.headerComponent"
|
||||
v-bind="item.headerProps"
|
||||
:id="item.key"
|
||||
/>
|
||||
<h3 v-else :id="item.key">
|
||||
{{ item.title || ' ' }}
|
||||
</h3>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<component
|
||||
:is="item.component"
|
||||
v-bind="item.contentProps"
|
||||
:maximized="item.dialogComponentProps.maximized"
|
||||
/>
|
||||
<component
|
||||
:is="item.component"
|
||||
v-bind="item.contentProps"
|
||||
:maximized="item.dialogComponentProps.maximized"
|
||||
/>
|
||||
|
||||
<template v-if="item.footerComponent" #footer>
|
||||
<component :is="item.footerComponent" v-bind="item.footerProps" />
|
||||
</template>
|
||||
</PrimeDialog>
|
||||
</template>
|
||||
<template v-if="item.footerComponent" #footer>
|
||||
<component :is="item.footerComponent" v-bind="item.footerProps" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { merge } from 'es-toolkit/compat'
|
||||
import PrimeDialog from 'primevue/dialog'
|
||||
import Dialog 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 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 type { DialogComponentProps } from '@/stores/dialogStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
@@ -110,14 +53,6 @@ const teamWorkspacesEnabled = computed(
|
||||
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
function isRekaItem(item: DialogInstance) {
|
||||
return item.dialogComponentProps.renderer === 'reka'
|
||||
}
|
||||
|
||||
function onRekaOpenChange(key: string, open: boolean) {
|
||||
if (!open) dialogStore.closeDialog({ key })
|
||||
}
|
||||
|
||||
function getDialogPt(item: {
|
||||
key: string
|
||||
dialogComponentProps: DialogComponentProps
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
<template>
|
||||
<div
|
||||
:ref="primeVueOverlay.overlayScopeRef"
|
||||
class="keybinding-panel flex flex-col gap-2"
|
||||
>
|
||||
<div class="keybinding-panel flex flex-col gap-2">
|
||||
<Teleport defer to="#keybinding-panel-header">
|
||||
<SearchInput
|
||||
v-model="filters['global'].value"
|
||||
@@ -18,12 +15,10 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<KeybindingPresetToolbar
|
||||
:preset-names="presetNames"
|
||||
:content-style="keybindingOverlayContentStyle"
|
||||
@presets-changed="refreshPresetList"
|
||||
/>
|
||||
<DropdownMenu
|
||||
:entries="menuEntries"
|
||||
:style="keybindingOverlayContentStyle"
|
||||
icon="icon-[lucide--ellipsis]"
|
||||
item-class="text-sm gap-2"
|
||||
button-size="unset"
|
||||
@@ -198,12 +193,11 @@
|
||||
</template>
|
||||
</Column>
|
||||
<template #expansion="slotProps">
|
||||
<div class="pl-4" data-testid="keybinding-expansion-content">
|
||||
<div class="pl-4">
|
||||
<div
|
||||
v-for="(binding, idx) in (slotProps.data as ICommandData)
|
||||
.keybindings"
|
||||
:key="binding.combo.serialize()"
|
||||
data-testid="keybinding-expansion-binding"
|
||||
class="flex items-center justify-between border-b border-border-subtle py-1.5 last:border-b-0"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
@@ -243,7 +237,6 @@
|
||||
</ContextMenuTrigger>
|
||||
<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"
|
||||
>
|
||||
<ContextMenuItem
|
||||
@@ -320,7 +313,6 @@ import { showConfirmDialog } from '@/components/dialog/confirm/confirmDialog'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import { useEditKeybindingDialog } from '@/composables/useEditKeybindingDialog'
|
||||
import { usePrimeVueOverlayChildStyle } from '@/composables/usePopoverSizing'
|
||||
import type { KeybindingImpl } from '@/platform/keybindings/keybinding'
|
||||
import { useKeybindingService } from '@/platform/keybindings/keybindingService'
|
||||
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
|
||||
@@ -344,8 +336,6 @@ const settingStore = useSettingStore()
|
||||
const commandStore = useCommandStore()
|
||||
const dialogStore = useDialogStore()
|
||||
const { t } = useI18n()
|
||||
const primeVueOverlay = usePrimeVueOverlayChildStyle()
|
||||
const keybindingOverlayContentStyle = primeVueOverlay.contentStyle
|
||||
|
||||
const presetNames = ref<string[]>([])
|
||||
|
||||
|
||||
@@ -9,10 +9,7 @@
|
||||
{{ displayLabel }}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
:style="contentStyle"
|
||||
class="max-w-64 min-w-0 **:[[role=listbox]]:gap-1"
|
||||
>
|
||||
<SelectContent class="max-w-64 min-w-0 **:[[role=listbox]]:gap-1">
|
||||
<div class="max-w-60">
|
||||
<SelectItem
|
||||
value="default"
|
||||
@@ -49,7 +46,6 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { StyleValue } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
@@ -61,9 +57,8 @@ import SelectValue from '@/components/ui/select/SelectValue.vue'
|
||||
import { useKeybindingPresetService } from '@/platform/keybindings/presetService'
|
||||
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
|
||||
|
||||
const { presetNames, contentStyle } = defineProps<{
|
||||
const { presetNames } = defineProps<{
|
||||
presetNames: string[]
|
||||
contentStyle?: StyleValue
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<a
|
||||
v-bind="props.action"
|
||||
class="flex items-center gap-2 px-3 py-1.5"
|
||||
@click="onItemClick($event, item)"
|
||||
@click="item.isColorSubmenu ? showColorPopover($event) : undefined"
|
||||
>
|
||||
<i v-if="item.icon" :class="[item.icon, 'size-4']" />
|
||||
<span class="flex-1">{{ item.label }}</span>
|
||||
@@ -21,27 +21,20 @@
|
||||
{{ item.shortcut }}
|
||||
</span>
|
||||
<i
|
||||
v-if="hasSubmenu || item.isColorSubmenu || item.isShapeSubmenu"
|
||||
v-if="hasSubmenu || item.isColorSubmenu"
|
||||
class="icon-[lucide--chevron-right] size-4 opacity-60"
|
||||
/>
|
||||
</a>
|
||||
</template>
|
||||
</ContextMenu>
|
||||
|
||||
<SubmenuPopover
|
||||
<!-- Color picker menu (custom with color circles) -->
|
||||
<ColorPickerMenu
|
||||
v-if="colorOption"
|
||||
ref="colorSubmenu"
|
||||
key="color-submenu"
|
||||
ref="colorPickerMenu"
|
||||
key="color-picker-menu"
|
||||
:option="colorOption"
|
||||
@submenu-click="handleSubmenuSelect"
|
||||
/>
|
||||
|
||||
<SubmenuPopover
|
||||
v-if="shapeOption"
|
||||
ref="shapeSubmenu"
|
||||
key="shape-submenu"
|
||||
:option="shapeOption"
|
||||
@submenu-click="handleSubmenuSelect"
|
||||
@submenu-click="handleColorSelect"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -61,18 +54,16 @@ import type {
|
||||
} from '@/composables/graph/useMoreOptionsMenu'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
|
||||
import SubmenuPopover from './selectionToolbox/SubmenuPopover.vue'
|
||||
import ColorPickerMenu from './selectionToolbox/ColorPickerMenu.vue'
|
||||
|
||||
interface ExtendedMenuItem extends MenuItem {
|
||||
isColorSubmenu?: boolean
|
||||
isShapeSubmenu?: boolean
|
||||
shortcut?: string
|
||||
originalOption?: MenuOption
|
||||
}
|
||||
|
||||
const contextMenu = ref<InstanceType<typeof ContextMenu>>()
|
||||
const colorSubmenu = ref<InstanceType<typeof SubmenuPopover>>()
|
||||
const shapeSubmenu = ref<InstanceType<typeof SubmenuPopover>>()
|
||||
const colorPickerMenu = ref<InstanceType<typeof ColorPickerMenu>>()
|
||||
const isOpen = ref(false)
|
||||
|
||||
const { menuOptions, bump } = useMoreOptionsMenu()
|
||||
@@ -159,20 +150,21 @@ useEventListener(
|
||||
{ passive: true }
|
||||
)
|
||||
|
||||
// Find color picker option
|
||||
const colorOption = computed(() =>
|
||||
menuOptions.value.find((opt) => opt.isColorPicker)
|
||||
)
|
||||
|
||||
const shapeOption = computed(() =>
|
||||
menuOptions.value.find((opt) => opt.isShapePicker)
|
||||
)
|
||||
// Check if option is the color picker
|
||||
function isColorOption(option: MenuOption): boolean {
|
||||
return Boolean(option.isColorPicker)
|
||||
}
|
||||
|
||||
// Convert MenuOption to PrimeVue MenuItem
|
||||
function convertToMenuItem(option: MenuOption): ExtendedMenuItem {
|
||||
if (option.type === 'divider') return { separator: true }
|
||||
|
||||
const isColor = Boolean(option.isColorPicker)
|
||||
const isShape = Boolean(option.isShapePicker)
|
||||
const usesPopover = isColor || isShape
|
||||
const isColor = isColorOption(option)
|
||||
|
||||
const item: ExtendedMenuItem = {
|
||||
label: option.label,
|
||||
@@ -180,14 +172,11 @@ function convertToMenuItem(option: MenuOption): ExtendedMenuItem {
|
||||
disabled: option.disabled,
|
||||
shortcut: option.shortcut,
|
||||
isColorSubmenu: isColor,
|
||||
isShapeSubmenu: isShape,
|
||||
originalOption: option
|
||||
}
|
||||
|
||||
// Submenus opened via popover (color, shape) deliberately omit `items` so
|
||||
// PrimeVue does not render a nested <ul> inside the scrollable root list,
|
||||
// which would be clipped when the menu overflows the viewport (FE-570).
|
||||
if (option.hasSubmenu && option.submenu && !usesPopover) {
|
||||
// Native submenus for non-color options
|
||||
if (option.hasSubmenu && option.submenu && !isColor) {
|
||||
item.items = option.submenu.map((sub) => ({
|
||||
label: sub.label,
|
||||
icon: sub.icon,
|
||||
@@ -199,6 +188,7 @@ function convertToMenuItem(option: MenuOption): ExtendedMenuItem {
|
||||
}))
|
||||
}
|
||||
|
||||
// Regular action items
|
||||
if (!option.hasSubmenu && option.action) {
|
||||
item.command = () => {
|
||||
option.action?.()
|
||||
@@ -255,30 +245,17 @@ function toggle(event: Event) {
|
||||
|
||||
defineExpose({ toggle, hide, isOpen, show })
|
||||
|
||||
function onItemClick(event: MouseEvent, item: ExtendedMenuItem) {
|
||||
if (item.isColorSubmenu) {
|
||||
openSubmenuPopover(event, colorSubmenu.value, shapeSubmenu.value)
|
||||
} else if (item.isShapeSubmenu) {
|
||||
openSubmenuPopover(event, shapeSubmenu.value, colorSubmenu.value)
|
||||
}
|
||||
}
|
||||
|
||||
function openSubmenuPopover(
|
||||
event: MouseEvent,
|
||||
target: InstanceType<typeof SubmenuPopover> | undefined,
|
||||
other: InstanceType<typeof SubmenuPopover> | undefined
|
||||
) {
|
||||
if (!target) return
|
||||
function showColorPopover(event: MouseEvent) {
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
other?.hide()
|
||||
const anchor = Array.from((event.currentTarget as HTMLElement).children).find(
|
||||
const target = Array.from((event.currentTarget as HTMLElement).children).find(
|
||||
(el) => el.classList.contains('icon-[lucide--chevron-right]')
|
||||
) as HTMLElement
|
||||
target.toggle(event, anchor)
|
||||
colorPickerMenu.value?.toggle(event, target)
|
||||
}
|
||||
|
||||
function handleSubmenuSelect(subOption: SubMenuOption) {
|
||||
// Handle color selection
|
||||
function handleColorSelect(subOption: SubMenuOption) {
|
||||
subOption.action()
|
||||
hide()
|
||||
}
|
||||
@@ -293,17 +270,11 @@ function constrainMenuHeight() {
|
||||
if (!rootList) return
|
||||
|
||||
const rect = rootList.getBoundingClientRect()
|
||||
const availableHeight = window.innerHeight - rect.top - 8
|
||||
if (availableHeight <= 0) return
|
||||
|
||||
// Setting overflow-y to auto/scroll on the root <ul> coerces overflow-x
|
||||
// to a non-visible value too (CSS spec), which clips horizontally-opening
|
||||
// submenus like Shape. Only apply the constraint when content truly
|
||||
// overflows so the common case keeps overflow visible.
|
||||
if (rootList.scrollHeight <= availableHeight) return
|
||||
|
||||
rootList.style.maxHeight = `${availableHeight}px`
|
||||
rootList.style.overflowY = 'auto'
|
||||
const maxHeight = window.innerHeight - rect.top - 8
|
||||
if (maxHeight > 0) {
|
||||
rootList.style.maxHeight = `${maxHeight}px`
|
||||
rootList.style.overflowY = 'auto'
|
||||
}
|
||||
}
|
||||
|
||||
function onMenuShow() {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
/* eslint-disable testing-library/no-container, testing-library/no-node-access */
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fireEvent, render } from '@testing-library/vue'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
@@ -30,26 +29,6 @@ function createMockExtensionService(): ReturnType<typeof useExtensionService> {
|
||||
>
|
||||
}
|
||||
|
||||
const { settingGetMock } = vi.hoisted(() => ({
|
||||
settingGetMock: vi.fn()
|
||||
}))
|
||||
|
||||
const defaultSettingValues: Record<string, unknown> = {
|
||||
'Comfy.UseNewMenu': 'Top',
|
||||
'Comfy.NodeLibrary.NewDesign': true,
|
||||
'Comfy.Load3D.3DViewerEnable': true
|
||||
}
|
||||
|
||||
function mockSettingValues(overrides: Record<string, unknown> = {}) {
|
||||
const settingValues = {
|
||||
...defaultSettingValues,
|
||||
...overrides
|
||||
}
|
||||
settingGetMock.mockImplementation(
|
||||
(key: string): unknown => settingValues[key] ?? null
|
||||
)
|
||||
}
|
||||
|
||||
// Mock the composables and services
|
||||
vi.mock('@/renderer/core/canvas/useCanvasInteractions', () => ({
|
||||
useCanvasInteractions: vi.fn(() => ({
|
||||
@@ -100,7 +79,10 @@ vi.mock('@/utils/nodeFilterUtil', () => ({
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: settingGetMock
|
||||
get: vi.fn((key: string) => {
|
||||
if (key === 'Comfy.Load3D.3DViewerEnable') return true
|
||||
return null
|
||||
})
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -146,7 +128,7 @@ describe('SelectionToolbox', () => {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ createSpy: vi.fn, stubActions: false }))
|
||||
setActivePinia(createPinia())
|
||||
canvasStore = useCanvasStore()
|
||||
nodeDefMock = {
|
||||
type: 'TestNode',
|
||||
@@ -157,7 +139,6 @@ describe('SelectionToolbox', () => {
|
||||
canvasStore.canvas = createMockCanvas()
|
||||
|
||||
vi.resetAllMocks()
|
||||
mockSettingValues()
|
||||
})
|
||||
|
||||
function renderComponent(props = {}): { container: Element } {
|
||||
@@ -250,42 +231,6 @@ describe('SelectionToolbox', () => {
|
||||
expect(container.querySelector('.info-button')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should not show info button when legacy menu uses the new node library', () => {
|
||||
mockSettingValues({
|
||||
'Comfy.UseNewMenu': 'Disabled',
|
||||
'Comfy.NodeLibrary.NewDesign': true
|
||||
})
|
||||
canvasStore.selectedItems = [createMockPositionable()]
|
||||
|
||||
const { container } = renderComponent()
|
||||
|
||||
expect(container.querySelector('.info-button')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should not show info button when legacy menu uses the legacy node library', () => {
|
||||
mockSettingValues({
|
||||
'Comfy.UseNewMenu': 'Disabled',
|
||||
'Comfy.NodeLibrary.NewDesign': false
|
||||
})
|
||||
canvasStore.selectedItems = [createMockPositionable()]
|
||||
|
||||
const { container } = renderComponent()
|
||||
|
||||
expect(container.querySelector('.info-button')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should show info button when new menu uses the legacy node library', () => {
|
||||
mockSettingValues({
|
||||
'Comfy.UseNewMenu': 'Top',
|
||||
'Comfy.NodeLibrary.NewDesign': false
|
||||
})
|
||||
canvasStore.selectedItems = [createMockPositionable()]
|
||||
|
||||
const { container } = renderComponent()
|
||||
|
||||
expect(container.querySelector('.info-button')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should show color picker for all selections', () => {
|
||||
// Single node selection
|
||||
canvasStore.selectedItems = [createMockPositionable()]
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
@wheel="canvasInteractions.forwardEventToCanvas"
|
||||
>
|
||||
<DeleteButton v-if="showDelete" />
|
||||
<VerticalDivider v-if="canOpenNodeInfo && showAnyPrimaryActions" />
|
||||
<InfoButton v-if="canOpenNodeInfo" />
|
||||
<VerticalDivider v-if="showInfoButton && showAnyPrimaryActions" />
|
||||
<InfoButton v-if="showInfoButton" />
|
||||
|
||||
<ColorPickerButton v-if="showColorPicker" />
|
||||
<FrameNodes v-if="showFrameNodes" />
|
||||
@@ -105,8 +105,9 @@ const {
|
||||
isSingleImageNode,
|
||||
hasAny3DNodeSelected,
|
||||
hasOutputNodesSelected,
|
||||
canOpenNodeInfo
|
||||
nodeDef
|
||||
} = useSelectionState()
|
||||
const showInfoButton = computed(() => !!nodeDef.value)
|
||||
|
||||
const showColorPicker = computed(() => hasAnySelection.value)
|
||||
const showConvertToSubgraph = computed(() => hasAnySelection.value)
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
unstyled
|
||||
:pt="{
|
||||
root: {
|
||||
class: 'p-popover absolute z-60'
|
||||
class: 'absolute z-60'
|
||||
},
|
||||
content: {
|
||||
class: [
|
||||
@@ -90,12 +90,8 @@ const popoverRef = ref<InstanceType<typeof Popover>>()
|
||||
const toggle = (event: Event, target?: HTMLElement) => {
|
||||
popoverRef.value?.toggle(event, target)
|
||||
}
|
||||
const hide = () => {
|
||||
popoverRef.value?.hide()
|
||||
}
|
||||
defineExpose({
|
||||
toggle,
|
||||
hide
|
||||
toggle
|
||||
})
|
||||
|
||||
const handleSubmenuClick = (subOption: SubMenuOption) => {
|
||||
@@ -1,5 +1,6 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import Tooltip from 'primevue/tooltip'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -8,20 +9,19 @@ import { createI18n } from 'vue-i18n'
|
||||
import InfoButton from '@/components/graph/selectionToolbox/InfoButton.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const { openNodeInfoMock, trackUiButtonClickedMock } = vi.hoisted(() => ({
|
||||
openNodeInfoMock: vi.fn(),
|
||||
trackUiButtonClickedMock: vi.fn()
|
||||
const { openPanelMock } = vi.hoisted(() => ({
|
||||
openPanelMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useSelectionState', () => ({
|
||||
useSelectionState: () => ({
|
||||
openNodeInfo: openNodeInfoMock
|
||||
vi.mock('@/stores/workspace/rightSidePanelStore', () => ({
|
||||
useRightSidePanelStore: () => ({
|
||||
openPanel: openPanelMock
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({
|
||||
trackUiButtonClicked: trackUiButtonClickedMock
|
||||
trackUiButtonClicked: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -39,8 +39,8 @@ describe('InfoButton', () => {
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
openNodeInfoMock.mockReturnValue(true)
|
||||
})
|
||||
|
||||
const renderComponent = () => {
|
||||
@@ -53,29 +53,12 @@ describe('InfoButton', () => {
|
||||
})
|
||||
}
|
||||
|
||||
const clickNodeInfoButton = async () => {
|
||||
it('should open the info panel on click', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Node Info' }))
|
||||
}
|
||||
|
||||
it('should open the node info panel on click', async () => {
|
||||
renderComponent()
|
||||
|
||||
await clickNodeInfoButton()
|
||||
|
||||
expect(openNodeInfoMock).toHaveBeenCalled()
|
||||
expect(trackUiButtonClickedMock).toHaveBeenCalledWith({
|
||||
button_id: 'selection_toolbox_node_info_opened'
|
||||
})
|
||||
})
|
||||
|
||||
it('should not track the click when the node info panel is unavailable', async () => {
|
||||
openNodeInfoMock.mockReturnValue(false)
|
||||
renderComponent()
|
||||
|
||||
await clickNodeInfoButton()
|
||||
|
||||
expect(openNodeInfoMock).toHaveBeenCalled()
|
||||
expect(trackUiButtonClickedMock).not.toHaveBeenCalled()
|
||||
expect(openPanelMock).toHaveBeenCalledWith('info')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,16 +15,18 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useSelectionState } from '@/composables/graph/useSelectionState'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
|
||||
const { openNodeInfo } = useSelectionState()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
|
||||
/**
|
||||
* Track node info button click and toggle node help.
|
||||
*/
|
||||
const onInfoClick = () => {
|
||||
if (!openNodeInfo()) return
|
||||
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'selection_toolbox_node_info_opened'
|
||||
})
|
||||
rightSidePanelStore.openPanel('info')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -21,42 +21,20 @@
|
||||
</Button>
|
||||
|
||||
<Select
|
||||
:model-value="selectedSpeed != null ? String(selectedSpeed) : undefined"
|
||||
@update:model-value="(val) => (selectedSpeed = Number(val))"
|
||||
>
|
||||
<SelectTrigger size="md" class="w-24">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="opt in speedOptions"
|
||||
:key="opt.value"
|
||||
:value="String(opt.value)"
|
||||
>
|
||||
{{ opt.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
v-model="selectedSpeed"
|
||||
:options="speedOptions"
|
||||
option-label="name"
|
||||
option-value="value"
|
||||
class="w-24"
|
||||
/>
|
||||
|
||||
<Select
|
||||
:model-value="
|
||||
selectedAnimation != null ? String(selectedAnimation) : undefined
|
||||
"
|
||||
@update:model-value="(val) => (selectedAnimation = Number(val))"
|
||||
>
|
||||
<SelectTrigger size="md" class="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="anim in animations"
|
||||
:key="anim.index"
|
||||
:value="String(anim.index)"
|
||||
>
|
||||
{{ anim.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
v-model="selectedAnimation"
|
||||
:options="animations"
|
||||
option-label="name"
|
||||
option-value="index"
|
||||
class="w-32"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full max-w-xs items-center gap-2 px-4">
|
||||
@@ -76,14 +54,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Select from 'primevue/select'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Select from '@/components/ui/select/Select.vue'
|
||||
import SelectContent from '@/components/ui/select/SelectContent.vue'
|
||||
import SelectItem from '@/components/ui/select/SelectItem.vue'
|
||||
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
|
||||
import SelectValue from '@/components/ui/select/SelectValue.vue'
|
||||
import Slider from '@/components/ui/slider/Slider.vue'
|
||||
|
||||
type Animation = { name: string; index: number }
|
||||
|
||||
@@ -5,20 +5,20 @@ import { ref } from 'vue'
|
||||
|
||||
import PopupSlider from '@/components/load3d/controls/PopupSlider.vue'
|
||||
|
||||
vi.mock('@/components/ui/slider/Slider.vue', () => ({
|
||||
vi.mock('primevue/slider', () => ({
|
||||
default: {
|
||||
name: 'UiSlider',
|
||||
name: 'Slider',
|
||||
props: ['modelValue', 'min', 'max', 'step'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<input
|
||||
type="range"
|
||||
role="slider"
|
||||
:value="Array.isArray(modelValue) ? modelValue[0] : modelValue"
|
||||
:value="modelValue"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
@input="$emit('update:modelValue', [Number($event.target.value)])"
|
||||
@input="$emit('update:modelValue', Number($event.target.value))"
|
||||
/>
|
||||
`
|
||||
}
|
||||
|
||||
@@ -15,22 +15,21 @@
|
||||
class="absolute top-0 left-12 w-[150px] rounded-lg bg-interface-menu-surface p-4 shadow-lg"
|
||||
>
|
||||
<Slider
|
||||
:model-value="sliderValue"
|
||||
v-model="value"
|
||||
class="w-full"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
@update:model-value="onSliderUpdate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import Slider from 'primevue/slider'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Slider from '@/components/ui/slider/Slider.vue'
|
||||
|
||||
const {
|
||||
icon = 'pi-expand',
|
||||
@@ -48,12 +47,6 @@ const {
|
||||
const value = defineModel<number>()
|
||||
const showSlider = ref(false)
|
||||
|
||||
const sliderValue = computed(() => [value.value ?? min])
|
||||
|
||||
function onSliderUpdate(val: number[] | undefined) {
|
||||
if (val?.length) value.value = val[0]
|
||||
}
|
||||
|
||||
const toggleSlider = () => {
|
||||
showSlider.value = !showSlider.value
|
||||
}
|
||||
|
||||
@@ -7,81 +7,38 @@ import { createI18n } from 'vue-i18n'
|
||||
import ViewerCameraControls from '@/components/load3d/controls/viewer/ViewerCameraControls.vue'
|
||||
import type { CameraType } from '@/extensions/core/load3d/interfaces'
|
||||
|
||||
vi.mock('@/components/ui/select/Select.vue', async () => {
|
||||
const { provide } = await import('vue')
|
||||
return {
|
||||
default: {
|
||||
name: 'Select',
|
||||
props: ['modelValue'],
|
||||
emits: ['update:modelValue'],
|
||||
setup(
|
||||
props: { modelValue: string },
|
||||
{ emit }: { emit: (event: string, value: string) => void }
|
||||
) {
|
||||
provide('selectModelValue', (): string => props.modelValue)
|
||||
provide('selectUpdate', (v: string): void =>
|
||||
emit('update:modelValue', v)
|
||||
)
|
||||
},
|
||||
template: '<div><slot /></div>'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/components/ui/select/SelectContent.vue', async () => {
|
||||
const { inject, ref, onMounted } = await import('vue')
|
||||
return {
|
||||
default: {
|
||||
name: 'SelectContent',
|
||||
setup() {
|
||||
const selectModelValue = inject<() => string>('selectModelValue')
|
||||
const selectUpdate = inject<(v: string) => void>('selectUpdate')
|
||||
const el = ref<HTMLSelectElement | null>(null)
|
||||
onMounted(() => {
|
||||
if (el.value) el.value.value = selectModelValue?.() ?? ''
|
||||
})
|
||||
return {
|
||||
el,
|
||||
onChange: (e: Event) => {
|
||||
selectUpdate?.((e.target as HTMLSelectElement).value)
|
||||
}
|
||||
}
|
||||
},
|
||||
template: '<select ref="el" @change="onChange"><slot /></select>'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/components/ui/select/SelectItem.vue', () => ({
|
||||
vi.mock('primevue/select', () => ({
|
||||
default: {
|
||||
name: 'SelectItem',
|
||||
props: ['value'],
|
||||
template: '<option :value="value"><slot /></option>'
|
||||
name: 'Select',
|
||||
props: ['modelValue', 'options', 'optionLabel', 'optionValue'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<select
|
||||
:value="modelValue"
|
||||
@change="$emit('update:modelValue', $event.target.value)"
|
||||
>
|
||||
<option v-for="opt in options" :key="opt[optionValue]" :value="opt[optionValue]">
|
||||
{{ opt[optionLabel] }}
|
||||
</option>
|
||||
</select>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/select/SelectTrigger.vue', () => ({
|
||||
default: { name: 'SelectTrigger', template: '<span />' }
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/select/SelectValue.vue', () => ({
|
||||
default: { name: 'SelectValue', template: '<span />' }
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/slider/Slider.vue', () => ({
|
||||
vi.mock('primevue/slider', () => ({
|
||||
default: {
|
||||
name: 'UiSlider',
|
||||
props: ['modelValue', 'min', 'max', 'step'],
|
||||
name: 'Slider',
|
||||
props: ['modelValue', 'min', 'max', 'step', 'ariaLabel'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<input
|
||||
type="range"
|
||||
role="slider"
|
||||
:value="Array.isArray(modelValue) ? modelValue[0] : modelValue"
|
||||
:value="modelValue"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
@input="$emit('update:modelValue', [Number($event.target.value)])"
|
||||
:aria-label="ariaLabel"
|
||||
@input="$emit('update:modelValue', Number($event.target.value))"
|
||||
/>
|
||||
`
|
||||
}
|
||||
|
||||
@@ -2,46 +2,34 @@
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label>{{ t('load3d.viewer.cameraType') }}</label>
|
||||
<Select v-model="cameraType">
|
||||
<SelectTrigger size="md">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="cam in cameras"
|
||||
:key="cam.value"
|
||||
:value="cam.value"
|
||||
>
|
||||
{{ cam.title }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
<Select
|
||||
v-model="cameraType"
|
||||
:options="cameras"
|
||||
option-label="title"
|
||||
option-value="value"
|
||||
>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div v-if="showFOVButton" class="flex flex-col gap-2">
|
||||
<label>{{ t('load3d.fov') }}</label>
|
||||
<Slider
|
||||
:model-value="fovSliderValue"
|
||||
v-model="fov"
|
||||
:min="10"
|
||||
:max="150"
|
||||
:step="1"
|
||||
:aria-label="t('load3d.fov')"
|
||||
@update:model-value="onFovUpdate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Select from 'primevue/select'
|
||||
import Slider from 'primevue/slider'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Select from '@/components/ui/select/Select.vue'
|
||||
import SelectContent from '@/components/ui/select/SelectContent.vue'
|
||||
import SelectItem from '@/components/ui/select/SelectItem.vue'
|
||||
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
|
||||
import SelectValue from '@/components/ui/select/SelectValue.vue'
|
||||
import Slider from '@/components/ui/slider/Slider.vue'
|
||||
import type { CameraType } from '@/extensions/core/load3d/interfaces'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -53,10 +41,4 @@ const cameras = [
|
||||
const cameraType = defineModel<CameraType>('cameraType')
|
||||
const fov = defineModel<number>('fov')
|
||||
const showFOVButton = computed(() => cameraType.value === 'perspective')
|
||||
|
||||
const fovSliderValue = computed(() => [fov.value ?? 10])
|
||||
|
||||
function onFovUpdate(val: number[] | undefined) {
|
||||
if (val?.length) fov.value = val[0]
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -5,67 +5,24 @@ import { createI18n } from 'vue-i18n'
|
||||
|
||||
import ViewerExportControls from '@/components/load3d/controls/viewer/ViewerExportControls.vue'
|
||||
|
||||
vi.mock('@/components/ui/select/Select.vue', async () => {
|
||||
const { provide } = await import('vue')
|
||||
return {
|
||||
default: {
|
||||
name: 'Select',
|
||||
props: ['modelValue'],
|
||||
emits: ['update:modelValue'],
|
||||
setup(
|
||||
props: { modelValue: string },
|
||||
{ emit }: { emit: (event: string, value: string) => void }
|
||||
) {
|
||||
provide('selectModelValue', (): string => props.modelValue)
|
||||
provide('selectUpdate', (v: string): void =>
|
||||
emit('update:modelValue', v)
|
||||
)
|
||||
},
|
||||
template: '<div><slot /></div>'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/components/ui/select/SelectContent.vue', async () => {
|
||||
const { inject, ref, onMounted } = await import('vue')
|
||||
return {
|
||||
default: {
|
||||
name: 'SelectContent',
|
||||
setup() {
|
||||
const selectModelValue = inject<() => string>('selectModelValue')
|
||||
const selectUpdate = inject<(v: string) => void>('selectUpdate')
|
||||
const el = ref<HTMLSelectElement | null>(null)
|
||||
onMounted(() => {
|
||||
if (el.value) el.value.value = selectModelValue?.() ?? ''
|
||||
})
|
||||
return {
|
||||
el,
|
||||
onChange: (e: Event) => {
|
||||
selectUpdate?.((e.target as HTMLSelectElement).value)
|
||||
}
|
||||
}
|
||||
},
|
||||
template: '<select ref="el" @change="onChange"><slot /></select>'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/components/ui/select/SelectItem.vue', () => ({
|
||||
vi.mock('primevue/select', () => ({
|
||||
default: {
|
||||
name: 'SelectItem',
|
||||
props: ['value'],
|
||||
template: '<option :value="value"><slot /></option>'
|
||||
name: 'Select',
|
||||
props: ['modelValue', 'options', 'optionLabel', 'optionValue'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<select
|
||||
:value="modelValue"
|
||||
@change="$emit('update:modelValue', $event.target.value)"
|
||||
>
|
||||
<option v-for="opt in options" :key="opt[optionValue]" :value="opt[optionValue]">
|
||||
{{ opt[optionLabel] }}
|
||||
</option>
|
||||
</select>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/select/SelectTrigger.vue', () => ({
|
||||
default: { name: 'SelectTrigger', template: '<span />' }
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/select/SelectValue.vue', () => ({
|
||||
default: { name: 'SelectValue', template: '<span />' }
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<Select v-model="exportFormat">
|
||||
<SelectTrigger size="md">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="fmt in exportFormats"
|
||||
:key="fmt.value"
|
||||
:value="fmt.value"
|
||||
>
|
||||
{{ fmt.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
<Select
|
||||
v-model="exportFormat"
|
||||
:options="exportFormats"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
@@ -26,14 +19,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Select from 'primevue/select'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Select from '@/components/ui/select/Select.vue'
|
||||
import SelectContent from '@/components/ui/select/SelectContent.vue'
|
||||
import SelectItem from '@/components/ui/select/SelectItem.vue'
|
||||
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
|
||||
import SelectValue from '@/components/ui/select/SelectValue.vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'exportModel', format: string): void
|
||||
|
||||
@@ -17,20 +17,19 @@ vi.mock('@/platform/settings/settingStore', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/slider/Slider.vue', () => ({
|
||||
vi.mock('primevue/slider', () => ({
|
||||
default: {
|
||||
name: 'UiSlider',
|
||||
name: 'Slider',
|
||||
props: ['modelValue', 'min', 'max', 'step'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<input
|
||||
type="range"
|
||||
role="slider"
|
||||
:value="Array.isArray(modelValue) ? modelValue[0] : modelValue"
|
||||
:value="modelValue"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
@input="$emit('update:modelValue', [Number($event.target.value)])"
|
||||
@input="$emit('update:modelValue', Number($event.target.value))"
|
||||
/>
|
||||
`
|
||||
}
|
||||
|
||||
@@ -3,20 +3,18 @@
|
||||
<label>{{ $t('load3d.lightIntensity') }}</label>
|
||||
|
||||
<Slider
|
||||
:model-value="sliderValue"
|
||||
v-model="lightIntensity"
|
||||
class="w-full"
|
||||
:min="lightIntensityMinimum"
|
||||
:max="lightIntensityMaximum"
|
||||
:step="lightAdjustmentIncrement"
|
||||
@update:model-value="onSliderUpdate"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import Slider from 'primevue/slider'
|
||||
|
||||
import Slider from '@/components/ui/slider/Slider.vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
|
||||
const lightIntensity = defineModel<number>('lightIntensity')
|
||||
@@ -30,12 +28,4 @@ const lightIntensityMinimum = useSettingStore().get(
|
||||
const lightAdjustmentIncrement = useSettingStore().get(
|
||||
'Comfy.Load3D.LightAdjustmentIncrement'
|
||||
)
|
||||
|
||||
const sliderValue = computed(() => [
|
||||
lightIntensity.value ?? lightIntensityMinimum
|
||||
])
|
||||
|
||||
function onSliderUpdate(val: number[] | undefined) {
|
||||
if (val?.length) lightIntensity.value = val[0]
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -9,67 +9,22 @@ import type {
|
||||
UpDirection
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
|
||||
vi.mock('@/components/ui/select/Select.vue', async () => {
|
||||
const { provide } = await import('vue')
|
||||
return {
|
||||
default: {
|
||||
name: 'Select',
|
||||
props: ['modelValue'],
|
||||
emits: ['update:modelValue'],
|
||||
setup(
|
||||
props: { modelValue: string },
|
||||
{ emit }: { emit: (event: string, value: string) => void }
|
||||
) {
|
||||
provide('selectModelValue', (): string => props.modelValue)
|
||||
provide('selectUpdate', (v: string): void =>
|
||||
emit('update:modelValue', v)
|
||||
)
|
||||
},
|
||||
template: '<div><slot /></div>'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/components/ui/select/SelectContent.vue', async () => {
|
||||
const { inject, ref, onMounted } = await import('vue')
|
||||
return {
|
||||
default: {
|
||||
name: 'SelectContent',
|
||||
setup() {
|
||||
const selectModelValue = inject<() => string>('selectModelValue')
|
||||
const selectUpdate = inject<(v: string) => void>('selectUpdate')
|
||||
const el = ref<HTMLSelectElement | null>(null)
|
||||
onMounted(() => {
|
||||
if (el.value) el.value.value = selectModelValue?.() ?? ''
|
||||
})
|
||||
return {
|
||||
el,
|
||||
onChange: (e: Event) => {
|
||||
selectUpdate?.((e.target as HTMLSelectElement).value)
|
||||
}
|
||||
}
|
||||
},
|
||||
template: '<select ref="el" @change="onChange"><slot /></select>'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/components/ui/select/SelectItem.vue', () => ({
|
||||
vi.mock('primevue/select', () => ({
|
||||
default: {
|
||||
name: 'SelectItem',
|
||||
props: ['value'],
|
||||
template: '<option :value="value"><slot /></option>'
|
||||
name: 'Select',
|
||||
props: ['modelValue', 'options', 'optionLabel', 'optionValue'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<select
|
||||
:value="modelValue"
|
||||
@change="$emit('update:modelValue', $event.target.value)"
|
||||
>
|
||||
<option v-for="opt in options" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
|
||||
</select>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/select/SelectTrigger.vue', () => ({
|
||||
default: { name: 'SelectTrigger', template: '<span />' }
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/select/SelectValue.vue', () => ({
|
||||
default: { name: 'SelectValue', template: '<span />' }
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
|
||||
@@ -2,51 +2,31 @@
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label>{{ $t('load3d.upDirection') }}</label>
|
||||
<Select v-model="upDirection">
|
||||
<SelectTrigger size="md">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="opt in upDirectionOptions"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
v-model="upDirection"
|
||||
:options="upDirectionOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="materialModes.length > 0" class="flex flex-col gap-2">
|
||||
<label>{{ $t('load3d.materialMode') }}</label>
|
||||
<Select v-model="materialMode">
|
||||
<SelectTrigger size="md">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="opt in materialModeOptions"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
v-model="materialMode"
|
||||
:options="materialModeOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Select from 'primevue/select'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Select from '@/components/ui/select/Select.vue'
|
||||
import SelectContent from '@/components/ui/select/SelectContent.vue'
|
||||
import SelectItem from '@/components/ui/select/SelectItem.vue'
|
||||
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
|
||||
import SelectValue from '@/components/ui/select/SelectValue.vue'
|
||||
import type {
|
||||
MaterialMode,
|
||||
UpDirection
|
||||
|
||||
@@ -7,15 +7,9 @@
|
||||
<input v-model="backgroundColor" type="color" class="h-8 w-full" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
id="showGrid"
|
||||
v-model="showGrid"
|
||||
type="checkbox"
|
||||
name="showGrid"
|
||||
class="size-4 cursor-pointer accent-node-component-surface-highlight"
|
||||
/>
|
||||
<label for="showGrid" class="cursor-pointer">
|
||||
<div>
|
||||
<Checkbox v-model="showGrid" input-id="showGrid" binary name="showGrid" />
|
||||
<label for="showGrid" class="pl-2">
|
||||
{{ $t('load3d.showGrid') }}
|
||||
</label>
|
||||
</div>
|
||||
@@ -64,6 +58,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Checkbox from 'primevue/checkbox'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
<div
|
||||
class="flex flex-col overflow-hidden rounded-lg border border-border-default bg-base-background"
|
||||
:style="{ width: `${BASE_WIDTH_PX * (scaleFactor / BASE_SCALE)}px` }"
|
||||
data-testid="node-preview-card"
|
||||
>
|
||||
<div ref="previewContainerRef" class="overflow-hidden p-3">
|
||||
<div
|
||||
|
||||
@@ -252,20 +252,6 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
expect(screen.getByText('Log Out')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('credits help icon (FE-617)', () => {
|
||||
it('renders the credits help icon as an interactive button with the unified-credits tooltip as its accessible name', () => {
|
||||
renderComponent()
|
||||
|
||||
const helpButton = screen.getByTestId('credits-info-button')
|
||||
expect(helpButton).toBeInTheDocument()
|
||||
expect(helpButton.tagName).toBe('BUTTON')
|
||||
expect(helpButton).toHaveAttribute(
|
||||
'aria-label',
|
||||
enMessages.credits.unified.tooltip
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('opens user settings and emits close event when settings item is clicked', async () => {
|
||||
const { user, onClose } = renderComponent()
|
||||
|
||||
|
||||
@@ -41,16 +41,10 @@
|
||||
<span v-else class="text-base font-semibold text-base-foreground">{{
|
||||
formattedBalance
|
||||
}}</span>
|
||||
<Button
|
||||
<i
|
||||
v-tooltip="{ value: $t('credits.unified.tooltip'), showDelay: 300 }"
|
||||
variant="muted-textonly"
|
||||
size="icon-sm"
|
||||
class="mr-auto"
|
||||
:aria-label="$t('credits.unified.tooltip')"
|
||||
data-testid="credits-info-button"
|
||||
>
|
||||
<i class="icon-[lucide--circle-help]" />
|
||||
</Button>
|
||||
class="mr-auto icon-[lucide--circle-help] cursor-help text-base text-muted-foreground"
|
||||
/>
|
||||
<Button
|
||||
v-if="isCloud && isFreeTier"
|
||||
variant="gradient"
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.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 DialogDescription from '@/components/ui/dialog/DialogDescription.vue'
|
||||
import DialogFooter from '@/components/ui/dialog/DialogFooter.vue'
|
||||
import DialogHeader from '@/components/ui/dialog/DialogHeader.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 { FOR_STORIES } from '@/components/ui/dialog/dialog.variants'
|
||||
|
||||
const { sizes } = FOR_STORIES
|
||||
|
||||
const meta: Meta = {
|
||||
title: 'Components/Dialog/Dialog',
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
size: {
|
||||
control: { type: 'select' },
|
||||
options: sizes,
|
||||
defaultValue: 'md'
|
||||
}
|
||||
},
|
||||
args: {
|
||||
size: 'md'
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj
|
||||
|
||||
export const Default: Story = {
|
||||
render: (args) => ({
|
||||
components: {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogClose
|
||||
},
|
||||
setup() {
|
||||
const open = ref(false)
|
||||
return { args, open }
|
||||
},
|
||||
template: `
|
||||
<Button @click="open = true">Open dialog</Button>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogContent :size="args.size">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Are you sure?</DialogTitle>
|
||||
<DialogClose />
|
||||
</DialogHeader>
|
||||
<div class="px-4 py-2">
|
||||
<DialogDescription>
|
||||
This action cannot be undone. The selected items will be permanently removed.
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="textonly" @click="open = false">Cancel</Button>
|
||||
<Button variant="destructive" @click="open = false">Delete</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const LongContent: Story = {
|
||||
render: (args) => ({
|
||||
components: {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogClose
|
||||
},
|
||||
setup() {
|
||||
const open = ref(false)
|
||||
return { args, open }
|
||||
},
|
||||
template: `
|
||||
<Button @click="open = true">Open long content</Button>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogContent :size="args.size">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Long content scrolls</DialogTitle>
|
||||
<DialogClose />
|
||||
</DialogHeader>
|
||||
<div class="px-4 py-2 space-y-2 overflow-auto">
|
||||
<p v-for="n in 30" :key="n">
|
||||
Paragraph {{ n }} — the dialog body should scroll independently
|
||||
while the header and footer stay pinned.
|
||||
</p>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const Headless: Story = {
|
||||
render: () => ({
|
||||
components: {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogContent
|
||||
},
|
||||
setup() {
|
||||
const open = ref(false)
|
||||
return { open }
|
||||
},
|
||||
template: `
|
||||
<Button @click="open = true">Open headless</Button>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogContent size="sm" class="p-6">
|
||||
<p class="text-sm">No header, no footer — fully custom content.</p>
|
||||
<Button class="mt-4" @click="open = false">Close</Button>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const AllSizes: Story = {
|
||||
render: () => ({
|
||||
components: {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogClose
|
||||
},
|
||||
setup() {
|
||||
const openSize = ref<string | null>(null)
|
||||
return { openSize, sizes }
|
||||
},
|
||||
template: `
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<Button v-for="s in sizes" :key="s" @click="openSize = s">{{ s }}</Button>
|
||||
</div>
|
||||
<Dialog
|
||||
v-for="s in sizes"
|
||||
:key="s"
|
||||
:open="openSize === s"
|
||||
@update:open="(o) => { if (!o) openSize = null }"
|
||||
>
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogContent :size="s">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Size: {{ s }}</DialogTitle>
|
||||
<DialogClose />
|
||||
</DialogHeader>
|
||||
<div class="px-4 py-2 text-sm">
|
||||
The {{ s }} size variant.
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
`
|
||||
})
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogRootEmits, DialogRootProps } from 'reka-ui'
|
||||
import { DialogRoot } from 'reka-ui'
|
||||
|
||||
const props = defineProps<DialogRootProps>()
|
||||
const emit = defineEmits<DialogRootEmits>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogRoot v-bind="props" @update:open="(open) => emit('update:open', open)">
|
||||
<slot />
|
||||
</DialogRoot>
|
||||
</template>
|
||||
@@ -1,18 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { DialogClose } from 'reka-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogClose as-child>
|
||||
<slot>
|
||||
<Button :aria-label="t('g.close')" size="icon" variant="muted-textonly">
|
||||
<i class="icon-[lucide--x]" />
|
||||
</Button>
|
||||
</slot>
|
||||
</DialogClose>
|
||||
</template>
|
||||
@@ -1,33 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogContentEmits, DialogContentProps } from 'reka-ui'
|
||||
import { DialogContent, useForwardPropsEmits } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import type { DialogContentSize } from './dialog.variants'
|
||||
import { dialogContentVariants } from './dialog.variants'
|
||||
|
||||
const {
|
||||
size,
|
||||
class: customClass = '',
|
||||
...restProps
|
||||
} = defineProps<
|
||||
DialogContentProps & {
|
||||
size?: DialogContentSize
|
||||
class?: HTMLAttributes['class']
|
||||
}
|
||||
>()
|
||||
|
||||
const emits = defineEmits<DialogContentEmits>()
|
||||
const forwarded = useForwardPropsEmits(restProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogContent
|
||||
v-bind="forwarded"
|
||||
:class="cn(dialogContentVariants({ size }), customClass)"
|
||||
>
|
||||
<slot />
|
||||
</DialogContent>
|
||||
</template>
|
||||
@@ -1,20 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogDescriptionProps } from 'reka-ui'
|
||||
import { DialogDescription } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: customClass = '', ...delegated } = defineProps<
|
||||
DialogDescriptionProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogDescription
|
||||
v-bind="delegated"
|
||||
:class="cn('text-sm text-muted-foreground', customClass)"
|
||||
>
|
||||
<slot />
|
||||
</DialogDescription>
|
||||
</template>
|
||||
@@ -1,22 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: customClass = '' } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex shrink-0 items-center justify-end gap-2 px-4 pt-2 pb-4',
|
||||
customClass
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,22 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: customClass = '' } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex shrink-0 items-center justify-between gap-2 px-4 pt-4 pb-2',
|
||||
customClass
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,23 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogOverlayProps } from 'reka-ui'
|
||||
import { DialogOverlay } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: customClass = '', ...delegated } = defineProps<
|
||||
DialogOverlayProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogOverlay
|
||||
v-bind="delegated"
|
||||
:class="
|
||||
cn(
|
||||
'fixed inset-0 z-1700 bg-black/70 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
|
||||
customClass
|
||||
)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
@@ -1,12 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogPortalProps } from 'reka-ui'
|
||||
import { DialogPortal } from 'reka-ui'
|
||||
|
||||
const props = defineProps<DialogPortalProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogPortal v-bind="props">
|
||||
<slot />
|
||||
</DialogPortal>
|
||||
</template>
|
||||
@@ -1,20 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogTitleProps } from 'reka-ui'
|
||||
import { DialogTitle } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: customClass = '', ...delegated } = defineProps<
|
||||
DialogTitleProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogTitle
|
||||
v-bind="delegated"
|
||||
:class="cn('text-base font-semibold text-base-foreground', customClass)"
|
||||
>
|
||||
<slot />
|
||||
</DialogTitle>
|
||||
</template>
|
||||
@@ -1,32 +0,0 @@
|
||||
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',
|
||||
variants: {
|
||||
size: {
|
||||
sm: 'sm:max-w-sm',
|
||||
md: 'sm:max-w-xl',
|
||||
lg: 'sm:max-w-3xl',
|
||||
xl: 'sm:max-w-5xl',
|
||||
full: 'sm:max-w-[calc(100vw-1rem)]'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'md'
|
||||
}
|
||||
})
|
||||
|
||||
export type DialogContentVariants = VariantProps<typeof dialogContentVariants>
|
||||
|
||||
export type DialogContentSize = NonNullable<DialogContentVariants['size']>
|
||||
|
||||
const sizes = [
|
||||
'sm',
|
||||
'md',
|
||||
'lg',
|
||||
'xl',
|
||||
'full'
|
||||
] as const satisfies Array<DialogContentSize>
|
||||
|
||||
export const FOR_STORIES = { sizes } as const
|
||||
@@ -50,7 +50,7 @@
|
||||
position="popper"
|
||||
:side-offset="8"
|
||||
align="start"
|
||||
:style="[popoverStyle, contentStyle]"
|
||||
:style="popoverStyle"
|
||||
:class="selectContentClass"
|
||||
@keydown="onContentKeydown"
|
||||
@focus-outside="preventFocusDismiss"
|
||||
@@ -152,7 +152,6 @@ import {
|
||||
ComboboxViewport
|
||||
} from 'reka-ui'
|
||||
import { computed, ref } from 'vue'
|
||||
import type { StyleValue } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
@@ -184,8 +183,7 @@ const {
|
||||
searchPlaceholder,
|
||||
listMaxHeight = '28rem',
|
||||
popoverMinWidth,
|
||||
popoverMaxWidth,
|
||||
contentStyle
|
||||
popoverMaxWidth
|
||||
} = defineProps<{
|
||||
/** Input label shown on the trigger button */
|
||||
label?: string
|
||||
@@ -209,7 +207,6 @@ const {
|
||||
popoverMinWidth?: string
|
||||
/** Maximum width of the popover (default: auto) */
|
||||
popoverMaxWidth?: string
|
||||
contentStyle?: StyleValue
|
||||
}>()
|
||||
|
||||
const selectedItems = defineModel<SelectOption[]>({
|
||||
|
||||
@@ -70,7 +70,6 @@
|
||||
v-if="suggestions.length > 0"
|
||||
position="popper"
|
||||
:side-offset="4"
|
||||
:style="contentStyle"
|
||||
:class="
|
||||
cn(
|
||||
'z-3000 max-h-60 w-(--reka-combobox-trigger-width) overflow-y-auto',
|
||||
@@ -100,7 +99,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" generic="T">
|
||||
import type { HTMLAttributes, StyleValue } from 'vue'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import {
|
||||
@@ -133,8 +132,7 @@ const {
|
||||
suggestions = [],
|
||||
optionLabel,
|
||||
optionKey,
|
||||
class: className,
|
||||
contentStyle
|
||||
class: className
|
||||
} = defineProps<{
|
||||
placeholder?: string
|
||||
icon?: string
|
||||
@@ -146,7 +144,6 @@ const {
|
||||
optionLabel?: keyof T & string
|
||||
optionKey?: keyof T & string
|
||||
class?: HTMLAttributes['class']
|
||||
contentStyle?: StyleValue
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
position="popper"
|
||||
:side-offset="8"
|
||||
align="start"
|
||||
:style="[optionStyle, contentStyle]"
|
||||
:style="optionStyle"
|
||||
:class="cn(selectContentClass, 'min-w-(--reka-select-trigger-width)')"
|
||||
@keydown="onContentKeydown"
|
||||
>
|
||||
@@ -82,7 +82,6 @@ import {
|
||||
SelectViewport
|
||||
} from 'reka-ui'
|
||||
import { ref } from 'vue'
|
||||
import type { StyleValue } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import {
|
||||
@@ -109,8 +108,7 @@ const {
|
||||
disabled = false,
|
||||
listMaxHeight = '28rem',
|
||||
popoverMinWidth,
|
||||
popoverMaxWidth,
|
||||
contentStyle
|
||||
popoverMaxWidth
|
||||
} = defineProps<{
|
||||
label?: string
|
||||
options?: SelectOption[]
|
||||
@@ -128,7 +126,6 @@ const {
|
||||
popoverMinWidth?: string
|
||||
/** Maximum width of the popover (default: auto) */
|
||||
popoverMaxWidth?: string
|
||||
contentStyle?: StyleValue
|
||||
}>()
|
||||
|
||||
const selectedItem = defineModel<string | undefined>({ required: true })
|
||||
|
||||