mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-18 09:48:09 +00:00
Compare commits
13 Commits
fix/ppform
...
jaeone/fea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bebe7f6ba | ||
|
|
68b4c4e123 | ||
|
|
73fa9153a5 | ||
|
|
ab7fd49b90 | ||
|
|
70f49fc109 | ||
|
|
cce933df60 | ||
|
|
e0b2de66dd | ||
|
|
4c9c11a26e | ||
|
|
e97e18be40 | ||
|
|
921232511a | ||
|
|
ef5bf5ab0a | ||
|
|
28480387d1 | ||
|
|
33f9319d24 |
3
.github/workflows/ci-website-build.yaml
vendored
3
.github/workflows/ci-website-build.yaml
vendored
@@ -40,6 +40,3 @@ jobs:
|
||||
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
|
||||
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
|
||||
run: pnpm --filter @comfyorg/website build
|
||||
|
||||
- name: Validate JSON-LD structured data
|
||||
run: pnpm --filter @comfyorg/website validate:jsonld
|
||||
|
||||
4
.github/workflows/cla.yml
vendored
4
.github/workflows/cla.yml
vendored
@@ -38,11 +38,9 @@ jobs:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
|
||||
PR_AUTHOR: ${{ github.event.pull_request.user.login || github.event.issue.user.login }}
|
||||
BASE_ALLOWLIST: action@github.com,actions-user,ampagent,claude,comfy-pr-bot,GitHub Action,github-actions,github-actions[bot],Glary Bot,Glary-Bot,*[bot]
|
||||
# For each commit emit the GitHub login when the author/committer email resolves to a GitHub account
|
||||
# otherwise fall back to the raw git name.
|
||||
run: |
|
||||
others=$(gh api "repos/${{ github.repository }}/pulls/${PR_NUMBER}/commits" --paginate \
|
||||
--jq '.[] | (.author.login // .commit.author.name // empty), (.committer.login // .commit.committer.name // empty)' \
|
||||
--jq '.[] | (.author.login // empty), (.committer.login // empty)' \
|
||||
| sort -u | grep -vix "${PR_AUTHOR}" | paste -sd, -)
|
||||
if [ -n "$others" ]; then
|
||||
echo "allowlist=${BASE_ALLOWLIST},${others}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
75
.github/workflows/pr-backport.yaml
vendored
75
.github/workflows/pr-backport.yaml
vendored
@@ -278,49 +278,32 @@ jobs:
|
||||
continue
|
||||
fi
|
||||
|
||||
# Create backport branch. A failure here (e.g. dirty state left
|
||||
# by a prior target) must not abort the loop and skip remaining
|
||||
# targets, so fall back to a clean checkout and record the error.
|
||||
if ! git checkout -B "${BACKPORT_BRANCH}" "origin/${TARGET_BRANCH}"; then
|
||||
echo "::error::Failed to create branch ${BACKPORT_BRANCH} for ${TARGET_BRANCH}"
|
||||
FAILED="${FAILED}${TARGET_BRANCH}:branch-create-failed "
|
||||
git checkout main || git checkout -f main
|
||||
echo "::endgroup::"
|
||||
continue
|
||||
fi
|
||||
# Create backport branch
|
||||
git checkout -b "${BACKPORT_BRANCH}" "origin/${TARGET_BRANCH}"
|
||||
|
||||
# Try cherry-pick
|
||||
if git cherry-pick "${MERGE_COMMIT}"; then
|
||||
if [ "$REMOTE_BACKPORT_EXISTS" = true ]; then
|
||||
PUSH_CMD=(git push --force-with-lease origin "${BACKPORT_BRANCH}")
|
||||
git push --force-with-lease origin "${BACKPORT_BRANCH}"
|
||||
else
|
||||
PUSH_CMD=(git push origin "${BACKPORT_BRANCH}")
|
||||
git push origin "${BACKPORT_BRANCH}"
|
||||
fi
|
||||
|
||||
# A push failure for one target must not abort the loop and
|
||||
# prevent remaining targets from being attempted.
|
||||
if "${PUSH_CMD[@]}"; then
|
||||
echo "${BACKPORT_BRANCH}" >> "$CREATED_BRANCHES_FILE"
|
||||
SUCCESS="${SUCCESS}${TARGET_BRANCH}:${BACKPORT_BRANCH} "
|
||||
echo "Successfully created backport branch: ${BACKPORT_BRANCH}"
|
||||
else
|
||||
echo "::error::Failed to push ${BACKPORT_BRANCH} for ${TARGET_BRANCH}"
|
||||
FAILED="${FAILED}${TARGET_BRANCH}:push-failed "
|
||||
fi
|
||||
|
||||
echo "${BACKPORT_BRANCH}" >> "$CREATED_BRANCHES_FILE"
|
||||
SUCCESS="${SUCCESS}${TARGET_BRANCH}:${BACKPORT_BRANCH} "
|
||||
echo "Successfully created backport branch: ${BACKPORT_BRANCH}"
|
||||
# Return to main (keep the branch, we need it for PR)
|
||||
git checkout main || git checkout -f main
|
||||
git checkout main
|
||||
else
|
||||
# Get conflict info
|
||||
CONFLICTS=$(git diff --name-only --diff-filter=U | tr '\n' ',')
|
||||
git cherry-pick --abort || true
|
||||
git cherry-pick --abort
|
||||
|
||||
echo "::error::Cherry-pick failed due to conflicts"
|
||||
FAILED="${FAILED}${TARGET_BRANCH}:conflicts:${CONFLICTS} "
|
||||
|
||||
# Clean up the failed branch
|
||||
git checkout main || git checkout -f main
|
||||
git branch -D "${BACKPORT_BRANCH}" || true
|
||||
git checkout main
|
||||
git branch -D "${BACKPORT_BRANCH}"
|
||||
fi
|
||||
|
||||
echo "::endgroup::"
|
||||
@@ -401,10 +384,6 @@ jobs:
|
||||
|
||||
**Reason:** Merge conflicts detected during cherry-pick of `${MERGE_COMMIT_SHORT}`
|
||||
|
||||
The auto-backport could not be completed automatically. Please backport
|
||||
manually onto branch `${BACKPORT_BRANCH}` (from `origin/${target}`) and
|
||||
open a PR to `${target}`.
|
||||
|
||||
<details>
|
||||
<summary>📄 Conflicting files</summary>
|
||||
|
||||
@@ -437,37 +416,19 @@ jobs:
|
||||
MERGE_COMMIT=$(jq -r '.pull_request.merge_commit_sha' "$GITHUB_EVENT_PATH")
|
||||
fi
|
||||
|
||||
# Post a comment without letting a single failed `gh pr comment` (e.g.
|
||||
# a locked issue, as happened for PR #13359, or a transient API error)
|
||||
# abort the step under `set -e` and swallow the remaining failures.
|
||||
post_comment() {
|
||||
local body="$1"
|
||||
local context="$2"
|
||||
if ! gh pr comment "${PR_NUMBER}" --body "${body}"; then
|
||||
echo "::warning::Could not comment on PR #${PR_NUMBER} about ${context}. Manual backport required."
|
||||
fi
|
||||
}
|
||||
|
||||
for failure in ${{ steps.backport.outputs.failed }}; do
|
||||
IFS=':' read -r target reason conflicts <<< "${failure}"
|
||||
|
||||
SAFE_TARGET=$(echo "$target" | tr '/' '-')
|
||||
BACKPORT_BRANCH="backport-${PR_NUMBER}-to-${SAFE_TARGET}"
|
||||
|
||||
if [ "${reason}" = "branch-missing" ]; then
|
||||
post_comment "@${PR_AUTHOR} Backport failed: Branch \`${target}\` does not exist" "missing branch ${target}"
|
||||
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Backport failed: Branch \`${target}\` does not exist"
|
||||
|
||||
elif [ "${reason}" = "already-exists" ]; then
|
||||
post_comment "@${PR_AUTHOR} Commit \`${MERGE_COMMIT}\` already exists on branch \`${target}\`. No backport needed." "already-backported ${target}"
|
||||
|
||||
elif [ "${reason}" = "branch-create-failed" ]; then
|
||||
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Backport to \`${target}\` failed: could not create the backport branch. Please retry or backport manually."
|
||||
|
||||
elif [ "${reason}" = "push-failed" ]; then
|
||||
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Backport to \`${target}\` cherry-picked cleanly but the push failed. Please retry or push the backport branch manually."
|
||||
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Commit \`${MERGE_COMMIT}\` already exists on branch \`${target}\`. No backport needed."
|
||||
|
||||
elif [ "${reason}" = "conflicts" ]; then
|
||||
CONFLICTS_INLINE=$(echo "${conflicts}" | tr ',' ' ')
|
||||
SAFE_TARGET=$(echo "$target" | tr '/' '-')
|
||||
BACKPORT_BRANCH="backport-${PR_NUMBER}-to-${SAFE_TARGET}"
|
||||
PR_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/pull/${PR_NUMBER}"
|
||||
|
||||
export PR_NUMBER PR_URL MERGE_COMMIT target BACKPORT_BRANCH CONFLICTS_INLINE
|
||||
@@ -483,10 +444,10 @@ jobs:
|
||||
CONFLICTS_BLOCK=$(echo "${conflicts}" | tr ',' '\n')
|
||||
MERGE_COMMIT_SHORT="${MERGE_COMMIT:0:7}"
|
||||
|
||||
export target MERGE_COMMIT_SHORT BACKPORT_BRANCH CONFLICTS_BLOCK AGENT_PROMPT PR_AUTHOR
|
||||
COMMENT_BODY=$(envsubst '${target} ${MERGE_COMMIT_SHORT} ${BACKPORT_BRANCH} ${CONFLICTS_BLOCK} ${AGENT_PROMPT} ${PR_AUTHOR}' <<<"$COMMENT_BODY_TEMPLATE")
|
||||
export target MERGE_COMMIT_SHORT CONFLICTS_BLOCK AGENT_PROMPT PR_AUTHOR
|
||||
COMMENT_BODY=$(envsubst '${target} ${MERGE_COMMIT_SHORT} ${CONFLICTS_BLOCK} ${AGENT_PROMPT} ${PR_AUTHOR}' <<<"$COMMENT_BODY_TEMPLATE")
|
||||
|
||||
post_comment "${COMMENT_BODY}" "cherry-pick conflict on ${target} (backport manually onto ${BACKPORT_BRANCH})"
|
||||
gh pr comment "${PR_NUMBER}" --body "${COMMENT_BODY}"
|
||||
fi
|
||||
done
|
||||
|
||||
|
||||
@@ -76,14 +76,10 @@ test.describe('Affiliates landing — desktop interactions', () => {
|
||||
return match?.textContent ?? null
|
||||
})
|
||||
expect(faqJsonLd, 'FAQ JSON-LD script').not.toBeNull()
|
||||
const graph = JSON.parse(faqJsonLd!)['@graph'] as {
|
||||
'@type': string
|
||||
mainEntity?: unknown[]
|
||||
}[]
|
||||
const faqPage = graph.find((node) => node['@type'] === 'FAQPage')
|
||||
expect(faqPage, 'FAQPage node in @graph').toBeDefined()
|
||||
expect(Array.isArray(faqPage!.mainEntity)).toBe(true)
|
||||
expect(faqPage!.mainEntity!.length).toBe(FAQ_COUNT)
|
||||
const parsed = JSON.parse(faqJsonLd!)
|
||||
expect(parsed['@type']).toBe('FAQPage')
|
||||
expect(Array.isArray(parsed.mainEntity)).toBe(true)
|
||||
expect(parsed.mainEntity.length).toBe(FAQ_COUNT)
|
||||
})
|
||||
|
||||
test('Apply Now CTA opens the application form in a new tab', async ({
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { learningTutorials } from '../src/data/learningTutorials'
|
||||
import { t } from '../src/i18n/translations'
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
const tutorialButtonName = (title: string, locale: 'en' | 'zh-CN') =>
|
||||
`${t('learning.tutorials.titlePrefix', locale)} ${title}`
|
||||
|
||||
test.describe('Learning page @smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/learning')
|
||||
})
|
||||
|
||||
test('has correct title', async ({ page }) => {
|
||||
await expect(page).toHaveTitle('Learning — Comfy')
|
||||
})
|
||||
|
||||
test('hero headline references ComfyUI', async ({ page }) => {
|
||||
const heading = page.getByRole('heading', { level: 1 })
|
||||
await expect(heading).toBeVisible()
|
||||
await expect(heading).toContainText(t('learning.heroTitle.before', 'en'))
|
||||
await expect(heading).toContainText('ComfyUI')
|
||||
await expect(heading).toContainText(t('learning.heroTitle.line2', 'en'))
|
||||
})
|
||||
|
||||
test('featured workflow section shows title and author', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
name: t('learning.featured.title', 'en'),
|
||||
level: 2
|
||||
})
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByText(t('learning.featured.author', 'en'))
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('renders every tutorial from the data source', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
name: t('learning.tutorials.heading', 'en'),
|
||||
level: 2
|
||||
})
|
||||
).toBeVisible()
|
||||
|
||||
for (const tutorial of learningTutorials) {
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: tutorialButtonName(tutorial.title.en, 'en')
|
||||
})
|
||||
).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('tutorials with a workflow link expose an external Try Workflow link', async ({
|
||||
page
|
||||
}) => {
|
||||
const linkedTutorials = learningTutorials.filter(
|
||||
(tutorial) => tutorial.href
|
||||
)
|
||||
const workflowLinks = page.getByRole('link', {
|
||||
name: t('cta.tryWorkflow', 'en')
|
||||
})
|
||||
const hrefs = await workflowLinks.evaluateAll((links) =>
|
||||
links.map((link) => link.getAttribute('href'))
|
||||
)
|
||||
for (const tutorial of linkedTutorials) {
|
||||
expect(hrefs).toContain(tutorial.href)
|
||||
}
|
||||
})
|
||||
|
||||
test('call to action links to contact sales', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
name: t('learning.cta.heading', 'en'),
|
||||
level: 2
|
||||
})
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('link', { name: t('learning.cta.contactSales', 'en') })
|
||||
).toHaveAttribute('href', '/contact')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Learning tutorial dialog', () => {
|
||||
test('opens a tutorial video and dismisses via the close button', async ({
|
||||
page
|
||||
}) => {
|
||||
const [firstTutorial] = learningTutorials
|
||||
await page.goto('/learning')
|
||||
|
||||
const openButton = page.getByRole('button', {
|
||||
name: tutorialButtonName(firstTutorial.title.en, 'en')
|
||||
})
|
||||
await openButton.scrollIntoViewIfNeeded()
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: firstTutorial.title.en })
|
||||
// TutorialsSection is hydrated via `client:visible`; retry the click until
|
||||
// Vue responds by opening the dialog.
|
||||
await expect(async () => {
|
||||
await openButton.click()
|
||||
await expect(dialog).toBeVisible({ timeout: 1_000 })
|
||||
}).toPass({ timeout: 10_000 })
|
||||
|
||||
await expect(
|
||||
dialog.getByRole('heading', { level: 2, name: firstTutorial.title.en })
|
||||
).toBeVisible()
|
||||
|
||||
await dialog
|
||||
.getByRole('button', { name: t('gallery.detail.close', 'en') })
|
||||
.click()
|
||||
await expect(dialog).toBeHidden()
|
||||
})
|
||||
|
||||
test('dismisses the dialog with the Escape key', async ({ page }) => {
|
||||
const [firstTutorial] = learningTutorials
|
||||
await page.goto('/learning')
|
||||
|
||||
const openButton = page.getByRole('button', {
|
||||
name: tutorialButtonName(firstTutorial.title.en, 'en')
|
||||
})
|
||||
await openButton.scrollIntoViewIfNeeded()
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: firstTutorial.title.en })
|
||||
await expect(async () => {
|
||||
await openButton.click()
|
||||
await expect(dialog).toBeVisible({ timeout: 1_000 })
|
||||
}).toPass({ timeout: 10_000 })
|
||||
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(dialog).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Learning page (zh-CN) @smoke', () => {
|
||||
test('renders localized title, headings, and tutorials', async ({ page }) => {
|
||||
await page.goto('/zh-CN/learning')
|
||||
|
||||
await expect(page).toHaveTitle('学习 — Comfy')
|
||||
await expect(page.getByRole('heading', { level: 1 })).toContainText(
|
||||
/[一-鿿]/
|
||||
)
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
name: t('learning.tutorials.heading', 'zh-CN'),
|
||||
level: 2
|
||||
})
|
||||
).toBeVisible()
|
||||
|
||||
const [firstTutorial] = learningTutorials
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: tutorialButtonName(firstTutorial.title['zh-CN'], 'zh-CN')
|
||||
})
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -17,8 +17,7 @@
|
||||
"test:visual:update": "playwright test --project visual --update-snapshots",
|
||||
"ashby:refresh-snapshot": "tsx ./scripts/refresh-ashby-snapshot.ts",
|
||||
"cloud-nodes:refresh-snapshot": "tsx ./scripts/refresh-cloud-nodes-snapshot.ts",
|
||||
"generate:models": "tsx ./scripts/generate-models.ts",
|
||||
"validate:jsonld": "tsx ./scripts/validate-jsonld.ts"
|
||||
"generate:models": "tsx ./scripts/generate-models.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/sitemap": "catalog:",
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,129 +0,0 @@
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { collectGraphIds } from '../src/utils/jsonLd'
|
||||
|
||||
const DIST_DIR = join(process.cwd(), 'dist')
|
||||
const JSON_LD_BLOCK =
|
||||
/<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi
|
||||
|
||||
interface Violation {
|
||||
file: string
|
||||
message: string
|
||||
}
|
||||
|
||||
function htmlFiles(dir: string): string[] {
|
||||
return readdirSync(dir, { recursive: true })
|
||||
.map(String)
|
||||
.filter((entry) => entry.endsWith('.html'))
|
||||
.map((entry) => join(dir, entry))
|
||||
}
|
||||
|
||||
function typesOf(node: Record<string, unknown>): string[] {
|
||||
const type = node['@type']
|
||||
if (typeof type === 'string') return [type]
|
||||
if (Array.isArray(type)) {
|
||||
return type.filter((t): t is string => typeof t === 'string')
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function hasValidPrice(node: Record<string, unknown>): boolean {
|
||||
const price = node.price
|
||||
const priceStr = price == null ? '' : String(price).trim()
|
||||
return priceStr !== '' && !Number.isNaN(Number(priceStr))
|
||||
}
|
||||
|
||||
function checkHonesty(
|
||||
value: unknown,
|
||||
file: string,
|
||||
violations: Violation[]
|
||||
): void {
|
||||
const walk = (node: unknown): void => {
|
||||
if (Array.isArray(node)) {
|
||||
node.forEach(walk)
|
||||
return
|
||||
}
|
||||
if (!node || typeof node !== 'object') return
|
||||
const record = node as Record<string, unknown>
|
||||
const types = typesOf(record)
|
||||
if (types.includes('Review') || types.includes('AggregateRating')) {
|
||||
violations.push({
|
||||
file,
|
||||
message: `dishonest node type ${types.join('/')}`
|
||||
})
|
||||
}
|
||||
if ('aggregateRating' in record || 'review' in record) {
|
||||
violations.push({
|
||||
file,
|
||||
message: 'node carries a review/aggregateRating'
|
||||
})
|
||||
}
|
||||
if (
|
||||
types.includes('Offer') &&
|
||||
(!hasValidPrice(record) || !record.priceCurrency)
|
||||
) {
|
||||
violations.push({
|
||||
file,
|
||||
message: 'Offer missing priceCurrency or a concrete price'
|
||||
})
|
||||
}
|
||||
Object.values(record).forEach(walk)
|
||||
}
|
||||
walk(value)
|
||||
}
|
||||
|
||||
function validateFile(file: string): Violation[] {
|
||||
const html = readFileSync(file, 'utf8')
|
||||
const violations: Violation[] = []
|
||||
const definedIds = new Set<string>()
|
||||
const referencedIds: string[] = []
|
||||
|
||||
for (const match of html.matchAll(JSON_LD_BLOCK)) {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(match[1])
|
||||
} catch (error) {
|
||||
violations.push({ file, message: `invalid JSON-LD: ${String(error)}` })
|
||||
continue
|
||||
}
|
||||
checkHonesty(parsed, file, violations)
|
||||
const { defined, references } = collectGraphIds(parsed)
|
||||
defined.forEach((id) => definedIds.add(id))
|
||||
referencedIds.push(...references)
|
||||
}
|
||||
|
||||
for (const id of referencedIds) {
|
||||
if (!definedIds.has(id)) {
|
||||
violations.push({ file, message: `unresolved @id reference: ${id}` })
|
||||
}
|
||||
}
|
||||
|
||||
return violations
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const files = htmlFiles(DIST_DIR)
|
||||
|
||||
if (files.length === 0) {
|
||||
console.error(
|
||||
`JSON-LD validation found no HTML in ${DIST_DIR} — build first.`
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const violations = files.flatMap(validateFile)
|
||||
if (violations.length > 0) {
|
||||
console.error(`JSON-LD validation failed (${violations.length} issue(s)):`)
|
||||
for (const { file, message } of violations) {
|
||||
console.error(` ${file.replace(DIST_DIR, 'dist')}: ${message}`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
process.stdout.write(
|
||||
`JSON-LD validation passed across ${files.length} page(s).\n`
|
||||
)
|
||||
}
|
||||
|
||||
main()
|
||||
@@ -1,41 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import HeroBackdrop01 from './HeroBackdrop01.vue'
|
||||
|
||||
const sampleImage =
|
||||
'https://images.unsplash.com/photo-1451187580459-43490279c0fa?auto=format&fit=crop&w=1600&q=80'
|
||||
|
||||
const meta: Meta<typeof HeroBackdrop01> = {
|
||||
title: 'Website/Blocks/HeroBackdrop01',
|
||||
component: HeroBackdrop01,
|
||||
tags: ['autodocs'],
|
||||
args: {
|
||||
backdrop: { type: 'image', src: sampleImage, alt: 'Abstract gradient' },
|
||||
title: 'Build anything\nwith ComfyUI',
|
||||
subtitle:
|
||||
'A powerful, modular visual interface for building and running AI workflows.'
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {}
|
||||
|
||||
export const WithBadge: Story = {
|
||||
args: {
|
||||
badgeText: 'New'
|
||||
}
|
||||
}
|
||||
|
||||
export const WithFootnote: Story = {
|
||||
args: {
|
||||
footnote: 'Available on Windows, macOS, and Linux.'
|
||||
}
|
||||
}
|
||||
|
||||
export const NoBackdrop: Story = {
|
||||
args: {
|
||||
backdrop: undefined
|
||||
}
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { prefersReducedMotion } from '../../composables/useReducedMotion'
|
||||
import ProductHeroBadge from '../common/ProductHeroBadge.vue'
|
||||
|
||||
type Backdrop =
|
||||
| { type: 'image'; src: string; alt?: string }
|
||||
| { type: 'video'; src: string; poster?: string; alt?: string }
|
||||
|
||||
const {
|
||||
backdrop,
|
||||
mobileBackdrop,
|
||||
badgeText,
|
||||
badgeLogoSrc,
|
||||
badgeLogoAlt,
|
||||
title,
|
||||
subtitle,
|
||||
footnote,
|
||||
class: className
|
||||
} = defineProps<{
|
||||
backdrop?: Backdrop
|
||||
mobileBackdrop?: Backdrop
|
||||
badgeText?: string
|
||||
badgeLogoSrc?: string
|
||||
badgeLogoAlt?: string
|
||||
title: string
|
||||
subtitle?: string
|
||||
footnote?: string
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
// Respect prefers-reduced-motion: don't autoplay the looping backdrop video
|
||||
// (WCAG 2.2.2). The paused video falls back to its poster/first frame.
|
||||
const reduceMotion = computed(() => prefersReducedMotion())
|
||||
|
||||
// Removing the reactive `autoplay` attribute only suppresses the *initial*
|
||||
// play; it can't pause a video the browser has already started. That is
|
||||
// exactly the SSR case: the server renders `autoplay` (it can't read the
|
||||
// client's motion preference), the browser begins playback on parse, and the
|
||||
// post-hydration attribute removal is too late. Pause on mount so
|
||||
// reduced-motion users get the poster frame instead of a looping video.
|
||||
const pauseIfReduced = (el: unknown) => {
|
||||
if (el instanceof HTMLVideoElement && reduceMotion.value) el.pause()
|
||||
}
|
||||
|
||||
// On mobile the backdrop is an in-flow rounded card above the content; on
|
||||
// desktop it is the full-bleed background behind it. A single element serves
|
||||
// both roles via responsive classes — mobileBackdrop only swaps the source.
|
||||
const sharedBackdropClass =
|
||||
'relative aspect-3/2 w-full rounded-3xl object-cover lg:absolute lg:inset-0 lg:aspect-auto lg:size-full lg:rounded-none'
|
||||
|
||||
// When both breakpoints use images, serve them from a single responsive <img>
|
||||
// so the browser fetches only the source matching the viewport. Two
|
||||
// `hidden`/`lg:hidden`-toggled <img> layers would each download (display:none
|
||||
// does not stop the fetch), doubling the high-priority load on an
|
||||
// LCP-critical hero. Videos or a mixed image/video pair can't collapse this
|
||||
// way and fall back to breakpoint-toggled layers below.
|
||||
const responsiveImage = computed(() => {
|
||||
if (backdrop?.type !== 'image') return null
|
||||
if (mobileBackdrop && mobileBackdrop.type !== 'image') return null
|
||||
const base = mobileBackdrop ?? backdrop
|
||||
return {
|
||||
src: base.src,
|
||||
alt: backdrop.alt ?? mobileBackdrop?.alt ?? '',
|
||||
// Larger-viewport source; omitted when one image serves both breakpoints.
|
||||
desktopSrc: mobileBackdrop ? backdrop.src : undefined
|
||||
}
|
||||
})
|
||||
|
||||
// Fallback for videos and mixed image/video pairs: toggle assets by breakpoint.
|
||||
const backdropLayers = computed(() => {
|
||||
if (!backdrop) return []
|
||||
if (mobileBackdrop) {
|
||||
return [
|
||||
{
|
||||
backdrop: mobileBackdrop,
|
||||
class: 'relative aspect-3/2 w-full rounded-3xl object-cover lg:hidden'
|
||||
},
|
||||
{
|
||||
backdrop,
|
||||
class: 'absolute inset-0 hidden size-full object-cover lg:block'
|
||||
}
|
||||
]
|
||||
}
|
||||
return [{ backdrop, class: sharedBackdropClass }]
|
||||
})
|
||||
|
||||
const scrimShape = 'farthest-side at 50% 50%'
|
||||
const scrimStyle = {
|
||||
background: `radial-gradient(${scrimShape}, color-mix(in srgb, var(--color-primary-warm-white) 80%, transparent) 0%, transparent 80%)`,
|
||||
maskImage: `radial-gradient(${scrimShape}, #000 45%, transparent 90%)`,
|
||||
WebkitMaskImage: `radial-gradient(${scrimShape}, #000 45%, transparent 90%)`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
:class="cn('max-w-9xl mx-auto px-4 pt-4 lg:px-6 lg:pt-6', className)"
|
||||
>
|
||||
<div class="relative overflow-hidden rounded-3xl">
|
||||
<slot name="backdrop">
|
||||
<picture v-if="responsiveImage" class="contents">
|
||||
<source
|
||||
v-if="responsiveImage.desktopSrc"
|
||||
:srcset="responsiveImage.desktopSrc"
|
||||
media="(min-width: 1024px)"
|
||||
/>
|
||||
<img
|
||||
:src="responsiveImage.src"
|
||||
:alt="responsiveImage.alt"
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
:class="sharedBackdropClass"
|
||||
/>
|
||||
</picture>
|
||||
|
||||
<template v-else>
|
||||
<template v-for="(layer, i) in backdropLayers" :key="i">
|
||||
<video
|
||||
v-if="layer.backdrop.type === 'video'"
|
||||
:ref="pauseIfReduced"
|
||||
:src="layer.backdrop.src"
|
||||
:poster="layer.backdrop.poster"
|
||||
:aria-label="layer.backdrop.alt"
|
||||
:aria-hidden="layer.backdrop.alt ? undefined : true"
|
||||
:autoplay="!reduceMotion"
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
preload="metadata"
|
||||
:class="layer.class"
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
:src="layer.backdrop.src"
|
||||
:alt="layer.backdrop.alt ?? ''"
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
:class="layer.class"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</slot>
|
||||
|
||||
<div
|
||||
class="relative flex flex-col justify-center px-0 pt-6 pb-8 lg:min-h-176 lg:px-16 lg:py-24"
|
||||
>
|
||||
<div class="relative w-full max-w-xl">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute -inset-12 hidden backdrop-blur-md lg:-inset-16 lg:block"
|
||||
:style="scrimStyle"
|
||||
/>
|
||||
|
||||
<div class="relative">
|
||||
<ProductHeroBadge
|
||||
v-if="badgeText"
|
||||
:text="badgeText"
|
||||
:logo-src="badgeLogoSrc"
|
||||
:logo-alt="badgeLogoAlt"
|
||||
/>
|
||||
|
||||
<h1
|
||||
class="mt-10 text-4xl/tight font-light tracking-tight whitespace-pre-line text-primary-comfy-canvas lg:text-6xl/tight lg:text-primary-comfy-ink"
|
||||
>
|
||||
{{ title }}
|
||||
</h1>
|
||||
|
||||
<p
|
||||
v-if="subtitle"
|
||||
class="mt-8 max-w-md text-base text-primary-comfy-canvas lg:text-lg lg:text-primary-comfy-ink"
|
||||
>
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="footnote"
|
||||
class="mt-10 text-sm text-primary-comfy-canvas lg:text-primary-comfy-ink"
|
||||
>
|
||||
{{ footnote }}
|
||||
</p>
|
||||
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,12 +0,0 @@
|
||||
---
|
||||
import type { JsonLdGraph } from '../../utils/jsonLd'
|
||||
import { escapeJsonLd } from '../../utils/escapeJsonLd'
|
||||
|
||||
interface Props {
|
||||
graph: JsonLdGraph
|
||||
}
|
||||
|
||||
const { graph } = Astro.props
|
||||
---
|
||||
|
||||
<script is:inline type="application/ld+json" set:html={escapeJsonLd(graph)} />
|
||||
@@ -1,53 +0,0 @@
|
||||
import { t } from '../i18n/translations'
|
||||
import type { Locale, TranslationKey } from '../i18n/translations'
|
||||
import { externalLinks } from './routes'
|
||||
|
||||
interface PricingTier {
|
||||
slug: string
|
||||
labelKey: TranslationKey
|
||||
priceKey: TranslationKey
|
||||
}
|
||||
|
||||
const tiers: PricingTier[] = [
|
||||
{
|
||||
slug: 'standard',
|
||||
labelKey: 'pricing.plan.standard.label',
|
||||
priceKey: 'pricing.plan.standard.price'
|
||||
},
|
||||
{
|
||||
slug: 'creator',
|
||||
labelKey: 'pricing.plan.creator.label',
|
||||
priceKey: 'pricing.plan.creator.price'
|
||||
},
|
||||
{
|
||||
slug: 'pro',
|
||||
labelKey: 'pricing.plan.pro.label',
|
||||
priceKey: 'pricing.plan.pro.price'
|
||||
}
|
||||
]
|
||||
|
||||
export interface PricingOffer {
|
||||
name: string
|
||||
price: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export function pricingOffers(locale: Locale): PricingOffer[] {
|
||||
return tiers.flatMap((tier) => {
|
||||
const display = t(tier.priceKey, locale).trim()
|
||||
const match = /^\$(\d+(?:\.\d+)?)$/.exec(display)
|
||||
if (!match) {
|
||||
console.warn(
|
||||
`pricingOffers: skipping tier "${tier.slug}" (${locale}) — price "${display}" is not a plain USD amount`
|
||||
)
|
||||
return []
|
||||
}
|
||||
return [
|
||||
{
|
||||
name: t(tier.labelKey, locale),
|
||||
price: match[1],
|
||||
url: `${externalLinks.cloud}/cloud/subscribe?tier=${tier.slug}&cycle=monthly`
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
@@ -82,19 +82,14 @@ export const externalLinks = {
|
||||
docsApi: 'https://docs.comfy.org/development/cloud/overview#quick-start',
|
||||
docsMcp: 'https://docs.comfy.org/agent-tools/cloud',
|
||||
docsSubscription: 'https://docs.comfy.org/support/subscription/subscribing',
|
||||
g2ComfyUi: 'https://www.g2.com/products/comfyui',
|
||||
github: 'https://github.com/Comfy-Org/ComfyUI',
|
||||
githubInstall: 'https://github.com/Comfy-Org/ComfyUI#installing',
|
||||
instagram: 'https://www.instagram.com/comfyui/',
|
||||
linkedin: 'https://www.linkedin.com/company/comfyui',
|
||||
mcpSkills: 'https://github.com/Comfy-Org/comfy-skills',
|
||||
platform: 'https://platform.comfy.org',
|
||||
platformUsage: 'https://platform.comfy.org/profile/usage',
|
||||
reddit: 'https://www.reddit.com/r/comfyui/',
|
||||
support: 'https://support.comfy.org/hc/en-us',
|
||||
wikidataComfyOrg: 'https://www.wikidata.org/wiki/Q130598554',
|
||||
wikidataComfyUi: 'https://www.wikidata.org/wiki/Q127798647',
|
||||
wikipediaComfyUi: 'https://en.wikipedia.org/wiki/ComfyUI',
|
||||
workflows: 'https://comfy.org/workflows',
|
||||
x: 'https://x.com/ComfyUI',
|
||||
youtube: 'https://www.youtube.com/@ComfyOrg'
|
||||
|
||||
@@ -2190,13 +2190,6 @@ const translations = {
|
||||
'nav.ctaCloudPrefix': { en: 'LAUNCH', 'zh-CN': '启动' },
|
||||
'nav.ctaCloudCore': { en: 'CLOUD', 'zh-CN': '云端' },
|
||||
'nav.home': { en: 'Comfy home', 'zh-CN': 'Comfy 首页' },
|
||||
'breadcrumb.home': { en: 'Home', 'zh-CN': '首页' },
|
||||
'breadcrumb.about': { en: 'About Us', 'zh-CN': '关于我们' },
|
||||
'breadcrumb.contact': { en: 'Contact', 'zh-CN': '联系我们' },
|
||||
'breadcrumb.download': { en: 'Download', 'zh-CN': '下载' },
|
||||
'breadcrumb.careers': { en: 'Careers', 'zh-CN': '招聘' },
|
||||
'breadcrumb.pricing': { en: 'Pricing', 'zh-CN': '定价' },
|
||||
'breadcrumb.supportedNodes': { en: 'Supported Nodes', 'zh-CN': '支持的节点' },
|
||||
'nav.menu': { en: 'Menu', 'zh-CN': '菜单' },
|
||||
'nav.toggleMenu': { en: 'Toggle menu', 'zh-CN': '切换菜单' },
|
||||
'nav.close': { en: 'Close', 'zh-CN': '关闭' },
|
||||
@@ -4068,6 +4061,7 @@ const translations = {
|
||||
en: 'This page is being redesigned. Check back soon.',
|
||||
'zh-CN': '此页面正在重新设计中,请稍后再来。'
|
||||
},
|
||||
'demos.breadcrumb.home': { en: 'Home', 'zh-CN': '首页' },
|
||||
'demos.breadcrumb.demos': { en: 'Demos', 'zh-CN': '演示' },
|
||||
|
||||
'customers.story.whatsNext': {
|
||||
@@ -4163,6 +4157,10 @@ const translations = {
|
||||
en: "Run the world's leading AI models in ComfyUI",
|
||||
'zh-CN': '在 ComfyUI 中运行世界领先的 AI 模型'
|
||||
},
|
||||
'models.breadcrumb.home': {
|
||||
en: 'Home',
|
||||
'zh-CN': '首页'
|
||||
},
|
||||
'models.breadcrumb.models': {
|
||||
en: 'Supported Models',
|
||||
'zh-CN': '支持的模型'
|
||||
|
||||
@@ -14,10 +14,8 @@ import {
|
||||
createBannerVersion,
|
||||
evaluateBannerVisibility
|
||||
} from '../utils/banner'
|
||||
import { escapeJsonLd } from '../utils/escapeJsonLd'
|
||||
import { fetchGitHubStars, formatStarCount } from '../utils/github'
|
||||
import { buildPageGraph, pageContext } from '../utils/jsonLd'
|
||||
import type { Crumb, JsonLdNode, WebPageType } from '../utils/jsonLd'
|
||||
import JsonLdGraph from '../components/common/JsonLdGraph.astro'
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
@@ -25,10 +23,6 @@ interface Props {
|
||||
keywords?: string[]
|
||||
ogImage?: string
|
||||
noindex?: boolean
|
||||
pageType?: WebPageType
|
||||
breadcrumbs?: Crumb[]
|
||||
mainEntityId?: string
|
||||
extraJsonLd?: (JsonLdNode | null | undefined)[]
|
||||
}
|
||||
|
||||
const {
|
||||
@@ -37,21 +31,15 @@ const {
|
||||
keywords,
|
||||
ogImage = 'https://media.comfy.org/website/comfy.webp',
|
||||
noindex = false,
|
||||
pageType,
|
||||
breadcrumbs,
|
||||
mainEntityId,
|
||||
extraJsonLd,
|
||||
} = Astro.props
|
||||
|
||||
const keywordsContent = keywords && keywords.length > 0 ? keywords.join(', ') : undefined
|
||||
|
||||
const { siteUrl, locale, url } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const canonicalURL = new URL(url)
|
||||
const ogImageURL = new URL(ogImage, Astro.site ?? 'https://comfy.org')
|
||||
const siteBase = Astro.site ?? 'https://comfy.org'
|
||||
const canonicalURL = new URL(Astro.url.pathname, siteBase)
|
||||
const ogImageURL = new URL(ogImage, siteBase)
|
||||
const rawLocale = Astro.currentLocale ?? 'en'
|
||||
const locale: Locale = rawLocale === 'zh-CN' ? 'zh-CN' : 'en'
|
||||
const rawStars = await fetchGitHubStars('Comfy-Org', 'ComfyUI')
|
||||
const githubStars = rawStars ? formatStarCount(rawStars) : ''
|
||||
|
||||
@@ -70,21 +58,28 @@ const bannerVersion = createBannerVersion(bannerData, locale)
|
||||
const gtmId = 'GTM-NP9JM6K7'
|
||||
const gtmEnabled = import.meta.env.PROD
|
||||
|
||||
const structuredData = noindex
|
||||
? undefined
|
||||
: buildPageGraph(
|
||||
{ siteUrl, locale },
|
||||
{
|
||||
url,
|
||||
name: title,
|
||||
description,
|
||||
imageUrl: ogImageURL.href,
|
||||
type: pageType,
|
||||
crumbs: breadcrumbs,
|
||||
mainEntityId,
|
||||
},
|
||||
...(extraJsonLd ?? []),
|
||||
)
|
||||
const organizationJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Organization',
|
||||
name: 'Comfy Org',
|
||||
url: 'https://comfy.org',
|
||||
logo: 'https://comfy.org/icons/logomark.svg',
|
||||
sameAs: [
|
||||
'https://github.com/comfyanonymous/ComfyUI',
|
||||
'https://discord.gg/comfyorg',
|
||||
'https://x.com/comaboratory',
|
||||
'https://reddit.com/r/comfyui',
|
||||
'https://linkedin.com/company/comfyorg',
|
||||
'https://instagram.com/comfyorg',
|
||||
],
|
||||
}
|
||||
|
||||
const websiteJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebSite',
|
||||
name: 'Comfy',
|
||||
url: 'https://comfy.org',
|
||||
}
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
@@ -126,7 +121,10 @@ const structuredData = noindex
|
||||
<meta name="twitter:image" content={ogImageURL.href} />
|
||||
|
||||
<!-- Structured Data -->
|
||||
{structuredData && <JsonLdGraph graph={structuredData} />}
|
||||
<script is:inline type="application/ld+json" set:html={escapeJsonLd(organizationJsonLd)} />
|
||||
<script is:inline type="application/ld+json" set:html={escapeJsonLd(websiteJsonLd)} />
|
||||
<slot name="head" />
|
||||
|
||||
<slot name="head" />
|
||||
|
||||
<!-- Google Tag Manager -->
|
||||
@@ -146,6 +144,7 @@ const structuredData = noindex
|
||||
)}
|
||||
|
||||
<ClientRouter />
|
||||
<slot name="head" />
|
||||
|
||||
<!-- Hide an already-dismissed announcement banner before first paint (no flash/shift). -->
|
||||
{bannerVisible && (
|
||||
|
||||
@@ -5,25 +5,9 @@ import StorySection from '../components/about/StorySection.vue'
|
||||
import OurValuesSection from '../components/about/OurValuesSection.vue'
|
||||
import ValuesSection from '../components/about/ValuesSection.vue'
|
||||
import CareersSection from '../components/about/CareersSection.vue'
|
||||
import { t } from '../i18n/translations'
|
||||
import { absoluteUrl, organizationId, pageContext } from '../utils/jsonLd'
|
||||
|
||||
const { siteUrl, locale } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="About Us — Comfy"
|
||||
pageType="AboutPage"
|
||||
mainEntityId={organizationId(siteUrl)}
|
||||
breadcrumbs={[
|
||||
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
|
||||
{ name: t('breadcrumb.about', locale) },
|
||||
]}
|
||||
>
|
||||
<BaseLayout title="About Us — Comfy">
|
||||
<HeroSection client:load />
|
||||
<StorySection />
|
||||
<OurValuesSection />
|
||||
|
||||
@@ -9,36 +9,34 @@ import HeroSection from '../../templates/affiliate/HeroSection.vue'
|
||||
import HowItWorksSection from '../../templates/affiliate/HowItWorksSection.vue'
|
||||
import { affiliateFaqs } from '../../data/affiliateFaq'
|
||||
import { t } from '../../i18n/translations'
|
||||
import type { JsonLdNode } from '../../utils/jsonLd'
|
||||
import { absoluteUrl, jsonLdId, pageContext } from '../../utils/jsonLd'
|
||||
|
||||
const pageTitle = t('affiliate.page.title', 'en')
|
||||
const pageDescription = t('affiliate.page.description', 'en')
|
||||
const { locale, url } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const faqPage: JsonLdNode = {
|
||||
const locale = 'en' as const
|
||||
|
||||
const faqJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
'@id': jsonLdId(url, 'faq'),
|
||||
mainEntity: affiliateFaqs.map((faq) => ({
|
||||
'@type': 'Question',
|
||||
name: faq.question[locale],
|
||||
acceptedAnswer: { '@type': 'Answer', text: faq.answer[locale] },
|
||||
})),
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: faq.answer[locale]
|
||||
}
|
||||
}))
|
||||
}
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={pageTitle}
|
||||
description={pageDescription}
|
||||
breadcrumbs={[
|
||||
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
|
||||
{ name: pageTitle },
|
||||
]}
|
||||
extraJsonLd={[faqPage]}
|
||||
title={t('affiliate.page.title', locale)}
|
||||
description={t('affiliate.page.description', locale)}
|
||||
>
|
||||
<Fragment slot="head">
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(faqJsonLd)}
|
||||
/>
|
||||
</Fragment>
|
||||
|
||||
<HeroSection />
|
||||
<HowItWorksSection />
|
||||
|
||||
@@ -7,13 +7,6 @@ import TeamPhotosSection from '../components/careers/TeamPhotosSection.vue'
|
||||
import FAQSection from '../components/common/FAQSection.vue'
|
||||
import { fetchRolesForBuild } from '../utils/ashby'
|
||||
import { reportAshbyOutcome } from '../utils/ashby.ci'
|
||||
import { t } from '../i18n/translations'
|
||||
import {
|
||||
absoluteUrl,
|
||||
itemListNode,
|
||||
jsonLdId,
|
||||
pageContext,
|
||||
} from '../utils/jsonLd'
|
||||
|
||||
const outcome = await fetchRolesForBuild()
|
||||
reportAshbyOutcome(outcome)
|
||||
@@ -26,31 +19,11 @@ if (outcome.status === 'failed') {
|
||||
}
|
||||
|
||||
const departments = outcome.snapshot.departments
|
||||
|
||||
const { siteUrl, locale, url } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const roles = itemListNode(
|
||||
url,
|
||||
t('breadcrumb.careers', locale),
|
||||
departments.flatMap((department) =>
|
||||
department.roles.map((role) => ({ name: role.title, url: role.jobUrl })),
|
||||
),
|
||||
)
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Careers — Comfy"
|
||||
description="Join the team building the operating system for generative AI. Open roles in engineering, design, marketing, and more."
|
||||
pageType="CollectionPage"
|
||||
mainEntityId={jsonLdId(url, 'itemlist')}
|
||||
breadcrumbs={[
|
||||
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
|
||||
{ name: t('breadcrumb.careers', locale) },
|
||||
]}
|
||||
extraJsonLd={[roles]}
|
||||
>
|
||||
<HeroSection />
|
||||
<RolesSection departments={departments} client:visible />
|
||||
|
||||
@@ -2,41 +2,9 @@
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import PriceSection from '../../components/pricing/PriceSection.vue'
|
||||
import WhatsIncludedSection from '../../components/pricing/WhatsIncludedSection.vue'
|
||||
import { pricingOffers } from '../../config/pricing'
|
||||
import { t } from '../../i18n/translations'
|
||||
import {
|
||||
absoluteUrl,
|
||||
jsonLdId,
|
||||
pageContext,
|
||||
productNode,
|
||||
} from '../../utils/jsonLd'
|
||||
|
||||
const { siteUrl, locale, url } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const productId = jsonLdId(url, 'product')
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Pricing — Comfy Cloud"
|
||||
mainEntityId={productId}
|
||||
breadcrumbs={[
|
||||
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
|
||||
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/cloud') },
|
||||
{ name: t('breadcrumb.pricing', locale) },
|
||||
]}
|
||||
extraJsonLd={[
|
||||
productNode({
|
||||
siteUrl,
|
||||
id: productId,
|
||||
name: 'Comfy Cloud',
|
||||
url,
|
||||
offers: pricingOffers(locale),
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<BaseLayout title="Pricing — Comfy Cloud">
|
||||
<PriceSection client:load />
|
||||
<WhatsIncludedSection />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -4,44 +4,39 @@ import HeroSection from '../../components/cloud-nodes/HeroSection.vue'
|
||||
import PackGridSection from '../../components/cloud-nodes/PackGridSection.vue'
|
||||
import { t } from '../../i18n/translations'
|
||||
import { loadPacksForBuild } from '../../utils/cloudNodes.build'
|
||||
import {
|
||||
absoluteUrl,
|
||||
itemListNode,
|
||||
jsonLdId,
|
||||
pageContext,
|
||||
} from '../../utils/jsonLd'
|
||||
import { escapeJsonLd } from '../../utils/escapeJsonLd'
|
||||
|
||||
const packs = await loadPacksForBuild()
|
||||
|
||||
const title = t('cloudNodes.meta.title', 'en')
|
||||
const description = t('cloudNodes.meta.description', 'en')
|
||||
const { url, locale } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const packList = itemListNode(
|
||||
url,
|
||||
title,
|
||||
packs.map((pack) => ({
|
||||
const siteBase = Astro.site ?? new URL('https://comfy.org')
|
||||
const pageUrl = new URL('/cloud/supported-nodes', siteBase).href
|
||||
|
||||
const itemListJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'ItemList',
|
||||
name: 'Custom-node packs supported on Comfy Cloud',
|
||||
url: pageUrl,
|
||||
numberOfItems: packs.length,
|
||||
itemListElement: packs.map((pack, index) => ({
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
url: new URL(`/cloud/supported-nodes/${pack.id}`, siteBase).href,
|
||||
name: pack.displayName,
|
||||
url: absoluteUrl(Astro.site, `/cloud/supported-nodes/${pack.id}`),
|
||||
})),
|
||||
)
|
||||
image: pack.bannerUrl || pack.iconUrl
|
||||
}))
|
||||
}
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={title}
|
||||
description={description}
|
||||
pageType="CollectionPage"
|
||||
mainEntityId={jsonLdId(url, 'itemlist')}
|
||||
breadcrumbs={[
|
||||
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
|
||||
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/cloud') },
|
||||
{ name: t('breadcrumb.supportedNodes', locale) },
|
||||
]}
|
||||
extraJsonLd={[packList]}
|
||||
title={t('cloudNodes.meta.title', 'en')}
|
||||
description={t('cloudNodes.meta.description', 'en')}
|
||||
>
|
||||
<script
|
||||
is:inline
|
||||
slot="head"
|
||||
type="application/ld+json"
|
||||
set:html={escapeJsonLd(itemListJsonLd)}
|
||||
/>
|
||||
<HeroSection client:visible />
|
||||
<PackGridSection packs={packs} client:visible />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -7,12 +7,7 @@ import PackDetail from '../../../components/cloud-nodes/PackDetail.vue'
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import { loadPacksForBuild } from '../../../utils/cloudNodes.build'
|
||||
import {
|
||||
absoluteUrl,
|
||||
jsonLdId,
|
||||
pageContext,
|
||||
softwareApplicationNode,
|
||||
} from '../../../utils/jsonLd'
|
||||
import { escapeJsonLd } from '../../../utils/escapeJsonLd'
|
||||
|
||||
export const getStaticPaths: GetStaticPaths = async () => {
|
||||
const packs = await loadPacksForBuild()
|
||||
@@ -34,45 +29,35 @@ const metaDescription = t('cloudNodes.detail.metaDescription', 'en')
|
||||
.replace('{nodeCount}', String(pack.nodes.length))
|
||||
.replace('{description}', description)
|
||||
|
||||
const { siteUrl, locale, url } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const softwareId = jsonLdId(url, 'software')
|
||||
const software = softwareApplicationNode({
|
||||
siteUrl,
|
||||
id: softwareId,
|
||||
const siteBase = Astro.site ?? new URL('https://comfy.org')
|
||||
const pageUrl = new URL(`/cloud/supported-nodes/${pack.id}`, siteBase).href
|
||||
|
||||
const softwareJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareApplication',
|
||||
name: pack.displayName,
|
||||
url,
|
||||
applicationCategory: 'DeveloperApplication',
|
||||
applicationSubCategory: 'ComfyUI custom-node pack',
|
||||
operatingSystem: 'Comfy Cloud (managed)',
|
||||
description: pack.description || undefined,
|
||||
url: pageUrl,
|
||||
description,
|
||||
image: pack.bannerUrl || pack.iconUrl,
|
||||
softwareVersion: pack.latestVersion,
|
||||
license: pack.license,
|
||||
codeRepository: pack.repoUrl,
|
||||
authorName: pack.publisher?.name,
|
||||
isFree: true,
|
||||
})
|
||||
author: pack.publisher?.name
|
||||
? { '@type': 'Person', name: pack.publisher.name }
|
||||
: undefined,
|
||||
offers: { '@type': 'Offer', price: 0, priceCurrency: 'USD' }
|
||||
}
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={title}
|
||||
description={metaDescription}
|
||||
ogImage={pack.bannerUrl}
|
||||
mainEntityId={softwareId}
|
||||
breadcrumbs={[
|
||||
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
|
||||
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/cloud') },
|
||||
{
|
||||
name: t('breadcrumb.supportedNodes', locale),
|
||||
url: absoluteUrl(Astro.site, '/cloud/supported-nodes'),
|
||||
},
|
||||
{ name: pack.displayName },
|
||||
]}
|
||||
extraJsonLd={[software]}
|
||||
>
|
||||
<BaseLayout title={title} description={metaDescription} ogImage={pack.bannerUrl}>
|
||||
<script
|
||||
is:inline
|
||||
slot="head"
|
||||
type="application/ld+json"
|
||||
set:html={escapeJsonLd(softwareJsonLd)}
|
||||
/>
|
||||
<PackDetail pack={pack} />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -2,25 +2,9 @@
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import FormSection from '../components/contact/FormSection.vue'
|
||||
import SocialProofBarSection from '../components/common/SocialProofBarSection.vue'
|
||||
import { t } from '../i18n/translations'
|
||||
import { absoluteUrl, organizationId, pageContext } from '../utils/jsonLd'
|
||||
|
||||
const { siteUrl, locale } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Contact — Comfy"
|
||||
pageType="ContactPage"
|
||||
mainEntityId={organizationId(siteUrl)}
|
||||
breadcrumbs={[
|
||||
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
|
||||
{ name: t('breadcrumb.contact', locale) },
|
||||
]}
|
||||
>
|
||||
<BaseLayout title="Contact — Comfy">
|
||||
<FormSection client:load />
|
||||
<SocialProofBarSection />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -7,13 +7,6 @@ import DemoTranscript from '../../components/demos/DemoTranscript.vue'
|
||||
import DemoNavSection from '../../components/demos/DemoNavSection.vue'
|
||||
import { demos, getDemoBySlug, getNextDemo } from '../../config/demos'
|
||||
import { t } from '../../i18n/translations'
|
||||
import type { JsonLdNode } from '../../utils/jsonLd'
|
||||
import {
|
||||
absoluteUrl,
|
||||
jsonLdId,
|
||||
organizationId,
|
||||
pageContext,
|
||||
} from '../../utils/jsonLd'
|
||||
|
||||
export const getStaticPaths: GetStaticPaths = () => {
|
||||
return demos.map((demo) => ({
|
||||
@@ -26,34 +19,68 @@ const demo = getDemoBySlug(slug as string)!
|
||||
const nextDemo = getNextDemo(slug as string)
|
||||
const title = t(demo.title)
|
||||
const description = t(demo.description)
|
||||
const canonicalURL = new URL(`/demos/${demo.slug}`, Astro.site)
|
||||
|
||||
const { siteUrl, locale, url } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const educationalLevel =
|
||||
demo.difficulty === 'beginner'
|
||||
const howToJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'HowTo',
|
||||
name: title,
|
||||
description,
|
||||
image: new URL(demo.ogImage, Astro.site).href,
|
||||
totalTime: demo.durationIso,
|
||||
datePublished: demo.publishedDate,
|
||||
dateModified: demo.modifiedDate,
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
name: 'Comfy Org',
|
||||
url: 'https://comfy.org'
|
||||
}
|
||||
}
|
||||
|
||||
const learningResourceJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'LearningResource',
|
||||
name: title,
|
||||
description,
|
||||
learningResourceType: 'interactive tutorial',
|
||||
interactivityType: 'active',
|
||||
educationalLevel: demo.difficulty === 'beginner'
|
||||
? 'Beginner'
|
||||
: demo.difficulty === 'intermediate'
|
||||
? 'Intermediate'
|
||||
: 'Advanced'
|
||||
const learningId = jsonLdId(url, 'learning')
|
||||
const learningResource: JsonLdNode = {
|
||||
'@type': 'LearningResource',
|
||||
'@id': learningId,
|
||||
name: title,
|
||||
description,
|
||||
url,
|
||||
image: new URL(demo.ogImage, Astro.site).href,
|
||||
learningResourceType: 'interactive tutorial',
|
||||
interactivityType: 'active',
|
||||
educationalLevel,
|
||||
timeRequired: demo.durationIso,
|
||||
: 'Advanced',
|
||||
url: canonicalURL.href,
|
||||
datePublished: demo.publishedDate,
|
||||
dateModified: demo.modifiedDate,
|
||||
isPartOf: { '@id': jsonLdId(url, 'webpage') },
|
||||
author: { '@id': organizationId(siteUrl) },
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
name: 'Comfy Org',
|
||||
url: 'https://comfy.org'
|
||||
}
|
||||
}
|
||||
|
||||
const breadcrumbJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 1,
|
||||
name: t('demos.breadcrumb.home'),
|
||||
item: 'https://comfy.org'
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 2,
|
||||
name: t('demos.breadcrumb.demos'),
|
||||
item: 'https://comfy.org/demos'
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 3,
|
||||
name: title
|
||||
}
|
||||
]
|
||||
}
|
||||
---
|
||||
|
||||
@@ -61,20 +88,25 @@ const learningResource: JsonLdNode = {
|
||||
title={`${title} — Comfy`}
|
||||
description={description}
|
||||
ogImage={demo.ogImage}
|
||||
mainEntityId={learningId}
|
||||
breadcrumbs={[
|
||||
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
|
||||
{
|
||||
name: t('demos.breadcrumb.demos', locale),
|
||||
url: absoluteUrl(Astro.site, '/demos'),
|
||||
},
|
||||
{ name: title },
|
||||
]}
|
||||
extraJsonLd={[learningResource]}
|
||||
>
|
||||
<Fragment slot="head">
|
||||
<meta property="article:published_time" content={demo.publishedDate} />
|
||||
<meta property="article:modified_time" content={demo.modifiedDate} />
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(howToJsonLd)}
|
||||
/>
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(learningResourceJsonLd)}
|
||||
/>
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(breadcrumbJsonLd)}
|
||||
/>
|
||||
<link rel="preconnect" href="https://demo.arcade.software" />
|
||||
</Fragment>
|
||||
|
||||
|
||||
@@ -8,29 +8,11 @@ import EcoSystemSection from '../components/product/local/EcoSystemSection.vue'
|
||||
import ProductCardsSection from '../components/product/local/ProductCardsSection.vue'
|
||||
import FAQSection from '../components/product/local/FAQSection.vue'
|
||||
import { t } from '../i18n/translations'
|
||||
import {
|
||||
absoluteUrl,
|
||||
comfyUiApplicationNode,
|
||||
comfyUiSoftwareId,
|
||||
pageContext,
|
||||
} from '../utils/jsonLd'
|
||||
|
||||
const { siteUrl, locale } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Download Comfy Desktop — Run AI on Your Hardware"
|
||||
description={t('download.hero.subtitle', 'en')}
|
||||
mainEntityId={comfyUiSoftwareId(siteUrl)}
|
||||
breadcrumbs={[
|
||||
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
|
||||
{ name: t('breadcrumb.download', locale) },
|
||||
]}
|
||||
extraJsonLd={[comfyUiApplicationNode(siteUrl)]}
|
||||
keywords={['comfyui app', 'comfyui desktop app', 'comfyui desktop', 'comfy ui application', 'comfyui download', 'download comfyui', 'comfyui windows', 'comfyui mac', 'comfyui linux']}
|
||||
>
|
||||
<CloudBannerSection />
|
||||
|
||||
@@ -9,28 +9,11 @@ import CaseStudySpotlightSection from "../components/home/CaseStudySpotlightSect
|
||||
import GetStartedSection from "../components/home/GetStartedSection.vue";
|
||||
import BuildWhatSection from "../components/home/BuildWhatSection.vue";
|
||||
import { t } from "../i18n/translations";
|
||||
import {
|
||||
comfyUiApplicationNode,
|
||||
comfyUiSoftwareId,
|
||||
comfyUiSourceCodeNode,
|
||||
pageContext,
|
||||
} from "../utils/jsonLd";
|
||||
|
||||
const { siteUrl } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
);
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Comfy — Professional Control of Visual AI"
|
||||
description={t("hero.subtitle", "en")}
|
||||
mainEntityId={comfyUiSoftwareId(siteUrl)}
|
||||
extraJsonLd={[
|
||||
comfyUiApplicationNode(siteUrl),
|
||||
comfyUiSourceCodeNode(siteUrl),
|
||||
]}
|
||||
keywords={[
|
||||
"comfyui app",
|
||||
"comfyui web app",
|
||||
|
||||
@@ -4,13 +4,6 @@ import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||
import ModelHeroSection from '../../../components/models/ModelHeroSection.vue'
|
||||
import { models, getModelBySlug } from '../../../config/models'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import type { JsonLdNode } from '../../../utils/jsonLd'
|
||||
import {
|
||||
absoluteUrl,
|
||||
jsonLdId,
|
||||
pageContext,
|
||||
softwareApplicationNode,
|
||||
} from '../../../utils/jsonLd'
|
||||
|
||||
export const getStaticPaths: GetStaticPaths = () => {
|
||||
return models.map((model) => ({
|
||||
@@ -26,6 +19,7 @@ if (model.canonicalSlug) {
|
||||
}
|
||||
|
||||
const { displayName } = model
|
||||
const canonicalURL = new URL(`/p/supported-models/${model.slug}`, Astro.site)
|
||||
|
||||
const dirDescriptions: Record<string, string> = {
|
||||
diffusion_models: 'a diffusion model that generates images or video from text and image prompts',
|
||||
@@ -46,31 +40,55 @@ const dirDescriptions: Record<string, string> = {
|
||||
const dirDesc = dirDescriptions[model.directory] ?? 'an AI model'
|
||||
const whatIsDescription = `${displayName} is ${dirDesc}. You can run it locally in ComfyUI with full control over every parameter, or access it through Comfy Cloud. ComfyUI's node-based workflow editor lets you connect ${displayName} with ControlNets, LoRAs, upscalers, and custom nodes to build any pipeline you need. There are ${model.workflowCount} community workflow templates using ${displayName} on Comfy Hub, ready to load and customize.`
|
||||
|
||||
const pageTitle = `${displayName} in ComfyUI`
|
||||
const pageDescription = `Run ${displayName} in ComfyUI with full parameter control. ${model.workflowCount} community workflow templates, step-by-step tutorials, and free local inference.`
|
||||
|
||||
const { siteUrl, locale, url } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const softwareId = jsonLdId(url, 'software')
|
||||
const software = softwareApplicationNode({
|
||||
siteUrl,
|
||||
id: softwareId,
|
||||
const softwareAppJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareApplication',
|
||||
name: displayName,
|
||||
url,
|
||||
applicationCategory: 'MultimediaApplication',
|
||||
operatingSystem: 'Any',
|
||||
})
|
||||
const faqPage: JsonLdNode = {
|
||||
url: canonicalURL.href,
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
name: 'Comfy Org',
|
||||
url: 'https://comfy.org'
|
||||
}
|
||||
}
|
||||
|
||||
const breadcrumbJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 1,
|
||||
name: t('models.breadcrumb.home'),
|
||||
item: 'https://comfy.org'
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 2,
|
||||
name: t('models.breadcrumb.models'),
|
||||
item: 'https://comfy.org/p/supported-models'
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 3,
|
||||
name: displayName
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const faqJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
'@id': jsonLdId(url, 'faq'),
|
||||
mainEntity: [
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: `What is ${displayName}?`,
|
||||
acceptedAnswer: { '@type': 'Answer', text: whatIsDescription },
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: whatIsDescription
|
||||
}
|
||||
},
|
||||
{
|
||||
'@type': 'Question',
|
||||
@@ -79,44 +97,54 @@ const faqPage: JsonLdNode = {
|
||||
'@type': 'Answer',
|
||||
text: model.docsUrl
|
||||
? `Follow the step-by-step tutorial at ${model.docsUrl}. You can also load any of the ${model.workflowCount} community workflow templates that use ${displayName} directly in ComfyUI.`
|
||||
: `Open ComfyUI and browse the ${model.workflowCount} community workflow templates that use ${displayName}. Load one as a starting point, then customize the nodes and parameters to fit your use case.`,
|
||||
},
|
||||
: `Open ComfyUI and browse the ${model.workflowCount} community workflow templates that use ${displayName}. Load one as a starting point, then customize the nodes and parameters to fit your use case.`
|
||||
}
|
||||
},
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: `How many ComfyUI workflows use ${displayName}?`,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: `There are ${model.workflowCount} community workflow templates that use ${displayName} on Comfy Hub. Each template is ready to run in ComfyUI and can be customized to suit your project.`,
|
||||
},
|
||||
text: `There are ${model.workflowCount} community workflow templates that use ${displayName} on Comfy Hub. Each template is ready to run in ComfyUI and can be customized to suit your project.`
|
||||
}
|
||||
},
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: `Is ${displayName} free to use in ComfyUI?`,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: `ComfyUI is free and open source. ${model.huggingFaceUrl ? `${displayName} weights are available to download from Hugging Face.` : `${displayName} is available as a cloud API through Comfy Cloud.`} You only pay for compute when running on Comfy Cloud; local inference on your own hardware is always free.`,
|
||||
},
|
||||
},
|
||||
],
|
||||
text: `ComfyUI is free and open source. ${model.huggingFaceUrl ? `${displayName} weights are available to download from Hugging Face.` : `${displayName} is available as a cloud API through Comfy Cloud.`} You only pay for compute when running on Comfy Cloud; local inference on your own hardware is always free.`
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const pageTitle = `${displayName} in ComfyUI`
|
||||
const pageDescription = `Run ${displayName} in ComfyUI with full parameter control. ${model.workflowCount} community workflow templates, step-by-step tutorials, and free local inference.`
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={`${pageTitle} — Comfy`}
|
||||
description={pageDescription}
|
||||
ogImage={model.thumbnailUrl}
|
||||
mainEntityId={softwareId}
|
||||
breadcrumbs={[
|
||||
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
|
||||
{
|
||||
name: t('models.breadcrumb.models', locale),
|
||||
url: absoluteUrl(Astro.site, '/p/supported-models'),
|
||||
},
|
||||
{ name: displayName },
|
||||
]}
|
||||
extraJsonLd={[software, faqPage]}
|
||||
>
|
||||
<Fragment slot="head">
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(softwareAppJsonLd)}
|
||||
/>
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(breadcrumbJsonLd)}
|
||||
/>
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(faqJsonLd)}
|
||||
/>
|
||||
</Fragment>
|
||||
|
||||
<ModelHeroSection
|
||||
displayName={displayName}
|
||||
|
||||
@@ -2,29 +2,10 @@
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||
import { models } from '../../../config/models'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import {
|
||||
absoluteUrl,
|
||||
itemListNode,
|
||||
jsonLdId,
|
||||
pageContext,
|
||||
} from '../../../utils/jsonLd'
|
||||
|
||||
const title = t('models.index.title')
|
||||
const subtitle = t('models.index.subtitle')
|
||||
|
||||
const { url, locale } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const modelList = itemListNode(
|
||||
url,
|
||||
title,
|
||||
models.map((model) => ({
|
||||
url: absoluteUrl(Astro.site, `/p/supported-models/${model.slug}`),
|
||||
})),
|
||||
)
|
||||
|
||||
const dirLabel: Record<string, string> = {
|
||||
diffusion_models: 'Diffusion',
|
||||
checkpoints: 'Checkpoint',
|
||||
@@ -45,13 +26,6 @@ const dirLabel: Record<string, string> = {
|
||||
<BaseLayout
|
||||
title={`${title} — Comfy`}
|
||||
description={subtitle}
|
||||
pageType="CollectionPage"
|
||||
mainEntityId={jsonLdId(url, 'itemlist')}
|
||||
breadcrumbs={[
|
||||
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
|
||||
{ name: title },
|
||||
]}
|
||||
extraJsonLd={[modelList]}
|
||||
>
|
||||
<div class="mx-auto max-w-7xl px-6 py-16 lg:px-8 lg:py-24">
|
||||
<header class="mb-12">
|
||||
|
||||
@@ -5,29 +5,9 @@ import StorySection from '../../components/about/StorySection.vue'
|
||||
import OurValuesSection from '../../components/about/OurValuesSection.vue'
|
||||
import ValuesSection from '../../components/about/ValuesSection.vue'
|
||||
import CareersSection from '../../components/about/CareersSection.vue'
|
||||
import { t } from '../../i18n/translations'
|
||||
import { absoluteUrl, organizationId, pageContext } from '../../utils/jsonLd'
|
||||
|
||||
const { siteUrl, locale } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="关于我们 — Comfy"
|
||||
description="了解 ComfyUI 背后的团队和使命——开源的生成式 AI 平台。"
|
||||
pageType="AboutPage"
|
||||
mainEntityId={organizationId(siteUrl)}
|
||||
breadcrumbs={[
|
||||
{
|
||||
name: t('breadcrumb.home', locale),
|
||||
url: absoluteUrl(Astro.site, '/zh-CN'),
|
||||
},
|
||||
{ name: t('breadcrumb.about', locale) },
|
||||
]}
|
||||
>
|
||||
<BaseLayout title="关于我们 — Comfy" description="了解 ComfyUI 背后的团队和使命——开源的生成式 AI 平台。">
|
||||
<HeroSection locale="zh-CN" client:load />
|
||||
<StorySection locale="zh-CN" />
|
||||
<OurValuesSection locale="zh-CN" />
|
||||
|
||||
@@ -7,13 +7,6 @@ import TeamPhotosSection from '../../components/careers/TeamPhotosSection.vue'
|
||||
import FAQSection from '../../components/common/FAQSection.vue'
|
||||
import { fetchRolesForBuild } from '../../utils/ashby'
|
||||
import { reportAshbyOutcome } from '../../utils/ashby.ci'
|
||||
import { t } from '../../i18n/translations'
|
||||
import {
|
||||
absoluteUrl,
|
||||
itemListNode,
|
||||
jsonLdId,
|
||||
pageContext,
|
||||
} from '../../utils/jsonLd'
|
||||
|
||||
const outcome = await fetchRolesForBuild()
|
||||
reportAshbyOutcome(outcome)
|
||||
@@ -26,34 +19,11 @@ if (outcome.status === 'failed') {
|
||||
}
|
||||
|
||||
const departments = outcome.snapshot.departments
|
||||
|
||||
const { siteUrl, locale, url } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const roles = itemListNode(
|
||||
url,
|
||||
t('breadcrumb.careers', locale),
|
||||
departments.flatMap((department) =>
|
||||
department.roles.map((role) => ({ name: role.title, url: role.jobUrl })),
|
||||
),
|
||||
)
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="招聘 — Comfy"
|
||||
description="加入构建生成式 AI 操作系统的团队。工程、设计、市场营销等岗位开放招聘中。"
|
||||
pageType="CollectionPage"
|
||||
mainEntityId={jsonLdId(url, 'itemlist')}
|
||||
breadcrumbs={[
|
||||
{
|
||||
name: t('breadcrumb.home', locale),
|
||||
url: absoluteUrl(Astro.site, '/zh-CN'),
|
||||
},
|
||||
{ name: t('breadcrumb.careers', locale) },
|
||||
]}
|
||||
extraJsonLd={[roles]}
|
||||
>
|
||||
<HeroSection locale="zh-CN" />
|
||||
<RolesSection locale="zh-CN" departments={departments} client:visible />
|
||||
|
||||
@@ -2,44 +2,9 @@
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||
import PriceSection from '../../../components/pricing/PriceSection.vue'
|
||||
import WhatsIncludedSection from '../../../components/pricing/WhatsIncludedSection.vue'
|
||||
import { pricingOffers } from '../../../config/pricing'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import {
|
||||
absoluteUrl,
|
||||
jsonLdId,
|
||||
pageContext,
|
||||
productNode,
|
||||
} from '../../../utils/jsonLd'
|
||||
|
||||
const { siteUrl, locale, url } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const productId = jsonLdId(url, 'product')
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="定价 — Comfy Cloud"
|
||||
mainEntityId={productId}
|
||||
breadcrumbs={[
|
||||
{
|
||||
name: t('breadcrumb.home', locale),
|
||||
url: absoluteUrl(Astro.site, '/zh-CN'),
|
||||
},
|
||||
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/zh-CN/cloud') },
|
||||
{ name: t('breadcrumb.pricing', locale) },
|
||||
]}
|
||||
extraJsonLd={[
|
||||
productNode({
|
||||
siteUrl,
|
||||
id: productId,
|
||||
name: 'Comfy Cloud',
|
||||
url,
|
||||
offers: pricingOffers(locale),
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<BaseLayout title="定价 — Comfy Cloud">
|
||||
<PriceSection locale="zh-CN" client:load />
|
||||
<WhatsIncludedSection locale="zh-CN" />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -4,47 +4,39 @@ import HeroSection from '../../../components/cloud-nodes/HeroSection.vue'
|
||||
import PackGridSection from '../../../components/cloud-nodes/PackGridSection.vue'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import { loadPacksForBuild } from '../../../utils/cloudNodes.build'
|
||||
import {
|
||||
absoluteUrl,
|
||||
itemListNode,
|
||||
jsonLdId,
|
||||
pageContext,
|
||||
} from '../../../utils/jsonLd'
|
||||
import { escapeJsonLd } from '../../../utils/escapeJsonLd'
|
||||
|
||||
const packs = await loadPacksForBuild()
|
||||
|
||||
const title = t('cloudNodes.meta.title', 'zh-CN')
|
||||
const description = t('cloudNodes.meta.description', 'zh-CN')
|
||||
const { url, locale } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const packList = itemListNode(
|
||||
url,
|
||||
title,
|
||||
packs.map((pack) => ({
|
||||
const siteBase = Astro.site ?? new URL('https://comfy.org')
|
||||
const pageUrl = new URL('/zh-CN/cloud/supported-nodes', siteBase).href
|
||||
|
||||
const itemListJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'ItemList',
|
||||
name: 'Comfy Cloud 支持的自定义节点包',
|
||||
url: pageUrl,
|
||||
numberOfItems: packs.length,
|
||||
itemListElement: packs.map((pack, index) => ({
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
url: new URL(`/zh-CN/cloud/supported-nodes/${pack.id}`, siteBase).href,
|
||||
name: pack.displayName,
|
||||
url: absoluteUrl(Astro.site, `/zh-CN/cloud/supported-nodes/${pack.id}`),
|
||||
})),
|
||||
)
|
||||
image: pack.bannerUrl || pack.iconUrl
|
||||
}))
|
||||
}
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={title}
|
||||
description={description}
|
||||
pageType="CollectionPage"
|
||||
mainEntityId={jsonLdId(url, 'itemlist')}
|
||||
breadcrumbs={[
|
||||
{
|
||||
name: t('breadcrumb.home', locale),
|
||||
url: absoluteUrl(Astro.site, '/zh-CN'),
|
||||
},
|
||||
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/zh-CN/cloud') },
|
||||
{ name: t('breadcrumb.supportedNodes', locale) },
|
||||
]}
|
||||
extraJsonLd={[packList]}
|
||||
title={t('cloudNodes.meta.title', 'zh-CN')}
|
||||
description={t('cloudNodes.meta.description', 'zh-CN')}
|
||||
>
|
||||
<script
|
||||
is:inline
|
||||
slot="head"
|
||||
type="application/ld+json"
|
||||
set:html={escapeJsonLd(itemListJsonLd)}
|
||||
/>
|
||||
<HeroSection locale="zh-CN" client:visible />
|
||||
<PackGridSection locale="zh-CN" packs={packs} client:visible />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -7,12 +7,7 @@ import PackDetail from '../../../../components/cloud-nodes/PackDetail.vue'
|
||||
import BaseLayout from '../../../../layouts/BaseLayout.astro'
|
||||
import { t } from '../../../../i18n/translations'
|
||||
import { loadPacksForBuild } from '../../../../utils/cloudNodes.build'
|
||||
import {
|
||||
absoluteUrl,
|
||||
jsonLdId,
|
||||
pageContext,
|
||||
softwareApplicationNode,
|
||||
} from '../../../../utils/jsonLd'
|
||||
import { escapeJsonLd } from '../../../../utils/escapeJsonLd'
|
||||
|
||||
export const getStaticPaths: GetStaticPaths = async () => {
|
||||
const packs = await loadPacksForBuild()
|
||||
@@ -34,48 +29,35 @@ const metaDescription = t('cloudNodes.detail.metaDescription', 'zh-CN')
|
||||
.replace('{nodeCount}', String(pack.nodes.length))
|
||||
.replace('{description}', description)
|
||||
|
||||
const { siteUrl, locale, url } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const softwareId = jsonLdId(url, 'software')
|
||||
const software = softwareApplicationNode({
|
||||
siteUrl,
|
||||
id: softwareId,
|
||||
const siteBase = Astro.site ?? new URL('https://comfy.org')
|
||||
const pageUrl = new URL(`/zh-CN/cloud/supported-nodes/${pack.id}`, siteBase).href
|
||||
|
||||
const softwareJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareApplication',
|
||||
name: pack.displayName,
|
||||
url,
|
||||
applicationCategory: 'DeveloperApplication',
|
||||
applicationSubCategory: 'ComfyUI custom-node pack',
|
||||
operatingSystem: 'Comfy Cloud (managed)',
|
||||
description: pack.description || undefined,
|
||||
url: pageUrl,
|
||||
description,
|
||||
image: pack.bannerUrl || pack.iconUrl,
|
||||
softwareVersion: pack.latestVersion,
|
||||
license: pack.license,
|
||||
codeRepository: pack.repoUrl,
|
||||
authorName: pack.publisher?.name,
|
||||
isFree: true,
|
||||
})
|
||||
author: pack.publisher?.name
|
||||
? { '@type': 'Person', name: pack.publisher.name }
|
||||
: undefined,
|
||||
offers: { '@type': 'Offer', price: 0, priceCurrency: 'USD' }
|
||||
}
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={title}
|
||||
description={metaDescription}
|
||||
ogImage={pack.bannerUrl}
|
||||
mainEntityId={softwareId}
|
||||
breadcrumbs={[
|
||||
{
|
||||
name: t('breadcrumb.home', locale),
|
||||
url: absoluteUrl(Astro.site, '/zh-CN'),
|
||||
},
|
||||
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/zh-CN/cloud') },
|
||||
{
|
||||
name: t('breadcrumb.supportedNodes', locale),
|
||||
url: absoluteUrl(Astro.site, '/zh-CN/cloud/supported-nodes'),
|
||||
},
|
||||
{ name: pack.displayName },
|
||||
]}
|
||||
extraJsonLd={[software]}
|
||||
>
|
||||
<BaseLayout title={title} description={metaDescription} ogImage={pack.bannerUrl}>
|
||||
<script
|
||||
is:inline
|
||||
slot="head"
|
||||
type="application/ld+json"
|
||||
set:html={escapeJsonLd(softwareJsonLd)}
|
||||
/>
|
||||
<PackDetail pack={pack} locale="zh-CN" />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -2,28 +2,9 @@
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import FormSection from '../../components/contact/FormSection.vue'
|
||||
import SocialProofBarSection from '../../components/common/SocialProofBarSection.vue'
|
||||
import { t } from '../../i18n/translations'
|
||||
import { absoluteUrl, organizationId, pageContext } from '../../utils/jsonLd'
|
||||
|
||||
const { siteUrl, locale } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="联系我们 — Comfy"
|
||||
pageType="ContactPage"
|
||||
mainEntityId={organizationId(siteUrl)}
|
||||
breadcrumbs={[
|
||||
{
|
||||
name: t('breadcrumb.home', locale),
|
||||
url: absoluteUrl(Astro.site, '/zh-CN'),
|
||||
},
|
||||
{ name: t('breadcrumb.contact', locale) },
|
||||
]}
|
||||
>
|
||||
<BaseLayout title="联系我们 — Comfy">
|
||||
<FormSection locale="zh-CN" client:load />
|
||||
<SocialProofBarSection />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -7,13 +7,6 @@ import DemoTranscript from '../../../components/demos/DemoTranscript.vue'
|
||||
import DemoNavSection from '../../../components/demos/DemoNavSection.vue'
|
||||
import { demos, getDemoBySlug, getNextDemo } from '../../../config/demos'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import type { JsonLdNode } from '../../../utils/jsonLd'
|
||||
import {
|
||||
absoluteUrl,
|
||||
jsonLdId,
|
||||
organizationId,
|
||||
pageContext,
|
||||
} from '../../../utils/jsonLd'
|
||||
|
||||
export const getStaticPaths: GetStaticPaths = () => {
|
||||
return demos.map((demo) => ({
|
||||
@@ -26,34 +19,68 @@ const demo = getDemoBySlug(slug as string)!
|
||||
const nextDemo = getNextDemo(slug as string)
|
||||
const title = t(demo.title, 'zh-CN')
|
||||
const description = t(demo.description, 'zh-CN')
|
||||
const canonicalURL = new URL(`/zh-CN/demos/${demo.slug}`, Astro.site)
|
||||
|
||||
const { siteUrl, locale, url } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const educationalLevel =
|
||||
demo.difficulty === 'beginner'
|
||||
const howToJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'HowTo',
|
||||
name: title,
|
||||
description,
|
||||
image: new URL(demo.ogImage, Astro.site).href,
|
||||
totalTime: demo.durationIso,
|
||||
datePublished: demo.publishedDate,
|
||||
dateModified: demo.modifiedDate,
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
name: 'Comfy Org',
|
||||
url: 'https://comfy.org'
|
||||
}
|
||||
}
|
||||
|
||||
const learningResourceJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'LearningResource',
|
||||
name: title,
|
||||
description,
|
||||
learningResourceType: 'interactive tutorial',
|
||||
interactivityType: 'active',
|
||||
educationalLevel: demo.difficulty === 'beginner'
|
||||
? 'Beginner'
|
||||
: demo.difficulty === 'intermediate'
|
||||
? 'Intermediate'
|
||||
: 'Advanced'
|
||||
const learningId = jsonLdId(url, 'learning')
|
||||
const learningResource: JsonLdNode = {
|
||||
'@type': 'LearningResource',
|
||||
'@id': learningId,
|
||||
name: title,
|
||||
description,
|
||||
url,
|
||||
image: new URL(demo.ogImage, Astro.site).href,
|
||||
learningResourceType: 'interactive tutorial',
|
||||
interactivityType: 'active',
|
||||
educationalLevel,
|
||||
timeRequired: demo.durationIso,
|
||||
: 'Advanced',
|
||||
url: canonicalURL.href,
|
||||
datePublished: demo.publishedDate,
|
||||
dateModified: demo.modifiedDate,
|
||||
isPartOf: { '@id': jsonLdId(url, 'webpage') },
|
||||
author: { '@id': organizationId(siteUrl) },
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
name: 'Comfy Org',
|
||||
url: 'https://comfy.org'
|
||||
}
|
||||
}
|
||||
|
||||
const breadcrumbJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 1,
|
||||
name: t('demos.breadcrumb.home', 'zh-CN'),
|
||||
item: 'https://comfy.org/zh-CN'
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 2,
|
||||
name: t('demos.breadcrumb.demos', 'zh-CN'),
|
||||
item: 'https://comfy.org/zh-CN/demos'
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 3,
|
||||
name: title
|
||||
}
|
||||
]
|
||||
}
|
||||
---
|
||||
|
||||
@@ -61,23 +88,25 @@ const learningResource: JsonLdNode = {
|
||||
title={`${title} — Comfy`}
|
||||
description={description}
|
||||
ogImage={demo.ogImage}
|
||||
mainEntityId={learningId}
|
||||
breadcrumbs={[
|
||||
{
|
||||
name: t('breadcrumb.home', locale),
|
||||
url: absoluteUrl(Astro.site, '/zh-CN'),
|
||||
},
|
||||
{
|
||||
name: t('demos.breadcrumb.demos', locale),
|
||||
url: absoluteUrl(Astro.site, '/zh-CN/demos'),
|
||||
},
|
||||
{ name: title },
|
||||
]}
|
||||
extraJsonLd={[learningResource]}
|
||||
>
|
||||
<Fragment slot="head">
|
||||
<meta property="article:published_time" content={demo.publishedDate} />
|
||||
<meta property="article:modified_time" content={demo.modifiedDate} />
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(howToJsonLd)}
|
||||
/>
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(learningResourceJsonLd)}
|
||||
/>
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(breadcrumbJsonLd)}
|
||||
/>
|
||||
<link rel="preconnect" href="https://demo.arcade.software" />
|
||||
</Fragment>
|
||||
|
||||
|
||||
@@ -8,32 +8,11 @@ import EcoSystemSection from '../../components/product/local/EcoSystemSection.vu
|
||||
import ProductCardsSection from '../../components/product/local/ProductCardsSection.vue'
|
||||
import FAQSection from '../../components/product/local/FAQSection.vue'
|
||||
import { t } from '../../i18n/translations'
|
||||
import {
|
||||
absoluteUrl,
|
||||
comfyUiApplicationNode,
|
||||
comfyUiSoftwareId,
|
||||
pageContext,
|
||||
} from '../../utils/jsonLd'
|
||||
|
||||
const { siteUrl, locale } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="下载 Comfy 桌面版 — 在您的硬件上运行 AI"
|
||||
description={t('download.hero.subtitle', 'zh-CN')}
|
||||
mainEntityId={comfyUiSoftwareId(siteUrl)}
|
||||
breadcrumbs={[
|
||||
{
|
||||
name: t('breadcrumb.home', locale),
|
||||
url: absoluteUrl(Astro.site, '/zh-CN'),
|
||||
},
|
||||
{ name: t('breadcrumb.download', locale) },
|
||||
]}
|
||||
extraJsonLd={[comfyUiApplicationNode(siteUrl)]}
|
||||
keywords={['comfyui app', 'comfyui desktop app', 'comfyui download', 'ComfyUI 下载', 'ComfyUI 桌面应用', 'ComfyUI 应用', 'ComfyUI Windows', 'ComfyUI macOS', 'ComfyUI Linux']}
|
||||
>
|
||||
<CloudBannerSection locale="zh-CN" />
|
||||
|
||||
@@ -9,25 +9,11 @@ import CaseStudySpotlightSection from '../../components/home/CaseStudySpotlightS
|
||||
import GetStartedSection from '../../components/home/GetStartedSection.vue'
|
||||
import BuildWhatSection from '../../components/home/BuildWhatSection.vue'
|
||||
import { t } from '../../i18n/translations'
|
||||
import {
|
||||
comfyUiApplicationNode,
|
||||
comfyUiSoftwareId,
|
||||
comfyUiSourceCodeNode,
|
||||
pageContext,
|
||||
} from '../../utils/jsonLd'
|
||||
|
||||
const { siteUrl } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Comfy — 视觉 AI 的最强可控性"
|
||||
description={t('hero.subtitle', 'zh-CN')}
|
||||
mainEntityId={comfyUiSoftwareId(siteUrl)}
|
||||
extraJsonLd={[comfyUiApplicationNode(siteUrl), comfyUiSourceCodeNode(siteUrl)]}
|
||||
keywords={['comfyui app', 'comfyui web app', 'comfyui application', 'ComfyUI 应用', 'ComfyUI 网页版', 'ComfyUI 桌面应用', 'ComfyUI 下载', '可视化 AI', '节点式 AI', '生成式 AI 工作流']}
|
||||
>
|
||||
<HeroSection locale="zh-CN" client:load />
|
||||
|
||||
@@ -255,10 +255,9 @@
|
||||
Shifting visually with `top` corrects the appearance without changing the
|
||||
inline-block's bounding box, so button/badge sizes are unaffected. */
|
||||
@utility ppformula-text-center {
|
||||
/* display: inline-block; */
|
||||
/* position: relative; */
|
||||
/* top: 0.1em; */
|
||||
--ppformula-text-center: 1;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
top: 0.1em;
|
||||
}
|
||||
|
||||
/* Hide native play-button overlay iOS Safari shows when autoplay is blocked
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { externalLinks } from '../config/routes'
|
||||
import { escapeJsonLd } from './escapeJsonLd'
|
||||
import type { JsonLdGraph } from './jsonLd'
|
||||
import {
|
||||
absoluteUrl,
|
||||
buildPageGraph,
|
||||
collectGraphIds,
|
||||
comfyUiApplicationNode,
|
||||
comfyUiSoftwareId,
|
||||
comfyUiSourceCodeNode,
|
||||
itemListNode,
|
||||
jsonLdId,
|
||||
organizationId,
|
||||
pageContext,
|
||||
productNode,
|
||||
softwareApplicationNode
|
||||
} from './jsonLd'
|
||||
|
||||
const siteUrl = 'https://comfy.org'
|
||||
const site = new URL('https://comfy.org/')
|
||||
|
||||
function typeNames(graph: JsonLdGraph): string[] {
|
||||
return graph['@graph'].map((node) => node['@type'])
|
||||
}
|
||||
|
||||
describe('absoluteUrl', () => {
|
||||
it('resolves internal paths to their trailing-slash canonical form', () => {
|
||||
expect(absoluteUrl(site, '/cloud')).toBe('https://comfy.org/cloud/')
|
||||
expect(absoluteUrl(site, '/about/')).toBe('https://comfy.org/about/')
|
||||
expect(absoluteUrl(site, '/')).toBe('https://comfy.org/')
|
||||
})
|
||||
})
|
||||
|
||||
describe('pageContext', () => {
|
||||
it('derives siteUrl, locale and canonical url from the Astro globals', () => {
|
||||
expect(pageContext(site, '/about/', undefined)).toEqual({
|
||||
siteUrl,
|
||||
locale: 'en',
|
||||
url: 'https://comfy.org/about/'
|
||||
})
|
||||
expect(pageContext(site, '/zh-CN/', 'zh-CN').locale).toBe('zh-CN')
|
||||
})
|
||||
})
|
||||
|
||||
describe('itemListNode', () => {
|
||||
it('counts items and omits per-item names when not supplied', () => {
|
||||
const node = itemListNode('https://comfy.org/careers/', 'Careers', [
|
||||
{ url: 'https://jobs.example/1' },
|
||||
{ url: 'https://jobs.example/2', name: 'Designer' }
|
||||
])
|
||||
expect(node.numberOfItems).toBe(2)
|
||||
const items = node.itemListElement as Record<string, unknown>[]
|
||||
expect('name' in items[0]).toBe(false)
|
||||
expect(items[1].name).toBe('Designer')
|
||||
})
|
||||
})
|
||||
|
||||
describe('softwareApplicationNode', () => {
|
||||
it('claims Comfy Org as author and publisher only when first-party', () => {
|
||||
const node = softwareApplicationNode({
|
||||
siteUrl,
|
||||
id: jsonLdId(siteUrl, 'software'),
|
||||
name: 'ComfyUI',
|
||||
url: siteUrl,
|
||||
firstParty: true,
|
||||
applicationCategory: 'MultimediaApplication',
|
||||
isFree: true
|
||||
})
|
||||
const orgRef = { '@id': organizationId(siteUrl) }
|
||||
expect(node.author).toEqual(orgRef)
|
||||
expect(node.publisher).toEqual(orgRef)
|
||||
expect(node.offers).toEqual({
|
||||
'@type': 'Offer',
|
||||
price: 0,
|
||||
priceCurrency: 'USD',
|
||||
seller: orgRef
|
||||
})
|
||||
})
|
||||
|
||||
it('does not name Comfy Org as seller on a third-party free offer', () => {
|
||||
const node = softwareApplicationNode({
|
||||
siteUrl,
|
||||
id: 'https://comfy.org/cloud/supported-nodes/foo/#software',
|
||||
name: 'Foo Pack',
|
||||
url: 'https://comfy.org/cloud/supported-nodes/foo/',
|
||||
applicationCategory: 'DeveloperApplication',
|
||||
isFree: true
|
||||
})
|
||||
expect((node.offers as Record<string, unknown>).seller).toBeUndefined()
|
||||
})
|
||||
|
||||
it('credits a known third-party author without claiming to publish it', () => {
|
||||
const node = softwareApplicationNode({
|
||||
siteUrl,
|
||||
id: 'https://comfy.org/cloud/supported-nodes/foo/#software',
|
||||
name: 'Foo Pack',
|
||||
url: 'https://comfy.org/cloud/supported-nodes/foo/',
|
||||
applicationCategory: 'DeveloperApplication',
|
||||
authorName: 'Jane Dev'
|
||||
})
|
||||
expect(node.author).toEqual({ '@type': 'Person', name: 'Jane Dev' })
|
||||
expect(node.publisher).toBeUndefined()
|
||||
})
|
||||
|
||||
it('claims no author or publisher for third-party software with no author', () => {
|
||||
const node = softwareApplicationNode({
|
||||
siteUrl,
|
||||
id: 'https://comfy.org/p/supported-models/foo/#software',
|
||||
name: 'Foo Model',
|
||||
url: 'https://comfy.org/p/supported-models/foo/',
|
||||
applicationCategory: 'MultimediaApplication'
|
||||
})
|
||||
expect(node.author).toBeUndefined()
|
||||
expect(node.publisher).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('sameAs encyclopedic references', () => {
|
||||
it('links the organization to its Wikidata entity', () => {
|
||||
const graph = buildPageGraph(
|
||||
{ siteUrl, locale: 'en' },
|
||||
{ url: `${siteUrl}/`, name: 'Home' }
|
||||
)
|
||||
const org = graph['@graph'].find((node) => node['@type'] === 'Organization')
|
||||
expect(org?.sameAs).toContain(externalLinks.wikidataComfyOrg)
|
||||
})
|
||||
|
||||
it('links the ComfyUI application to its Wikidata, Wikipedia and G2 entities', () => {
|
||||
const node = comfyUiApplicationNode(siteUrl)
|
||||
expect(node.sameAs).toEqual([
|
||||
externalLinks.wikidataComfyUi,
|
||||
externalLinks.wikipediaComfyUi,
|
||||
externalLinks.g2ComfyUi
|
||||
])
|
||||
})
|
||||
|
||||
it('omits sameAs for third-party software', () => {
|
||||
const node = softwareApplicationNode({
|
||||
siteUrl,
|
||||
id: 'https://comfy.org/p/supported-models/foo/#software',
|
||||
name: 'Foo Model',
|
||||
url: 'https://comfy.org/p/supported-models/foo/',
|
||||
applicationCategory: 'MultimediaApplication'
|
||||
})
|
||||
expect(node.sameAs).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('productNode', () => {
|
||||
it('gives every offer a currency and price', () => {
|
||||
const node = productNode({
|
||||
siteUrl,
|
||||
id: 'https://comfy.org/cloud/pricing/#product',
|
||||
name: 'Comfy Cloud',
|
||||
url: 'https://comfy.org/cloud/pricing/',
|
||||
offers: [{ name: 'Standard', price: '20' }]
|
||||
})
|
||||
const offers = node.offers as Record<string, unknown>[]
|
||||
expect(offers[0].price).toBe('20')
|
||||
expect(offers[0].priceCurrency).toBe('USD')
|
||||
expect(offers[0].seller).toEqual({ '@id': organizationId(siteUrl) })
|
||||
})
|
||||
})
|
||||
|
||||
describe('comfyUiSourceCodeNode', () => {
|
||||
it('links the source code to the ComfyUI application via targetProduct', () => {
|
||||
const node = comfyUiSourceCodeNode(siteUrl)
|
||||
expect(node.targetProduct).toEqual({ '@id': comfyUiSoftwareId(siteUrl) })
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildPageGraph', () => {
|
||||
const url = 'https://comfy.org/cloud/pricing/'
|
||||
const graph = buildPageGraph(
|
||||
{ siteUrl, locale: 'en' },
|
||||
{
|
||||
url,
|
||||
name: 'Pricing',
|
||||
type: 'CollectionPage',
|
||||
mainEntityId: jsonLdId(url, 'itemlist'),
|
||||
crumbs: [{ name: 'Home', url: `${siteUrl}/` }, { name: 'Pricing' }]
|
||||
},
|
||||
itemListNode(url, 'Plans', [{ url: `${siteUrl}/one/` }])
|
||||
)
|
||||
|
||||
it('always includes the site-wide organization, website and page entity', () => {
|
||||
expect(typeNames(graph)).toContain('Organization')
|
||||
expect(typeNames(graph)).toContain('WebSite')
|
||||
expect(typeNames(graph)).toContain('CollectionPage')
|
||||
})
|
||||
|
||||
it('produces a graph where every @id reference resolves', () => {
|
||||
const { defined, references } = collectGraphIds(graph)
|
||||
for (const reference of references) {
|
||||
expect(defined.has(reference)).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('escapeJsonLd on a built graph', () => {
|
||||
it('neutralizes a </script> breakout in a page name', () => {
|
||||
const graph = buildPageGraph(
|
||||
{ siteUrl, locale: 'en' },
|
||||
{ url: `${siteUrl}/x/`, name: '</script><script>alert(1)</script>' }
|
||||
)
|
||||
const serialized = escapeJsonLd(graph)
|
||||
expect(serialized).not.toContain('</script>')
|
||||
expect(serialized).toContain('\\u003c')
|
||||
})
|
||||
})
|
||||
@@ -1,377 +0,0 @@
|
||||
import { externalLinks } from '../config/routes'
|
||||
import type { Locale } from '../i18n/translations'
|
||||
|
||||
export type JsonLdNode = Record<string, unknown> & { '@type': string }
|
||||
|
||||
export interface JsonLdGraph {
|
||||
'@context': 'https://schema.org'
|
||||
'@graph': JsonLdNode[]
|
||||
}
|
||||
|
||||
export interface PageContext {
|
||||
siteUrl: string
|
||||
locale: Locale
|
||||
}
|
||||
|
||||
export type WebPageType =
|
||||
| 'WebPage'
|
||||
| 'AboutPage'
|
||||
| 'ContactPage'
|
||||
| 'CollectionPage'
|
||||
|
||||
export interface Crumb {
|
||||
name: string
|
||||
url?: string
|
||||
}
|
||||
|
||||
const sameAs = [
|
||||
externalLinks.github,
|
||||
externalLinks.x,
|
||||
externalLinks.youtube,
|
||||
externalLinks.discord,
|
||||
externalLinks.instagram,
|
||||
externalLinks.reddit,
|
||||
externalLinks.linkedin,
|
||||
// Wikidata entity for the organization, so the Knowledge Graph can resolve it.
|
||||
externalLinks.wikidataComfyOrg
|
||||
]
|
||||
|
||||
// Authoritative encyclopedic and review-platform references for the ComfyUI software entity.
|
||||
const comfyUiSameAs = [
|
||||
externalLinks.wikidataComfyUi,
|
||||
externalLinks.wikipediaComfyUi,
|
||||
externalLinks.g2ComfyUi
|
||||
]
|
||||
|
||||
function siteUrlFrom(site: URL | undefined): string {
|
||||
return (site?.href ?? 'https://comfy.org/').replace(/\/$/, '')
|
||||
}
|
||||
|
||||
export function absoluteUrl(site: URL | undefined, path: string): string {
|
||||
const resolved = new URL(path, site ?? 'https://comfy.org').href
|
||||
return resolved.endsWith('/') ? resolved : `${resolved}/`
|
||||
}
|
||||
|
||||
export function pageContext(
|
||||
site: URL | undefined,
|
||||
pathname: string,
|
||||
currentLocale: string | undefined
|
||||
): PageContext & { url: string } {
|
||||
return {
|
||||
siteUrl: siteUrlFrom(site),
|
||||
locale: currentLocale === 'zh-CN' ? 'zh-CN' : 'en',
|
||||
url: absoluteUrl(site, pathname)
|
||||
}
|
||||
}
|
||||
|
||||
export function jsonLdId(pageUrl: string, fragment: string): string {
|
||||
return `${pageUrl}#${fragment}`
|
||||
}
|
||||
|
||||
export function organizationId(siteUrl: string): string {
|
||||
return `${siteUrl}/#organization`
|
||||
}
|
||||
|
||||
function websiteId(siteUrl: string): string {
|
||||
return `${siteUrl}/#website`
|
||||
}
|
||||
|
||||
function buildGraph(...nodes: (JsonLdNode | null | undefined)[]): JsonLdGraph {
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@graph': nodes.filter((node): node is JsonLdNode => Boolean(node))
|
||||
}
|
||||
}
|
||||
|
||||
function organizationNode(siteUrl: string): JsonLdNode {
|
||||
return {
|
||||
'@type': 'Organization',
|
||||
'@id': organizationId(siteUrl),
|
||||
name: 'Comfy Org',
|
||||
url: siteUrl,
|
||||
logo: {
|
||||
'@type': 'ImageObject',
|
||||
url: `${siteUrl}/web-app-manifest-512x512.png`,
|
||||
width: 512,
|
||||
height: 512
|
||||
},
|
||||
sameAs
|
||||
}
|
||||
}
|
||||
|
||||
function websiteNode(siteUrl: string): JsonLdNode {
|
||||
return {
|
||||
'@type': 'WebSite',
|
||||
'@id': websiteId(siteUrl),
|
||||
name: 'Comfy',
|
||||
url: siteUrl,
|
||||
publisher: { '@id': organizationId(siteUrl) }
|
||||
}
|
||||
}
|
||||
|
||||
function breadcrumbNode(pageUrl: string, crumbs: Crumb[]): JsonLdNode {
|
||||
return {
|
||||
'@type': 'BreadcrumbList',
|
||||
'@id': jsonLdId(pageUrl, 'breadcrumb'),
|
||||
itemListElement: crumbs.map((crumb, index) => {
|
||||
const isLast = index === crumbs.length - 1
|
||||
return isLast || !crumb.url
|
||||
? { '@type': 'ListItem', position: index + 1, name: crumb.name }
|
||||
: {
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
name: crumb.name,
|
||||
item: crumb.url
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function itemListNode(
|
||||
pageUrl: string,
|
||||
name: string,
|
||||
items: { url: string; name?: string }[]
|
||||
): JsonLdNode {
|
||||
return {
|
||||
'@type': 'ItemList',
|
||||
'@id': jsonLdId(pageUrl, 'itemlist'),
|
||||
name,
|
||||
numberOfItems: items.length,
|
||||
itemListElement: items.map((item, index) => ({
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
url: item.url,
|
||||
...(item.name ? { name: item.name } : {})
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
interface WebPageInput {
|
||||
siteUrl: string
|
||||
locale: Locale
|
||||
url: string
|
||||
name: string
|
||||
description?: string
|
||||
imageUrl?: string
|
||||
crumbs?: Crumb[]
|
||||
mainEntityId?: string
|
||||
}
|
||||
|
||||
function webPageNode(input: WebPageInput, type: WebPageType): JsonLdNode {
|
||||
const hasCrumbs = Boolean(input.crumbs && input.crumbs.length > 0)
|
||||
return {
|
||||
'@type': type,
|
||||
'@id': jsonLdId(input.url, 'webpage'),
|
||||
url: input.url,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
isPartOf: { '@id': websiteId(input.siteUrl) },
|
||||
primaryImageOfPage: input.imageUrl
|
||||
? { '@type': 'ImageObject', url: input.imageUrl }
|
||||
: undefined,
|
||||
breadcrumb: hasCrumbs
|
||||
? { '@id': jsonLdId(input.url, 'breadcrumb') }
|
||||
: undefined,
|
||||
mainEntity: input.mainEntityId ? { '@id': input.mainEntityId } : undefined,
|
||||
inLanguage: input.locale
|
||||
}
|
||||
}
|
||||
|
||||
export interface SoftwareAppInput {
|
||||
siteUrl: string
|
||||
id: string
|
||||
name: string
|
||||
url: string
|
||||
applicationCategory: string
|
||||
firstParty?: boolean
|
||||
applicationSubCategory?: string
|
||||
description?: string
|
||||
operatingSystem?: string
|
||||
image?: string
|
||||
softwareVersion?: string
|
||||
license?: string
|
||||
codeRepository?: string
|
||||
authorName?: string
|
||||
isFree?: boolean
|
||||
sameAs?: string[]
|
||||
}
|
||||
|
||||
export function softwareApplicationNode(input: SoftwareAppInput): JsonLdNode {
|
||||
const orgRef = { '@id': organizationId(input.siteUrl) }
|
||||
const author = input.firstParty
|
||||
? orgRef
|
||||
: input.authorName
|
||||
? { '@type': 'Person', name: input.authorName }
|
||||
: undefined
|
||||
return {
|
||||
'@type': 'SoftwareApplication',
|
||||
'@id': input.id,
|
||||
name: input.name,
|
||||
url: input.url,
|
||||
applicationCategory: input.applicationCategory,
|
||||
applicationSubCategory: input.applicationSubCategory,
|
||||
description: input.description,
|
||||
operatingSystem: input.operatingSystem,
|
||||
image: input.image,
|
||||
softwareVersion: input.softwareVersion,
|
||||
license: input.license,
|
||||
codeRepository: input.codeRepository,
|
||||
author,
|
||||
publisher: input.firstParty ? orgRef : undefined,
|
||||
sameAs: input.sameAs,
|
||||
offers: input.isFree
|
||||
? {
|
||||
'@type': 'Offer',
|
||||
price: 0,
|
||||
priceCurrency: 'USD',
|
||||
seller: input.firstParty ? orgRef : undefined
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
|
||||
interface SourceCodeInput {
|
||||
siteUrl: string
|
||||
id: string
|
||||
name: string
|
||||
codeRepository: string
|
||||
programmingLanguage?: string
|
||||
targetProductId?: string
|
||||
}
|
||||
|
||||
function softwareSourceCodeNode(input: SourceCodeInput): JsonLdNode {
|
||||
return {
|
||||
'@type': 'SoftwareSourceCode',
|
||||
'@id': input.id,
|
||||
name: input.name,
|
||||
codeRepository: input.codeRepository,
|
||||
programmingLanguage: input.programmingLanguage,
|
||||
targetProduct: input.targetProductId
|
||||
? { '@id': input.targetProductId }
|
||||
: undefined,
|
||||
author: { '@id': organizationId(input.siteUrl) }
|
||||
}
|
||||
}
|
||||
|
||||
export function comfyUiSoftwareId(siteUrl: string): string {
|
||||
return `${siteUrl}/#software`
|
||||
}
|
||||
|
||||
export function comfyUiApplicationNode(siteUrl: string): JsonLdNode {
|
||||
return softwareApplicationNode({
|
||||
siteUrl,
|
||||
id: comfyUiSoftwareId(siteUrl),
|
||||
name: 'ComfyUI',
|
||||
url: siteUrl,
|
||||
firstParty: true,
|
||||
applicationCategory: 'MultimediaApplication',
|
||||
operatingSystem: 'Windows, macOS, Linux',
|
||||
isFree: true,
|
||||
sameAs: comfyUiSameAs
|
||||
})
|
||||
}
|
||||
|
||||
export function comfyUiSourceCodeNode(siteUrl: string): JsonLdNode {
|
||||
return softwareSourceCodeNode({
|
||||
siteUrl,
|
||||
id: `${siteUrl}/#sourcecode`,
|
||||
name: 'ComfyUI',
|
||||
codeRepository: externalLinks.github,
|
||||
programmingLanguage: 'Python',
|
||||
targetProductId: comfyUiSoftwareId(siteUrl)
|
||||
})
|
||||
}
|
||||
|
||||
interface OfferInput {
|
||||
name: string
|
||||
price: string | number
|
||||
url?: string
|
||||
}
|
||||
|
||||
export interface ProductInput {
|
||||
siteUrl: string
|
||||
id: string
|
||||
name: string
|
||||
url: string
|
||||
offers: OfferInput[]
|
||||
}
|
||||
|
||||
export function productNode(input: ProductInput): JsonLdNode {
|
||||
return {
|
||||
'@type': 'Product',
|
||||
'@id': input.id,
|
||||
name: input.name,
|
||||
url: input.url,
|
||||
brand: { '@id': organizationId(input.siteUrl) },
|
||||
offers: input.offers.map((offer) => ({
|
||||
'@type': 'Offer',
|
||||
name: offer.name,
|
||||
price: offer.price,
|
||||
priceCurrency: 'USD',
|
||||
url: offer.url,
|
||||
seller: { '@id': organizationId(input.siteUrl) },
|
||||
priceSpecification: {
|
||||
'@type': 'UnitPriceSpecification',
|
||||
price: offer.price,
|
||||
priceCurrency: 'USD',
|
||||
unitText: 'MONTH'
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
export interface PageGraphInput {
|
||||
url: string
|
||||
name: string
|
||||
type?: WebPageType
|
||||
description?: string
|
||||
imageUrl?: string
|
||||
crumbs?: Crumb[]
|
||||
mainEntityId?: string
|
||||
}
|
||||
|
||||
export function buildPageGraph(
|
||||
ctx: PageContext,
|
||||
page: PageGraphInput,
|
||||
...extraNodes: (JsonLdNode | null | undefined)[]
|
||||
): JsonLdGraph {
|
||||
const { type = 'WebPage', ...rest } = page
|
||||
const input: WebPageInput = {
|
||||
...rest,
|
||||
siteUrl: ctx.siteUrl,
|
||||
locale: ctx.locale
|
||||
}
|
||||
const hasCrumbs = Boolean(page.crumbs && page.crumbs.length > 0)
|
||||
return buildGraph(
|
||||
organizationNode(ctx.siteUrl),
|
||||
websiteNode(ctx.siteUrl),
|
||||
webPageNode(input, type),
|
||||
hasCrumbs ? breadcrumbNode(page.url, page.crumbs!) : undefined,
|
||||
...extraNodes
|
||||
)
|
||||
}
|
||||
|
||||
export function collectGraphIds(value: unknown): {
|
||||
defined: Set<string>
|
||||
references: string[]
|
||||
} {
|
||||
const defined = new Set<string>()
|
||||
const references: string[] = []
|
||||
const walk = (node: unknown): void => {
|
||||
if (Array.isArray(node)) {
|
||||
node.forEach(walk)
|
||||
return
|
||||
}
|
||||
if (node && typeof node === 'object') {
|
||||
const record = node as Record<string, unknown>
|
||||
const id = record['@id']
|
||||
if (typeof id === 'string') {
|
||||
if (Object.keys(record).length === 1) references.push(id)
|
||||
else defined.add(id)
|
||||
}
|
||||
Object.values(record).forEach(walk)
|
||||
}
|
||||
}
|
||||
walk(value)
|
||||
return { defined, references }
|
||||
}
|
||||
@@ -167,6 +167,10 @@ export const TestIds = {
|
||||
selectDefaultSearchInput: 'widget-select-default-search-input',
|
||||
selectDefaultViewport: 'widget-select-default-viewport'
|
||||
},
|
||||
errorResolution: {
|
||||
panel: 'error-resolution-panel',
|
||||
back: 'error-resolution-back'
|
||||
},
|
||||
linear: {
|
||||
centerPanel: 'linear-center-panel',
|
||||
mobile: 'linear-mobile',
|
||||
@@ -276,7 +280,6 @@ export const TestIds = {
|
||||
overlay: 'loading-overlay'
|
||||
},
|
||||
load3d: {
|
||||
categoryMenu: 'load3d-category-menu',
|
||||
recordingDuration: 'load3d-recording-duration'
|
||||
},
|
||||
load3dViewer: {
|
||||
|
||||
@@ -61,10 +61,10 @@ test.describe(
|
||||
|
||||
await expect(comfyPage.appMode.linearWidgets).toBeHidden()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.propertiesPanel.root)
|
||||
comfyPage.page.getByTestId(TestIds.errorResolution.panel)
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.propertiesPanel.errorsTab)
|
||||
comfyPage.page.getByTestId(TestIds.errorResolution.back)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
|
||||
@@ -1,279 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { bootCloud, mockCloudBoot } from '@e2e/fixtures/utils/cloudBootMocks'
|
||||
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
|
||||
|
||||
/**
|
||||
* End-to-end coverage for the user-secrets (API keys) surface in the cloud app:
|
||||
* add a provider key, see it listed, delete it — the full CRUD round-trip —
|
||||
* plus the entitlement contract that a non-entitled account never sees the
|
||||
* gated providers.
|
||||
*
|
||||
* Drives a raw `page` against fully-mocked endpoints (the `comfyPage` fixture
|
||||
* would reach the OSS devtools backend during setup); `mockCloudBoot` +
|
||||
* `bootCloud` boot the app signed-in, and this spec layers a stateful in-memory
|
||||
* `/secrets` backend on top so the flow is deterministic and never touches a
|
||||
* real server.
|
||||
*/
|
||||
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
|
||||
// `/api/features` is the remote-config source. Enabling user secrets is what
|
||||
// surfaces the Secrets settings panel for a signed-in user.
|
||||
const BOOT_FEATURES = {
|
||||
user_secrets_enabled: true
|
||||
} satisfies RemoteConfig
|
||||
|
||||
// TutorialCompleted suppresses the new-user template browser, whose modal
|
||||
// overlay (z-1700) would otherwise intercept clicks on the settings dialog.
|
||||
const BOOT_SETTINGS = { 'Comfy.TutorialCompleted': true }
|
||||
|
||||
// The plaintext key a user types in. It must be sent on create but NEVER echoed
|
||||
// back by the API or rendered anywhere in the UI.
|
||||
const RUNWAY_KEY_VALUE = 'sk-runway-do-not-echo-0xDEADBEEF'
|
||||
|
||||
interface SecretRecord {
|
||||
id: string
|
||||
name: string
|
||||
provider?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
last_used_at?: string
|
||||
}
|
||||
|
||||
interface CreateCapture {
|
||||
name?: string
|
||||
provider?: string
|
||||
secret_value?: string
|
||||
}
|
||||
|
||||
interface SecretsBackend {
|
||||
/** Bodies received by POST /secrets, in order — for asserting what was sent. */
|
||||
createRequests: CreateCapture[]
|
||||
/** Current server-side store — for asserting delete actually removed a row. */
|
||||
store: SecretRecord[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Stateful mock of the ingest `/secrets` surface. A single route handler
|
||||
* branches on path + method so registration order can never make a specific
|
||||
* path (`/secrets/providers`, `/secrets/:id`) lose to the collection glob.
|
||||
*
|
||||
* `providerIds` models entitlement: an entitled account sees runway/gemini,
|
||||
* a non-entitled account gets an empty list (the server omits them).
|
||||
*/
|
||||
async function mockSecretsBackend(
|
||||
page: Page,
|
||||
providerIds: string[]
|
||||
): Promise<SecretsBackend> {
|
||||
const backend: SecretsBackend = { createRequests: [], store: [] }
|
||||
let idSeq = 0
|
||||
|
||||
const respondList = (route: Route) =>
|
||||
route.fulfill(jsonRoute({ data: backend.store }))
|
||||
|
||||
await page.route('**/api/secrets**', async (route) => {
|
||||
const request = route.request()
|
||||
const { pathname } = new URL(request.url())
|
||||
const method = request.method()
|
||||
|
||||
// The glob `**/api/secrets**` also matches the panel's own lazy-loaded
|
||||
// source module (`/src/platform/secrets/api/secretsApi.ts`), whose path
|
||||
// contains the `/api/secrets` substring. Fulfilling that dev-server module
|
||||
// request with JSON breaks the dynamic import and the panel never mounts.
|
||||
// Anchor to the start of the pathname so only genuine `/api/secrets…` API
|
||||
// routes are handled; everything else falls through to the real Vite server.
|
||||
if (!/^\/api\/secrets(\/|$)/.test(pathname)) {
|
||||
return route.continue()
|
||||
}
|
||||
|
||||
// GET /secrets/providers — the entitlement-gated provider allowlist.
|
||||
if (pathname.endsWith('/secrets/providers')) {
|
||||
return route.fulfill(
|
||||
jsonRoute({ data: providerIds.map((id) => ({ id })) })
|
||||
)
|
||||
}
|
||||
|
||||
// /secrets/:id — item routes (only DELETE is exercised by this flow).
|
||||
const itemMatch = pathname.match(/\/secrets\/([^/]+)$/)
|
||||
if (itemMatch) {
|
||||
const id = itemMatch[1]
|
||||
if (method === 'DELETE') {
|
||||
backend.store = backend.store.filter((s) => s.id !== id)
|
||||
return route.fulfill({ status: 204, body: '' })
|
||||
}
|
||||
return respondList(route)
|
||||
}
|
||||
|
||||
// /secrets — collection routes.
|
||||
if (method === 'POST') {
|
||||
const body = (request.postDataJSON() ?? {}) as CreateCapture
|
||||
backend.createRequests.push(body)
|
||||
idSeq += 1
|
||||
const created: SecretRecord = {
|
||||
id: `00000000-0000-4000-8000-${String(idSeq).padStart(12, '0')}`,
|
||||
name: body.name ?? '',
|
||||
provider: body.provider,
|
||||
created_at: '2026-07-08T00:00:00Z',
|
||||
updated_at: '2026-07-08T00:00:00Z'
|
||||
}
|
||||
backend.store.push(created)
|
||||
// Response echoes metadata ONLY — the schema has no secret_value field.
|
||||
return route.fulfill(jsonRoute(created))
|
||||
}
|
||||
|
||||
// GET /secrets (list).
|
||||
return respondList(route)
|
||||
})
|
||||
|
||||
return backend
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the settings dialog and land on the Secrets panel, waiting for both the
|
||||
* provider allowlist and the secret list to resolve so subsequent assertions
|
||||
* are not racing the panel's on-mount fetches.
|
||||
*/
|
||||
async function openSecretsPanel(page: Page) {
|
||||
const settingsDialog = page.getByTestId('settings-dialog')
|
||||
|
||||
await page.evaluate(() => {
|
||||
const app = window.app
|
||||
if (!app) throw new Error('window.app is not available')
|
||||
return app.extensionManager.command.execute('Comfy.ShowSettingsDialog')
|
||||
})
|
||||
await settingsDialog.waitFor({ state: 'visible' })
|
||||
|
||||
const providersResolved = page.waitForResponse((r) =>
|
||||
r.url().includes('/api/secrets/providers')
|
||||
)
|
||||
const listResolved = page.waitForResponse(
|
||||
(r) =>
|
||||
/\/api\/secrets(\?|$)/.test(r.url()) && r.request().method() === 'GET'
|
||||
)
|
||||
|
||||
await settingsDialog
|
||||
.locator('nav')
|
||||
.getByRole('button', { name: 'Secrets' })
|
||||
.click()
|
||||
|
||||
await Promise.all([providersResolved, listResolved])
|
||||
return settingsDialog
|
||||
}
|
||||
|
||||
test.describe('Cloud user secrets (API keys)', { tag: '@cloud' }, () => {
|
||||
test('an entitled account can add, list, and delete a provider key', async ({
|
||||
page
|
||||
}) => {
|
||||
test.slow()
|
||||
|
||||
await mockCloudBoot(page, {
|
||||
features: BOOT_FEATURES,
|
||||
settings: BOOT_SETTINGS
|
||||
})
|
||||
await bootCloud(page)
|
||||
const backend = await mockSecretsBackend(page, ['runway', 'gemini'])
|
||||
|
||||
await page.goto(APP_URL)
|
||||
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
|
||||
timeout: 45_000
|
||||
})
|
||||
|
||||
const settingsDialog = await openSecretsPanel(page)
|
||||
|
||||
// Empty state before anything is added.
|
||||
await expect(settingsDialog.getByText(/No secrets stored/)).toBeVisible()
|
||||
|
||||
// --- ADD -------------------------------------------------------------
|
||||
await settingsDialog.getByRole('button', { name: 'Add Secret' }).click()
|
||||
|
||||
const formDialog = page
|
||||
.getByRole('dialog')
|
||||
.filter({ hasText: 'Secret Value' })
|
||||
await expect(formDialog).toBeVisible()
|
||||
|
||||
// Pick the entitled Runway provider from the server-driven dropdown.
|
||||
await formDialog.locator('#secret-provider').click()
|
||||
await page.getByRole('option', { name: 'Runway' }).click()
|
||||
|
||||
await formDialog.locator('#secret-name').fill('My Runway Key')
|
||||
await formDialog.locator('input[type="password"]').fill(RUNWAY_KEY_VALUE)
|
||||
|
||||
await formDialog.getByRole('button', { name: 'Save', exact: true }).click()
|
||||
await expect(formDialog).toBeHidden()
|
||||
|
||||
// --- LIST ------------------------------------------------------------
|
||||
await expect(settingsDialog.getByText('My Runway Key')).toBeVisible()
|
||||
await expect(settingsDialog.getByText(/No secrets stored/)).toBeHidden()
|
||||
|
||||
// The create request carried the plaintext value + provider...
|
||||
expect(backend.createRequests).toHaveLength(1)
|
||||
expect(backend.createRequests[0]).toMatchObject({
|
||||
name: 'My Runway Key',
|
||||
provider: 'runway',
|
||||
secret_value: RUNWAY_KEY_VALUE
|
||||
})
|
||||
// ...but the value must never be echoed back into the list — the API
|
||||
// response carries metadata only, so nothing should render it as text.
|
||||
await expect(page.getByText(RUNWAY_KEY_VALUE)).toHaveCount(0)
|
||||
|
||||
// --- DELETE ----------------------------------------------------------
|
||||
await settingsDialog
|
||||
.getByRole('button', { name: 'Delete', exact: true })
|
||||
.click()
|
||||
|
||||
const confirmDialog = page
|
||||
.getByRole('dialog')
|
||||
.filter({ hasText: 'Delete Secret' })
|
||||
await confirmDialog
|
||||
.getByRole('button', { name: 'Delete', exact: true })
|
||||
.click()
|
||||
|
||||
await expect(settingsDialog.getByText('My Runway Key')).toBeHidden()
|
||||
await expect(settingsDialog.getByText(/No secrets stored/)).toBeVisible()
|
||||
expect(backend.store).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('a non-entitled account never sees the gated providers', async ({
|
||||
page
|
||||
}) => {
|
||||
test.slow()
|
||||
|
||||
await mockCloudBoot(page, {
|
||||
features: BOOT_FEATURES,
|
||||
settings: BOOT_SETTINGS
|
||||
})
|
||||
await bootCloud(page)
|
||||
// Non-entitled: the server omits runway/gemini from the allowlist.
|
||||
await mockSecretsBackend(page, [])
|
||||
|
||||
await page.goto(APP_URL)
|
||||
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
|
||||
timeout: 45_000
|
||||
})
|
||||
|
||||
const settingsDialog = await openSecretsPanel(page)
|
||||
await expect(settingsDialog.getByText(/No secrets stored/)).toBeVisible()
|
||||
|
||||
// The add form opens, but its provider dropdown is empty — the gated
|
||||
// providers must not appear anywhere.
|
||||
await settingsDialog.getByRole('button', { name: 'Add Secret' }).click()
|
||||
const formDialog = page
|
||||
.getByRole('dialog')
|
||||
.filter({ hasText: 'Secret Value' })
|
||||
await expect(formDialog).toBeVisible()
|
||||
|
||||
await formDialog.locator('#secret-provider').click()
|
||||
// Anchor on the opened listbox so the absence assertions below can't pass
|
||||
// vacuously against a dropdown that never opened.
|
||||
const providerListbox = page.getByRole('listbox')
|
||||
await expect(providerListbox).toBeVisible()
|
||||
// An empty allowlist must yield an empty dropdown. Asserting zero options
|
||||
// (not just runway/gemini absent) also rejects the fetch-failure fallback,
|
||||
// where `availableProviders` is null and the default providers would show.
|
||||
await expect(providerListbox.getByRole('option')).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
191
browser_tests/tests/errorResolution.spec.ts
Normal file
191
browser_tests/tests/errorResolution.spec.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { NodeError } from '@/schemas/apiSchema'
|
||||
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||
import { enableErrorsOverlay } from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
const SAVE_IMAGE_NODE_ID = '9'
|
||||
|
||||
function buildSaveImageRequiredInputError(): NodeError {
|
||||
return {
|
||||
class_type: 'SaveImage',
|
||||
dependent_outputs: [],
|
||||
errors: [
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: 'Required input is missing: images',
|
||||
details: '',
|
||||
extra_info: { input_name: 'images' }
|
||||
},
|
||||
{
|
||||
type: 'value_smaller_than_min',
|
||||
message: 'Value -1 smaller than min of 0',
|
||||
details: '',
|
||||
extra_info: { input_name: 'quality' }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('Error resolution view', { tag: ['@ui', '@workflow'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await enableErrorsOverlay(comfyPage)
|
||||
await comfyPage.settings.setSetting('Comfy.Minimap.Visible', false)
|
||||
await comfyPage.workflow.loadWorkflow('linear-validation-warning')
|
||||
await comfyPage.appMode.toggleAppMode()
|
||||
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
|
||||
|
||||
const exec = new ExecutionHelper(comfyPage)
|
||||
await exec.mockValidationFailure({
|
||||
[SAVE_IMAGE_NODE_ID]: buildSaveImageRequiredInputError()
|
||||
})
|
||||
await comfyPage.appMode.runButton.click()
|
||||
await expect(comfyPage.appMode.validationWarning).toBeVisible()
|
||||
await comfyPage.appMode.viewErrorsInGraphButton.click()
|
||||
})
|
||||
|
||||
test('shows canvas with error panel and hides UI chrome', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await expect(comfyPage.canvas).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.errorResolution.panel)
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.errorResolution.back)
|
||||
).toBeVisible()
|
||||
await expect(comfyPage.menu.sideToolbar).toBeHidden()
|
||||
|
||||
// FitView on entry must wait for the canvas to be re-measured; fitting
|
||||
// a zero-sized canvas corrupts the view transform (scale 0 / NaN)
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const { scale, offset } = window.app!.canvas.ds
|
||||
return (
|
||||
Number.isFinite(scale) &&
|
||||
scale > 0.01 &&
|
||||
Number.isFinite(offset[0]) &&
|
||||
Number.isFinite(offset[1])
|
||||
)
|
||||
})
|
||||
)
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
test('back button returns to app mode and restores chrome on next graph entry', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.getByTestId(TestIds.errorResolution.back).click()
|
||||
|
||||
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.errorResolution.panel)
|
||||
).toBeHidden()
|
||||
|
||||
await comfyPage.appMode.toggleAppMode()
|
||||
await expect(comfyPage.menu.sideToolbar).toBeVisible()
|
||||
})
|
||||
|
||||
test('locating an error node keeps the panel open', async ({ comfyPage }) => {
|
||||
const panel = comfyPage.page.getByTestId(TestIds.errorResolution.panel)
|
||||
await panel
|
||||
.getByRole('button', { name: /locate/i })
|
||||
.first()
|
||||
.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(panel).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.errorResolution.back)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('narrow viewport shows collapsible top bar with card carousel', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.setViewportSize({ width: 375, height: 812 })
|
||||
|
||||
const panel = comfyPage.page.getByTestId(TestIds.errorResolution.panel)
|
||||
await expect(panel).toBeVisible()
|
||||
await expect(panel.getByTestId(TestIds.errorResolution.back)).toBeVisible()
|
||||
|
||||
const errorCards = panel.getByTestId('error-group-execution')
|
||||
await expect(errorCards.first()).toBeVisible()
|
||||
// The summary hero is replaced by the top bar count in carousel layout
|
||||
await expect(panel.getByTestId('errors-summary-hero')).toBeHidden()
|
||||
|
||||
// Two catalog groups → two carousel slides with position dots
|
||||
await expect(errorCards).toHaveCount(2)
|
||||
const dots = panel.getByTestId('error-carousel-dots').getByRole('button')
|
||||
await expect(dots).toHaveCount(2)
|
||||
await expect(dots.first()).toHaveAttribute('aria-current', 'true')
|
||||
|
||||
// Each slide spans the full track width (one card per view)
|
||||
const cardBox = await errorCards.first().boundingBox()
|
||||
const panelBox = await panel.boundingBox()
|
||||
expect(cardBox!.width).toBeGreaterThan(panelBox!.width * 0.8)
|
||||
|
||||
// Clicking a dot swipes to that slide
|
||||
await dots.nth(1).click()
|
||||
await expect(dots.nth(1)).toHaveAttribute('aria-current', 'true')
|
||||
|
||||
await panel.getByRole('button', { name: /hide errors/i }).click()
|
||||
await expect(errorCards.first()).toBeHidden()
|
||||
|
||||
await panel.getByRole('button', { name: /show errors/i }).click()
|
||||
await expect(errorCards.first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('desktop panel does not cover the minimap', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Minimap.Visible', true)
|
||||
const panel = comfyPage.page.getByTestId(TestIds.errorResolution.panel)
|
||||
const minimap = comfyPage.page.getByTestId('minimap-container')
|
||||
await expect(panel).toBeVisible()
|
||||
await expect(minimap).toBeVisible()
|
||||
|
||||
const panelBox = await panel.boundingBox()
|
||||
const minimapBox = await minimap.boundingBox()
|
||||
expect(panelBox).not.toBeNull()
|
||||
expect(minimapBox).not.toBeNull()
|
||||
expect(panelBox!.y + panelBox!.height).toBeLessThanOrEqual(minimapBox!.y)
|
||||
})
|
||||
|
||||
test('desktop panel does not cover the canvas menu when minimap is hidden', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Graph.CanvasMenu', true)
|
||||
await comfyPage.settings.setSetting('Comfy.Minimap.Visible', false)
|
||||
const panel = comfyPage.page.getByTestId(TestIds.errorResolution.panel)
|
||||
const minimapToggle = comfyPage.page.getByTestId('toggle-minimap-button')
|
||||
await expect(panel).toBeVisible()
|
||||
await expect(minimapToggle).toBeVisible()
|
||||
|
||||
const panelBox = await panel.boundingBox()
|
||||
const toggleBox = await minimapToggle.boundingBox()
|
||||
expect(panelBox).not.toBeNull()
|
||||
expect(toggleBox).not.toBeNull()
|
||||
expect(panelBox!.y + panelBox!.height).toBeLessThanOrEqual(toggleBox!.y)
|
||||
})
|
||||
|
||||
test('right screen edge has no splitter gutter hit area', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.errorResolution.panel)
|
||||
).toBeVisible()
|
||||
|
||||
const edgeHitsGutter = await comfyPage.page.evaluate(() => {
|
||||
const x = window.innerWidth - 1
|
||||
for (const y of [100, window.innerHeight / 2, window.innerHeight - 100]) {
|
||||
const el = document.elementFromPoint(x, y)
|
||||
if (el?.closest('.p-splitter-gutter')) return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
expect(edgeHitsGutter).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -11,47 +11,39 @@ export class Load3DHelper {
|
||||
}
|
||||
|
||||
get menuButton(): Locator {
|
||||
return this.node.getByTestId(TestIds.load3d.categoryMenu)
|
||||
}
|
||||
|
||||
private get menuPanel(): Locator {
|
||||
return this.node.page().getByRole('dialog')
|
||||
return this.node.getByRole('button', { name: /show menu/i })
|
||||
}
|
||||
|
||||
get recordingButton(): Locator {
|
||||
return this.node.getByRole('button', { name: 'Record', exact: true })
|
||||
return this.node.getByRole('button', { name: /start recording/i })
|
||||
}
|
||||
|
||||
get stopRecordingButton(): Locator {
|
||||
return this.node.getByRole('button', { name: /stop recording/i })
|
||||
}
|
||||
|
||||
get recordingMenuButton(): Locator {
|
||||
get exportRecordingButton(): Locator {
|
||||
return this.node.getByRole('button', { name: /export recording/i })
|
||||
}
|
||||
|
||||
get clearRecordingButton(): Locator {
|
||||
return this.node.getByRole('button', { name: /clear recording/i })
|
||||
}
|
||||
|
||||
get recordingDuration(): Locator {
|
||||
return this.node.getByTestId(TestIds.load3d.recordingDuration)
|
||||
}
|
||||
|
||||
get downloadRecordingMenuItem(): Locator {
|
||||
return this.menuPanel.getByRole('button', { name: 'Download Recording' })
|
||||
}
|
||||
|
||||
get startNewRecordingMenuItem(): Locator {
|
||||
return this.menuPanel.getByRole('button', { name: 'Start New Recording' })
|
||||
}
|
||||
|
||||
get deleteRecordingMenuItem(): Locator {
|
||||
return this.menuPanel.getByRole('button', { name: 'Delete Recording' })
|
||||
}
|
||||
|
||||
get gridToggleButton(): Locator {
|
||||
return this.node.getByRole('button', { name: /show grid/i })
|
||||
}
|
||||
|
||||
get uploadBackgroundImageButton(): Locator {
|
||||
return this.node.getByRole('button', { name: 'BG Image' })
|
||||
return this.node.getByRole('button', { name: /upload background image/i })
|
||||
}
|
||||
|
||||
get removeBackgroundImageButton(): Locator {
|
||||
return this.node.getByRole('button', { name: 'Remove BG' })
|
||||
return this.node.getByRole('button', { name: /remove background image/i })
|
||||
}
|
||||
|
||||
get panoramaModeButton(): Locator {
|
||||
@@ -62,10 +54,6 @@ export class Load3DHelper {
|
||||
return this.node.locator('input[type="color"]')
|
||||
}
|
||||
|
||||
get exportButton(): Locator {
|
||||
return this.node.getByRole('button', { name: 'Export', exact: true })
|
||||
}
|
||||
|
||||
get openViewerButton(): Locator {
|
||||
return this.node.getByRole('button', { name: /open in 3d viewer/i })
|
||||
}
|
||||
@@ -75,15 +63,11 @@ export class Load3DHelper {
|
||||
}
|
||||
|
||||
getMenuCategory(name: string): Locator {
|
||||
return this.menuPanel.getByRole('button', { name, exact: true })
|
||||
return this.node.getByText(name, { exact: true })
|
||||
}
|
||||
|
||||
get gizmoToggleButton(): Locator {
|
||||
// The category chip is also named "Gizmo" once that category is active;
|
||||
// only the toggle carries aria-pressed.
|
||||
return this.node
|
||||
.getByRole('button', { name: 'Gizmo' })
|
||||
.and(this.node.locator('[aria-pressed]'))
|
||||
return this.node.getByRole('button', { name: 'Gizmo' })
|
||||
}
|
||||
|
||||
get gizmoTranslateButton(): Locator {
|
||||
@@ -99,17 +83,13 @@ export class Load3DHelper {
|
||||
}
|
||||
|
||||
get gizmoResetButton(): Locator {
|
||||
return this.node.getByRole('button', { name: 'Reset', exact: true })
|
||||
return this.node.getByRole('button', { name: 'Reset Transform' })
|
||||
}
|
||||
|
||||
async openMenu(): Promise<void> {
|
||||
await this.menuButton.click()
|
||||
}
|
||||
|
||||
async openRecordingMenu(): Promise<void> {
|
||||
await this.recordingMenuButton.click()
|
||||
}
|
||||
|
||||
async openGizmoCategory(): Promise<void> {
|
||||
await this.openMenu()
|
||||
await this.getMenuCategory('Gizmo').click()
|
||||
|
||||
@@ -47,12 +47,10 @@ test.describe('Load3D', () => {
|
||||
await load3d.openMenu()
|
||||
|
||||
await expect(load3d.getMenuCategory('Scene')).toBeVisible()
|
||||
await expect(load3d.getMenuCategory('3D Model')).toBeVisible()
|
||||
await expect(load3d.getMenuCategory('Model')).toBeVisible()
|
||||
await expect(load3d.getMenuCategory('Camera')).toBeVisible()
|
||||
await expect(load3d.getMenuCategory('Light')).toBeVisible()
|
||||
await expect(load3d.getMenuCategory('HDRI')).toBeVisible()
|
||||
await expect(load3d.getMenuCategory('Gizmo')).toBeVisible()
|
||||
await expect(load3d.exportButton).toBeVisible()
|
||||
await expect(load3d.getMenuCategory('Export')).toBeVisible()
|
||||
|
||||
await expect(load3d.node).toHaveScreenshot(
|
||||
'load3d-controls-menu-open.png',
|
||||
@@ -255,7 +253,7 @@ test.describe('Load3D', () => {
|
||||
}
|
||||
)
|
||||
|
||||
test('Recording controls collapse into a duration chip with a menu after a recording', async ({
|
||||
test('Recording controls show stop/export/clear buttons after a recording', async ({
|
||||
comfyPage,
|
||||
load3d
|
||||
}) => {
|
||||
@@ -267,25 +265,20 @@ test.describe('Load3D', () => {
|
||||
await expect(load3d.stopRecordingButton).toBeVisible()
|
||||
})
|
||||
|
||||
await test.step('Stop recording surfaces the duration chip with a 1s duration', async () => {
|
||||
await test.step('Stop recording surfaces export/clear controls and a 1s duration', async () => {
|
||||
// Record for 1s wall-clock so the duration display settles on 00:01.
|
||||
await comfyPage.delay(1000)
|
||||
await load3d.stopRecordingButton.click()
|
||||
await expect(load3d.recordingMenuButton).toBeVisible()
|
||||
await expect(load3d.recordingMenuButton).toHaveText('00:01')
|
||||
})
|
||||
|
||||
await test.step('Chip menu offers download, re-record and delete actions', async () => {
|
||||
await load3d.openRecordingMenu()
|
||||
await expect(load3d.downloadRecordingMenuItem).toBeVisible()
|
||||
await expect(load3d.startNewRecordingMenuItem).toBeVisible()
|
||||
await expect(load3d.deleteRecordingMenuItem).toBeVisible()
|
||||
})
|
||||
|
||||
await test.step('Deleting the recording restores the record button', async () => {
|
||||
await load3d.deleteRecordingMenuItem.click()
|
||||
await expect(load3d.recordingMenuButton).toHaveCount(0)
|
||||
await expect(load3d.recordingButton).toBeVisible()
|
||||
await expect(load3d.exportRecordingButton).toBeVisible()
|
||||
await expect(load3d.clearRecordingButton).toBeVisible()
|
||||
await expect(load3d.recordingDuration).toHaveText('00:01')
|
||||
})
|
||||
|
||||
await test.step('Clear recording removes export and clear controls', async () => {
|
||||
await load3d.clearRecordingButton.click()
|
||||
await expect(load3d.exportRecordingButton).toHaveCount(0)
|
||||
await expect(load3d.clearRecordingButton).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 109 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 148 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 44 KiB |
@@ -476,37 +476,6 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
|
||||
})
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
test(
|
||||
'Closing minimap after subgraph navigation keeps Vue render in sync',
|
||||
{ tag: '@vue-nodes' },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNodeId = await comfyPage.subgraph.findSubgraphNodeId()
|
||||
|
||||
// Round-trip layers Vue's onNodeAdded wrapper on top of the minimap's.
|
||||
await comfyPage.vueNodes.enterSubgraph(subgraphNodeId)
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
// Minimap unmount must not clobber the Vue wrapper layered above it.
|
||||
await comfyPage.page
|
||||
.getByTestId(TestIds.canvas.closeMinimapButton)
|
||||
.click()
|
||||
|
||||
const subgraphFixture =
|
||||
await comfyPage.vueNodes.getFixtureByTitle('New Subgraph')
|
||||
await comfyPage.contextMenu.openForVueNode(subgraphFixture.header)
|
||||
await comfyPage.contextMenu.clickMenuItemExact('Unpack Subgraph')
|
||||
await comfyPage.contextMenu.waitForHidden()
|
||||
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(2)
|
||||
await expect.poll(() => comfyPage.vueNodes.getNodeCount()).toBe(2)
|
||||
await expect(
|
||||
comfyPage.vueNodes.getNodeLocator(subgraphNodeId)
|
||||
).toHaveCount(0)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test.describe('Minimap mobile', { tag: ['@mobile', '@canvas'] }, () => {
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import { mergeTests } from '@playwright/test'
|
||||
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
|
||||
const wstest = mergeTests(test, webSocketFixture)
|
||||
|
||||
test.describe('Preview as Text node', () => {
|
||||
test('does not include preview widget values in the API prompt', async ({
|
||||
@@ -45,34 +39,4 @@ test.describe('Preview as Text node', () => {
|
||||
expect(previewEntry!.inputs).not.toHaveProperty('preview_text')
|
||||
expect(previewEntry!.inputs).not.toHaveProperty('previewMode')
|
||||
})
|
||||
|
||||
wstest(
|
||||
'restoring workflow restores state',
|
||||
{ tag: '@vue-nodes' },
|
||||
async ({ comfyPage, getWebSocket }) => {
|
||||
const execution = new ExecutionHelper(comfyPage, await getWebSocket())
|
||||
|
||||
await comfyPage.menu.topbar.newWorkflowButton.click()
|
||||
await comfyPage.searchBoxV2.addNode('Preview as Text')
|
||||
const node = await comfyPage.vueNodes.getFixtureByTitle('Preview as Text')
|
||||
const preview = node.root.locator('textarea')
|
||||
|
||||
await test.step('node previews execution result', async () => {
|
||||
const id = await comfyPage.vueNodes.getNodeIdByTitle('Preview as Text')
|
||||
execution.executed('', id, { text: 'massive fennec ears' })
|
||||
await expect(preview).toHaveValue('massive fennec ears')
|
||||
})
|
||||
|
||||
await test.step('swap to a different workflow and back', async () => {
|
||||
await comfyPage.menu.topbar.getTab(0).click()
|
||||
await expect(node.root).toBeHidden()
|
||||
await comfyPage.menu.topbar.getTab(1).click()
|
||||
await expect(node.root).toBeVisible()
|
||||
})
|
||||
|
||||
await expect(preview, 'previous output is restored').toHaveValue(
|
||||
'massive fennec ears'
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.48.2",
|
||||
"version": "1.47.7",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -114,7 +114,6 @@
|
||||
"jsonata": "catalog:",
|
||||
"loglevel": "^1.9.2",
|
||||
"marked": "^15.0.11",
|
||||
"minisearch": "catalog:",
|
||||
"pinia": "catalog:",
|
||||
"posthog-js": "catalog:",
|
||||
"primeicons": "catalog:",
|
||||
|
||||
19
pnpm-lock.yaml
generated
19
pnpm-lock.yaml
generated
@@ -282,9 +282,6 @@ catalogs:
|
||||
markdown-table:
|
||||
specifier: ^3.0.4
|
||||
version: 3.0.4
|
||||
minisearch:
|
||||
specifier: ^7.2.0
|
||||
version: 7.2.0
|
||||
mixpanel-browser:
|
||||
specifier: ^2.71.0
|
||||
version: 2.71.0
|
||||
@@ -594,9 +591,6 @@ importers:
|
||||
marked:
|
||||
specifier: ^15.0.11
|
||||
version: 15.0.11
|
||||
minisearch:
|
||||
specifier: 'catalog:'
|
||||
version: 7.2.0
|
||||
pinia:
|
||||
specifier: 'catalog:'
|
||||
version: 3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3))
|
||||
@@ -7030,9 +7024,6 @@ packages:
|
||||
resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
|
||||
minisearch@7.2.0:
|
||||
resolution: {integrity: sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==}
|
||||
|
||||
mitt@3.0.1:
|
||||
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
|
||||
|
||||
@@ -8893,8 +8884,8 @@ packages:
|
||||
vue-component-type-helpers@3.3.2:
|
||||
resolution: {integrity: sha512-l4Z2Y34m7nFMlx8vrslJaVtXxUpzgDMSESC7TakG/c5kwjYT/do+E0NcT2/vWDzaoIhsShg/2OKwX7Q4nbzC0g==}
|
||||
|
||||
vue-component-type-helpers@3.3.6:
|
||||
resolution: {integrity: sha512-FkljacAwJ9BUoSUdpFe3VDy0sGigNlTH9+2zcXUWmZOjN8swiCkl3t48wOJun0OsUd2cEIda1l04tsxMiKIIrQ==}
|
||||
vue-component-type-helpers@3.3.5:
|
||||
resolution: {integrity: sha512-Fe1jyPJoUGpJOYKOri44jduR7My4yYINOMJISuMAbmrs+L5LbIDUc8NTWZYY3EJLK0yPLuCmcd5zoCsE4k2/KA==}
|
||||
|
||||
vue-demi@0.14.10:
|
||||
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
|
||||
@@ -11677,7 +11668,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.34(typescript@5.9.3)
|
||||
vue-component-type-helpers: 3.3.6
|
||||
vue-component-type-helpers: 3.3.5
|
||||
|
||||
'@swc/helpers@0.5.21':
|
||||
dependencies:
|
||||
@@ -15826,8 +15817,6 @@ snapshots:
|
||||
|
||||
minipass@7.1.3: {}
|
||||
|
||||
minisearch@7.2.0: {}
|
||||
|
||||
mitt@3.0.1: {}
|
||||
|
||||
mixpanel-browser@2.71.0:
|
||||
@@ -18149,7 +18138,7 @@ snapshots:
|
||||
|
||||
vue-component-type-helpers@3.3.2: {}
|
||||
|
||||
vue-component-type-helpers@3.3.6: {}
|
||||
vue-component-type-helpers@3.3.5: {}
|
||||
|
||||
vue-demi@0.14.10(vue@3.5.34(typescript@5.9.3)):
|
||||
dependencies:
|
||||
|
||||
@@ -103,7 +103,6 @@ catalog:
|
||||
lenis: ^1.3.21
|
||||
lint-staged: ^16.2.7
|
||||
markdown-table: ^3.0.4
|
||||
minisearch: ^7.2.0
|
||||
mixpanel-browser: ^2.71.0
|
||||
monocart-coverage-reports: ^2.12.9
|
||||
oxfmt: ^0.54.0
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
:pt:gutter="
|
||||
cn(
|
||||
'rounded-t-lg',
|
||||
!(bottomPanelVisible && !focusMode) && 'hidden'
|
||||
!(bottomPanelVisible && !isChromeHidden) && 'hidden'
|
||||
)
|
||||
"
|
||||
state-key="bottom-panel-splitter"
|
||||
@@ -81,7 +81,7 @@
|
||||
<slot name="graph-canvas-panel" />
|
||||
</SplitterPanel>
|
||||
<SplitterPanel
|
||||
v-show="bottomPanelVisible && !focusMode"
|
||||
v-show="bottomPanelVisible && !isChromeHidden"
|
||||
class="bottom-panel pointer-events-auto max-w-full overflow-x-auto rounded-lg border border-(--p-panel-border-color) bg-comfy-menu-bg focus-visible:outline-hidden"
|
||||
>
|
||||
<slot name="bottom-panel" />
|
||||
@@ -131,6 +131,7 @@ import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useChromeVisibility } from '@/composables/useChromeVisibility'
|
||||
import {
|
||||
BUILDER_MIN_SIZE,
|
||||
CENTER_PANEL_SIZE,
|
||||
@@ -141,9 +142,7 @@ import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const settingStore = useSettingStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const sidebarTabStore = useSidebarTabStore()
|
||||
@@ -156,7 +155,7 @@ const unifiedWidth = computed(() =>
|
||||
settingStore.get('Comfy.Sidebar.UnifiedWidth')
|
||||
)
|
||||
|
||||
const { focusMode } = storeToRefs(workspaceStore)
|
||||
const { isChromeHidden } = useChromeVisibility()
|
||||
|
||||
const { isSelectMode, isBuilderMode } = useAppMode()
|
||||
const { activeSidebarTabId, activeSidebarTab } = storeToRefs(sidebarTabStore)
|
||||
@@ -189,7 +188,9 @@ const lastPanelVisible = computed(
|
||||
*/
|
||||
const bothSidePanelsVisible = computed(
|
||||
() =>
|
||||
!focusMode.value && sidebarPanelVisible.value && showOffsideSplitter.value
|
||||
!isChromeHidden.value &&
|
||||
sidebarPanelVisible.value &&
|
||||
showOffsideSplitter.value
|
||||
)
|
||||
|
||||
const centerPanelDefaultSize = computed(() =>
|
||||
@@ -264,7 +265,7 @@ const splitterRefreshKey = computed(() => {
|
||||
})
|
||||
|
||||
const firstPanelStyle = computed(() => {
|
||||
if (focusMode.value) return { display: 'none' }
|
||||
if (isChromeHidden.value) return { display: 'none' }
|
||||
if (sidebarLocation.value === 'left') {
|
||||
return { display: sidebarPanelVisible.value ? 'flex' : 'none' }
|
||||
}
|
||||
@@ -272,7 +273,7 @@ const firstPanelStyle = computed(() => {
|
||||
})
|
||||
|
||||
const lastPanelStyle = computed(() => {
|
||||
if (focusMode.value) return { display: 'none' }
|
||||
if (isChromeHidden.value) return { display: 'none' }
|
||||
if (sidebarLocation.value === 'right') {
|
||||
return { display: sidebarPanelVisible.value ? 'flex' : 'none' }
|
||||
}
|
||||
@@ -296,7 +297,9 @@ const lastPanelStyle = computed(() => {
|
||||
[data-pc-name='splitterpanel'][style*='display: none'] + .p-splitter-gutter
|
||||
),
|
||||
:deep(
|
||||
.p-splitter-gutter + [data-pc-name='splitterpanel'][style*='display: none']
|
||||
.p-splitter-gutter:has(
|
||||
+ [data-pc-name='splitterpanel'][style*='display: none']
|
||||
)
|
||||
) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
v-show="workspaceState.focusMode"
|
||||
v-show="workspaceState.focusMode && !errorResolutionStore.isActive"
|
||||
class="no-drag fixed top-0 right-0 z-9999 flex flex-row"
|
||||
>
|
||||
<Button
|
||||
@@ -24,10 +24,12 @@ import { watchEffect } from 'vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useErrorResolutionStore } from '@/stores/workspace/errorResolutionStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { showNativeSystemMenu } from '@/utils/envUtil'
|
||||
|
||||
const workspaceState = useWorkspaceStore()
|
||||
const errorResolutionStore = useErrorResolutionStore()
|
||||
const settingStore = useSettingStore()
|
||||
const exitFocusMode = () => {
|
||||
workspaceState.focusMode = false
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="!workspaceStore.focusMode"
|
||||
v-if="!isChromeHidden"
|
||||
class="ml-1 flex flex-col gap-1 pt-1"
|
||||
@mouseenter="isTopMenuHovered = true"
|
||||
@mouseleave="isTopMenuHovered = false"
|
||||
@@ -145,6 +145,7 @@ import StatusBadge from '@/components/common/StatusBadge.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
|
||||
import { useChromeVisibility } from '@/composables/useChromeVisibility'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
@@ -154,7 +155,6 @@ import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useActionBarButtonStore } from '@/stores/actionBarButtonStore'
|
||||
import { useQueueUIStore } from '@/stores/queueStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { isCloud, isDesktop } from '@/platform/distribution/types'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import {
|
||||
@@ -168,7 +168,7 @@ import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTyp
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const { isChromeHidden } = useChromeVisibility()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const managerState = useManagerState()
|
||||
const managerSurveyDialog = useManagerSurveyDialog()
|
||||
|
||||
@@ -4,67 +4,37 @@
|
||||
data-testid="bounding-boxes"
|
||||
@pointerdown.stop
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<div
|
||||
class="flex h-9 items-center gap-1 rounded-t-sm border border-b-0 border-component-node-border bg-component-node-widget-background px-2"
|
||||
>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:aria-pressed="grid"
|
||||
:class="
|
||||
cn(
|
||||
actionBtnClass,
|
||||
grid && 'bg-component-node-widget-background-selected'
|
||||
)
|
||||
"
|
||||
@click="grid = !grid"
|
||||
>
|
||||
<i class="icon-[lucide--grid-3x3] size-4" />
|
||||
<span>{{ $t('boundingBoxes.grid') }}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:class="cn(actionBtnClass, 'ml-auto')"
|
||||
@click="clearAll"
|
||||
>
|
||||
<i class="icon-[lucide--undo-2] size-4" />
|
||||
<span>{{ $t('boundingBoxes.clearAll') }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
ref="canvasContainer"
|
||||
class="relative w-full shrink-0 overflow-hidden rounded-b-sm border border-t-0 border-component-node-border bg-base-background"
|
||||
:style="canvasStyle"
|
||||
>
|
||||
<canvas
|
||||
ref="canvasEl"
|
||||
tabindex="0"
|
||||
class="absolute inset-0 size-full rounded-sm text-node-component-slot-text outline-none"
|
||||
:style="{ cursor: canvasCursor }"
|
||||
@pointerdown="onPointerDown"
|
||||
@pointermove="onCanvasPointerMove"
|
||||
@pointerup="onDocPointerUp"
|
||||
@pointercancel="onDocPointerUp"
|
||||
@pointerleave="onPointerLeave"
|
||||
@lostpointercapture="onDocPointerUp"
|
||||
@dblclick="onDoubleClick"
|
||||
@keydown="onCanvasKeyDown"
|
||||
@focus="focused = true"
|
||||
@blur="focused = false"
|
||||
/>
|
||||
<textarea
|
||||
v-if="inlineEditor"
|
||||
ref="inlineEditorEl"
|
||||
v-model="inlineEditor.value"
|
||||
class="absolute box-border resize-none rounded-sm border-2 bg-black/90 p-1 font-mono text-xs text-white outline-none"
|
||||
:style="inlineEditor.style"
|
||||
data-capture-wheel="true"
|
||||
@keydown.stop="onInlineKeyDown"
|
||||
@blur="commitInlineEditor"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
ref="canvasContainer"
|
||||
class="relative w-full shrink-0 overflow-hidden rounded-sm border border-component-node-border bg-node-component-surface"
|
||||
:style="canvasStyle"
|
||||
>
|
||||
<canvas
|
||||
ref="canvasEl"
|
||||
tabindex="0"
|
||||
class="absolute inset-0 size-full rounded-sm outline-none"
|
||||
:style="{ cursor: canvasCursor }"
|
||||
@pointerdown="onPointerDown"
|
||||
@pointermove="onCanvasPointerMove"
|
||||
@pointerup="onDocPointerUp"
|
||||
@pointercancel="onDocPointerUp"
|
||||
@pointerleave="onPointerLeave"
|
||||
@lostpointercapture="onDocPointerUp"
|
||||
@dblclick="onDoubleClick"
|
||||
@keydown="onCanvasKeyDown"
|
||||
@focus="focused = true"
|
||||
@blur="focused = false"
|
||||
/>
|
||||
<textarea
|
||||
v-if="inlineEditor"
|
||||
ref="inlineEditorEl"
|
||||
v-model="inlineEditor.value"
|
||||
class="absolute box-border resize-none rounded-sm border-2 bg-black/90 p-1 font-mono text-xs text-white outline-none"
|
||||
:style="inlineEditor.style"
|
||||
data-capture-wheel="true"
|
||||
@keydown.stop="onInlineKeyDown"
|
||||
@blur="commitInlineEditor"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -152,6 +122,16 @@
|
||||
<div v-else-if="hasRegions" class="text-node-text-muted px-1 text-xs">
|
||||
{{ $t('boundingBoxes.clickRegionToEdit') }}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
class="gap-2 rounded-lg border border-component-node-border bg-component-node-background text-xs text-muted-foreground hover:text-base-foreground"
|
||||
@click="clearAll"
|
||||
>
|
||||
<i class="icon-[lucide--undo-2]" />
|
||||
{{ $t('boundingBoxes.clearAll') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -167,9 +147,6 @@ import { useBoundingBoxes } from '@/composables/boundingBoxes/useBoundingBoxes'
|
||||
import type { BoundingBox } from '@/types/boundingBoxes'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
|
||||
const actionBtnClass =
|
||||
'flex shrink-0 items-center gap-1.5 rounded-md border-0 bg-transparent px-2 py-1 text-sm text-base-foreground outline-none transition-colors hover:bg-component-node-widget-background-hovered'
|
||||
|
||||
const { nodeId } = defineProps<{ nodeId: NodeId }>()
|
||||
const modelValue = defineModel<BoundingBox[]>({ default: () => [] })
|
||||
|
||||
@@ -195,8 +172,7 @@ const {
|
||||
commitInlineEditor,
|
||||
setActiveType,
|
||||
clearAll,
|
||||
syncState,
|
||||
grid
|
||||
syncState
|
||||
} = useBoundingBoxes(nodeId, {
|
||||
canvasEl,
|
||||
canvasContainer,
|
||||
|
||||
@@ -97,7 +97,7 @@
|
||||
<!-- Sort Options -->
|
||||
<div>
|
||||
<SingleSelect
|
||||
v-model="sortSelection"
|
||||
v-model="sortBy"
|
||||
:label="$t('templateWorkflows.sorting', 'Sort by')"
|
||||
:options="sortOptions"
|
||||
:content-style="selectContentStyle"
|
||||
@@ -556,8 +556,7 @@ const {
|
||||
selectedModels,
|
||||
selectedUseCases,
|
||||
selectedRunsOn,
|
||||
sortSelection,
|
||||
hasActiveQuery,
|
||||
sortBy,
|
||||
activeModels,
|
||||
activeUseCases,
|
||||
filteredTemplates,
|
||||
@@ -566,13 +565,14 @@ const {
|
||||
availableRunsOn,
|
||||
filteredCount,
|
||||
totalCount,
|
||||
resetFilters
|
||||
resetFilters,
|
||||
loadFuseOptions
|
||||
} = useTemplateFiltering(navigationFilteredTemplates)
|
||||
|
||||
/**
|
||||
* Raw search input bound to the search box. The actual `searchQuery` consumed
|
||||
* by the filtering composable is only updated via `applySearchQuery` after the
|
||||
* debounce settles, keeping search/grid re-renders off the keystroke critical path.
|
||||
* debounce settles, keeping Fuse/grid re-renders off the keystroke critical path.
|
||||
*/
|
||||
const searchInput = ref(searchQuery.value)
|
||||
|
||||
@@ -595,13 +595,15 @@ watch(searchQuery, (value) => {
|
||||
*/
|
||||
const coordinateNavAndSort = (source: 'nav' | 'sort') => {
|
||||
const isPopularNav = selectedNavItem.value === 'popular'
|
||||
const isPopularSort = sortSelection.value === 'popular'
|
||||
const isPopularSort = sortBy.value === 'popular'
|
||||
|
||||
if (source === 'nav') {
|
||||
if (isPopularNav && !isPopularSort) {
|
||||
sortSelection.value = 'popular'
|
||||
// When navigating to 'Popular' category, automatically set sort to 'Popular'.
|
||||
sortBy.value = 'popular'
|
||||
} else if (!isPopularNav && isPopularSort) {
|
||||
sortSelection.value = 'default'
|
||||
// When navigating away from 'Popular' category while sort is 'Popular', reset sort to default.
|
||||
sortBy.value = 'default'
|
||||
}
|
||||
} else if (source === 'sort') {
|
||||
// When sort is changed away from 'Popular' while in the 'Popular' category,
|
||||
@@ -614,7 +616,7 @@ const coordinateNavAndSort = (source: 'nav' | 'sort') => {
|
||||
|
||||
// Watch for changes from the two sources ('nav' and 'sort') and trigger the coordinator.
|
||||
watch(selectedNavItem, () => coordinateNavAndSort('nav'))
|
||||
watch(sortSelection, () => coordinateNavAndSort('sort'))
|
||||
watch(sortBy, () => coordinateNavAndSort('sort'))
|
||||
|
||||
// Convert between string array and object array for MultiSelect component
|
||||
// Only show selected items that exist in the current scope
|
||||
@@ -724,15 +726,8 @@ const runsOnFilterLabel = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
// Sort options
|
||||
const sortOptions = computed(() => [
|
||||
...(hasActiveQuery.value
|
||||
? [
|
||||
{
|
||||
name: t('templateWorkflows.sort.relevance', 'Relevance'),
|
||||
value: 'relevance'
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: t('templateWorkflows.sort.default', 'Default'),
|
||||
value: 'default'
|
||||
@@ -794,7 +789,7 @@ watch(
|
||||
[
|
||||
filteredTemplates,
|
||||
selectedNavItem,
|
||||
sortSelection,
|
||||
sortBy,
|
||||
selectedModels,
|
||||
selectedUseCases,
|
||||
selectedRunsOn
|
||||
@@ -844,7 +839,8 @@ const { isLoading } = useAsyncState(
|
||||
async () => {
|
||||
await Promise.all([
|
||||
loadTemplates(),
|
||||
workflowTemplatesStore.loadWorkflowTemplates()
|
||||
workflowTemplatesStore.loadWorkflowTemplates(),
|
||||
loadFuseOptions()
|
||||
])
|
||||
return true
|
||||
},
|
||||
|
||||
@@ -449,12 +449,6 @@ describe('shouldPreventRekaDismiss', () => {
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
})
|
||||
|
||||
it('focus-outside never dismisses when dismissOnFocusOutside is false', () => {
|
||||
const event = makeEvent(document.body)
|
||||
onRekaFocusOutside(event, { dismissOnFocusOutside: false })
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
})
|
||||
|
||||
it('focus-outside on a sibling Reka portal does not dismiss the parent', () => {
|
||||
const portal = document.createElement('div')
|
||||
portal.setAttribute('role', 'dialog')
|
||||
|
||||
@@ -32,9 +32,7 @@
|
||||
dialogStore.activeKey === item.key
|
||||
)
|
||||
"
|
||||
@focus-outside="
|
||||
(e) => onRekaFocusOutside(e, item.dialogComponentProps)
|
||||
"
|
||||
@focus-outside="onRekaFocusOutside"
|
||||
@mousedown="() => dialogStore.riseDialog({ key: item.key })"
|
||||
>
|
||||
<template v-if="item.dialogComponentProps.headless">
|
||||
|
||||
@@ -53,22 +53,7 @@ export function onRekaPointerDownOutside(
|
||||
// nested Reka or PrimeVue dialog teleported to body). Without this guard a
|
||||
// non-modal Reka dialog would dismiss itself the moment a nested dialog
|
||||
// receives focus.
|
||||
//
|
||||
// A container dialog (e.g. Settings) that hosts nested confirm/edit dialogs can
|
||||
// also lose focus to an ordinary app element — not just a portal — when a
|
||||
// nested dialog closes and the element it focused was removed (deleting the
|
||||
// selected row). That programmatic focus shift is not a dismiss intent, so such
|
||||
// a dialog opts out of focus-outside dismissal entirely via
|
||||
// `dismissOnFocusOutside: false`; it still dismisses on escape or an outside
|
||||
// pointer.
|
||||
export function onRekaFocusOutside(
|
||||
event: OutsideEvent,
|
||||
options: { dismissOnFocusOutside?: boolean } = {}
|
||||
) {
|
||||
if (options.dismissOnFocusOutside === false) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
export function onRekaFocusOutside(event: OutsideEvent) {
|
||||
if (isInsideOverlay(event.detail.originalEvent.target)) {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
@@ -81,9 +81,7 @@ function createTestI18n() {
|
||||
viewDetails: 'View details'
|
||||
},
|
||||
linearMode: {
|
||||
error: {
|
||||
goto: 'Show errors in graph'
|
||||
}
|
||||
fixErrors: 'Fix errors'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -196,7 +194,7 @@ describe('ErrorOverlay', () => {
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByTestId('error-overlay-see-errors')).toHaveTextContent(
|
||||
'Show errors in graph'
|
||||
'Fix errors'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
>
|
||||
{{
|
||||
appMode
|
||||
? t('linearMode.error.goto')
|
||||
? t('linearMode.fixErrors')
|
||||
: t('errorOverlay.viewDetails')
|
||||
}}
|
||||
</Button>
|
||||
|
||||
116
src/components/errorResolution/ErrorResolutionOverlay.test.ts
Normal file
116
src/components/errorResolution/ErrorResolutionOverlay.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { render, screen, waitFor } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useErrorResolutionStore } from '@/stores/workspace/errorResolutionStore'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
|
||||
import ErrorResolutionOverlay from './ErrorResolutionOverlay.vue'
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
rootGraph: {
|
||||
serialize: vi.fn(() => ({})),
|
||||
getNodeById: vi.fn()
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
// The real store initializes the auth chain, which cannot run in tests
|
||||
const workspaceMock = vi.hoisted(() => ({ focusMode: false }))
|
||||
vi.mock('@/stores/workspaceStore', () => ({
|
||||
useWorkspaceStore: () => workspaceMock
|
||||
}))
|
||||
|
||||
vi.mock('@/components/errorResolution/ErrorResolutionPanel.vue', async () => {
|
||||
const { defineComponent, h } = await import('vue')
|
||||
return {
|
||||
// Without this the async loader mounts the module namespace itself
|
||||
// instead of unwrapping the default export
|
||||
__esModule: true,
|
||||
default: defineComponent({
|
||||
render: () => h('section', { 'data-testid': 'error-resolution-panel' })
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
errorResolution: {
|
||||
backToApp: 'Back to App Mode'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function renderOverlay() {
|
||||
const user = userEvent.setup()
|
||||
const result = render(ErrorResolutionOverlay, {
|
||||
global: {
|
||||
plugins: [
|
||||
i18n,
|
||||
createTestingPinia({ createSpy: vi.fn, stubActions: false })
|
||||
]
|
||||
}
|
||||
})
|
||||
const workflowStore = useWorkflowStore()
|
||||
workflowStore.activeWorkflow = fromAny({ activeMode: 'graph' })
|
||||
return { user, workflowStore, ...result }
|
||||
}
|
||||
|
||||
describe('ErrorResolutionOverlay.vue', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
workspaceMock.focusMode = false
|
||||
})
|
||||
|
||||
it('renders nothing while the view is inactive', () => {
|
||||
renderOverlay()
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('error-resolution-panel')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the back button and panel while active in graph mode', async () => {
|
||||
renderOverlay()
|
||||
useErrorResolutionStore().enter()
|
||||
|
||||
expect(
|
||||
await screen.findByTestId('error-resolution-panel')
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByTestId('error-resolution-back')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('stays hidden while the workflow is in app mode', async () => {
|
||||
const { workflowStore } = renderOverlay()
|
||||
useErrorResolutionStore().enter()
|
||||
workflowStore.activeWorkflow = fromAny({ activeMode: 'app' })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByTestId('error-resolution-panel')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('returns to app mode via the back button', async () => {
|
||||
const { user } = renderOverlay()
|
||||
const errorResolutionStore = useErrorResolutionStore()
|
||||
workspaceMock.focusMode = true
|
||||
errorResolutionStore.enter()
|
||||
|
||||
await user.click(await screen.findByTestId('error-resolution-back'))
|
||||
|
||||
expect(errorResolutionStore.isActive).toBe(false)
|
||||
expect(workspaceMock.focusMode).toBe(false)
|
||||
expect(useCanvasStore().linearMode).toBe(true)
|
||||
})
|
||||
})
|
||||
47
src/components/errorResolution/ErrorResolutionOverlay.vue
Normal file
47
src/components/errorResolution/ErrorResolutionOverlay.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<template v-if="isVisible">
|
||||
<Button
|
||||
v-if="!isNarrow"
|
||||
data-testid="error-resolution-back"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
class="fixed top-2 left-2 z-1000"
|
||||
@click="backToAppMode"
|
||||
>
|
||||
<i class="icon-[lucide--arrow-left] size-4" />
|
||||
{{ t('errorResolution.backToApp') }}
|
||||
</Button>
|
||||
<ErrorResolutionPanel @back="backToAppMode" />
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
|
||||
import { computed, defineAsyncComponent } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useErrorResolutionStore } from '@/stores/workspace/errorResolutionStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
|
||||
const ErrorResolutionPanel = defineAsyncComponent(
|
||||
() => import('@/components/errorResolution/ErrorResolutionPanel.vue')
|
||||
)
|
||||
|
||||
const { t } = useI18n()
|
||||
const canvasStore = useCanvasStore()
|
||||
const errorResolutionStore = useErrorResolutionStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const isNarrow = useBreakpoints(breakpointsTailwind).smaller('md')
|
||||
|
||||
const isVisible = computed(
|
||||
() => errorResolutionStore.isActive && !canvasStore.linearMode
|
||||
)
|
||||
|
||||
function backToAppMode() {
|
||||
errorResolutionStore.exit()
|
||||
workspaceStore.focusMode = false
|
||||
canvasStore.linearMode = true
|
||||
}
|
||||
</script>
|
||||
158
src/components/errorResolution/ErrorResolutionPanel.test.ts
Normal file
158
src/components/errorResolution/ErrorResolutionPanel.test.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { render, screen, waitFor } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { setViewport } from '@/components/searchbox/v2/__test__/testUtils'
|
||||
|
||||
import ErrorResolutionPanel from './ErrorResolutionPanel.vue'
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
rootGraph: {
|
||||
serialize: vi.fn(() => ({})),
|
||||
getNodeById: vi.fn()
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||
getNodeByExecutionId: vi.fn(),
|
||||
getRootParentNode: vi.fn(() => null),
|
||||
forEachNode: vi.fn(),
|
||||
mapAllNodes: vi.fn(() => [])
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useCopyToClipboard', () => ({
|
||||
useCopyToClipboard: vi.fn(() => ({
|
||||
copyToClipboard: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/canvas/useFocusNode', () => ({
|
||||
useFocusNode: vi.fn(() => ({
|
||||
focusNode: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
describe('ErrorResolutionPanel.vue', () => {
|
||||
let i18n: ReturnType<typeof createI18n>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
errorResolution: {
|
||||
title: 'Fix workflow errors',
|
||||
backToApp: 'Back to App Mode',
|
||||
allResolved: 'All errors resolved',
|
||||
allResolvedDesc: 'You are ready to go back to App Mode.',
|
||||
showErrors: 'Show errors',
|
||||
hideErrors: 'Hide errors'
|
||||
},
|
||||
rightSidePanel: {
|
||||
noErrors: 'No errors',
|
||||
noneSearchDesc: 'No results found',
|
||||
errorsDetected: 'Error detected | Errors detected',
|
||||
resolveBeforeRun: 'Resolve before running the workflow',
|
||||
expand: 'Expand',
|
||||
collapse: 'Collapse'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function renderComponent(initialState = {}) {
|
||||
const user = userEvent.setup()
|
||||
const result = render(ErrorResolutionPanel, {
|
||||
global: {
|
||||
plugins: [
|
||||
PrimeVue,
|
||||
i18n,
|
||||
createTestingPinia({
|
||||
createSpy: vi.fn,
|
||||
initialState
|
||||
})
|
||||
],
|
||||
stubs: {
|
||||
AsyncSearchInput: {
|
||||
template: '<input />'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
return { user, ...result }
|
||||
}
|
||||
|
||||
it('shows the resolved state with a back button when no errors exist', async () => {
|
||||
const { user, emitted } = renderComponent()
|
||||
|
||||
expect(
|
||||
screen.getByRole('status'),
|
||||
'the resolved transition is announced via the persistent live region'
|
||||
).toHaveTextContent('All errors resolved')
|
||||
expect(screen.getAllByText('All errors resolved')).not.toHaveLength(0)
|
||||
expect(screen.queryByTestId('errors-summary-hero')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Back to App Mode/i }))
|
||||
expect(emitted('back')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('shows the error list when errors exist', () => {
|
||||
renderComponent({
|
||||
executionError: {
|
||||
lastPromptError: {
|
||||
type: 'prompt_no_outputs',
|
||||
message: 'Server Error: No outputs',
|
||||
details: 'Error details'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('errors-summary-hero')).toBeInTheDocument()
|
||||
expect(screen.queryByText('All errors resolved')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a collapsible top bar with the error count on narrow viewports', async () => {
|
||||
setViewport({ width: 375, height: 800 })
|
||||
const { user, emitted } = renderComponent({
|
||||
executionError: {
|
||||
lastPromptError: {
|
||||
type: 'prompt_no_outputs',
|
||||
message: 'Server Error: No outputs',
|
||||
details: 'Error details'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(
|
||||
screen.getByText('Error detected'),
|
||||
'the top bar shows the error count summary'
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByTestId('errors-summary-hero'),
|
||||
'the hero is replaced by the top bar on narrow viewports'
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Hide errors' }))
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByTestId('selection-context-strip')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Show errors' })
|
||||
).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByTestId('error-resolution-back'))
|
||||
expect(emitted('back')).toHaveLength(1)
|
||||
|
||||
setViewport({ width: 1024, height: 768 })
|
||||
})
|
||||
})
|
||||
161
src/components/errorResolution/ErrorResolutionPanel.vue
Normal file
161
src/components/errorResolution/ErrorResolutionPanel.vue
Normal file
@@ -0,0 +1,161 @@
|
||||
<template>
|
||||
<section
|
||||
data-testid="error-resolution-panel"
|
||||
:class="
|
||||
cn(
|
||||
'pointer-events-auto fixed z-1000 flex flex-col overflow-hidden border-interface-stroke bg-base-background',
|
||||
isNarrow
|
||||
? 'inset-x-0 top-0 border-b'
|
||||
: cn(
|
||||
// Centered in the band above the canvas menu (or the open
|
||||
// minimap): equal 10%-of-band gaps top and bottom
|
||||
'right-1 w-90 max-w-[calc(100vw-0.5rem)] rounded-lg border shadow-interface',
|
||||
isMinimapVisible
|
||||
? 'top-[calc((100%-258px)/10)] bottom-[calc(258px+(100%-258px)/10)]'
|
||||
: 'top-[calc((100%-58px)/10)] bottom-[calc(58px+(100%-58px)/10)]'
|
||||
)
|
||||
)
|
||||
"
|
||||
:aria-label="t('errorResolution.title')"
|
||||
>
|
||||
<!-- Persistent live region: one inserted with its content is not announced -->
|
||||
<span role="status" class="sr-only">
|
||||
{{ isResolved ? t('errorResolution.allResolved') : '' }}
|
||||
</span>
|
||||
<div
|
||||
v-if="isNarrow"
|
||||
class="flex min-w-0 shrink-0 items-center gap-2 bg-base-foreground/5 p-2"
|
||||
>
|
||||
<Button
|
||||
data-testid="error-resolution-back"
|
||||
variant="base"
|
||||
size="icon"
|
||||
class="shrink-0 border border-interface-stroke"
|
||||
:aria-label="t('errorResolution.backToApp')"
|
||||
@click="emit('back')"
|
||||
>
|
||||
<i class="icon-[lucide--arrow-left] size-4" />
|
||||
</Button>
|
||||
<template v-if="isResolved">
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--circle-check] size-5 shrink-0 text-success-background"
|
||||
/>
|
||||
<span class="min-w-0 flex-1 truncate text-sm font-semibold">
|
||||
{{ t('errorResolution.allResolved') }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span
|
||||
class="flex h-10 min-w-7 shrink-0 items-center justify-center px-1 text-2xl/none font-extrabold text-destructive-background-hover tabular-nums"
|
||||
>
|
||||
{{ totalErrorCount }}
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="h-8 w-px shrink-0 bg-interface-stroke"
|
||||
/>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-0.5 px-1">
|
||||
<span
|
||||
class="truncate text-xs/tight font-semibold text-base-foreground"
|
||||
>
|
||||
{{ t('rightSidePanel.errorsDetected', totalErrorCount) }}
|
||||
</span>
|
||||
<span class="truncate text-2xs/tight text-muted-foreground">
|
||||
{{ t('rightSidePanel.resolveBeforeRun') }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
class="shrink-0"
|
||||
:aria-label="
|
||||
isExpanded
|
||||
? t('errorResolution.hideErrors')
|
||||
: t('errorResolution.showErrors')
|
||||
"
|
||||
:aria-expanded="isExpanded"
|
||||
@click="isExpanded = !isExpanded"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
'size-4',
|
||||
isExpanded
|
||||
? 'icon-[lucide--chevron-up]'
|
||||
: 'icon-[lucide--chevron-down]'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<TransitionCollapse>
|
||||
<div v-if="!isNarrow || isExpanded" class="flex min-h-0 flex-1 flex-col">
|
||||
<div
|
||||
v-if="isResolved"
|
||||
class="flex min-h-0 flex-1 flex-col bg-interface-panel-surface p-3"
|
||||
>
|
||||
<div
|
||||
class="flex flex-1 flex-col items-center justify-center gap-3 rounded-lg border border-secondary-background px-6 py-8 text-center"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--circle-check] size-10 text-success-background"
|
||||
/>
|
||||
<p class="m-0 text-sm font-semibold text-base-foreground">
|
||||
{{ t('errorResolution.allResolved') }}
|
||||
</p>
|
||||
<p class="m-0 text-sm text-muted-foreground">
|
||||
{{ t('errorResolution.allResolvedDesc') }}
|
||||
</p>
|
||||
<Button variant="secondary" class="mt-2" @click="emit('back')">
|
||||
<i class="icon-[lucide--arrow-left] size-4" />
|
||||
{{ t('errorResolution.backToApp') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ErrorGroupList
|
||||
v-else
|
||||
:show-search="false"
|
||||
:carousel="isNarrow"
|
||||
class="min-h-0 flex-1"
|
||||
/>
|
||||
</div>
|
||||
</TransitionCollapse>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import ErrorGroupList from '@/components/rightSidePanel/errors/ErrorGroupList.vue'
|
||||
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useErrorGroups } from '@/components/rightSidePanel/errors/useErrorGroups'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
|
||||
const emit = defineEmits<{
|
||||
back: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const settingStore = useSettingStore()
|
||||
const isNarrow = useBreakpoints(breakpointsTailwind).smaller('md')
|
||||
const isExpanded = ref(true)
|
||||
|
||||
const isMinimapVisible = computed(() =>
|
||||
settingStore.get('Comfy.Minimap.Visible')
|
||||
)
|
||||
|
||||
const { allErrorGroups } = useErrorGroups('')
|
||||
const totalErrorCount = computed(() =>
|
||||
allErrorGroups.value.reduce((sum, group) => sum + group.count, 0)
|
||||
)
|
||||
const isResolved = computed(() => allErrorGroups.value.length === 0)
|
||||
</script>
|
||||
@@ -153,6 +153,7 @@ import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
|
||||
import { useNodeBadge } from '@/composables/node/useNodeBadge'
|
||||
import { useCanvasDrop } from '@/composables/useCanvasDrop'
|
||||
import { useChromeVisibility } from '@/composables/useChromeVisibility'
|
||||
import { useContextMenuTranslation } from '@/composables/useContextMenuTranslation'
|
||||
import { useCopy } from '@/composables/useCopy'
|
||||
import { useGlobalLitegraph } from '@/composables/useGlobalLitegraph'
|
||||
@@ -239,9 +240,8 @@ const selectionToolboxEnabled = computed(() =>
|
||||
const activeSidebarTab = computed(() => {
|
||||
return workspaceStore.sidebarTab.activeSidebarTab
|
||||
})
|
||||
const showUI = computed(
|
||||
() => !workspaceStore.focusMode && betaMenuEnabled.value
|
||||
)
|
||||
const { isChromeHidden } = useChromeVisibility()
|
||||
const showUI = computed(() => !isChromeHidden.value && betaMenuEnabled.value)
|
||||
|
||||
const minimapEnabled = computed(() => settingStore.get('Comfy.Minimap.Visible'))
|
||||
|
||||
|
||||
@@ -128,9 +128,9 @@ function renderLoad3D(options: RenderOptions = {}) {
|
||||
name: 'AnimationControls',
|
||||
template: '<div data-testid="animation-controls" />'
|
||||
},
|
||||
RecordMenuControl: {
|
||||
name: 'RecordMenuControl',
|
||||
template: '<div data-testid="record-menu-control" />'
|
||||
RecordingControls: {
|
||||
name: 'RecordingControls',
|
||||
template: '<div data-testid="recording-controls" />'
|
||||
},
|
||||
ViewerControls: {
|
||||
name: 'ViewerControls',
|
||||
@@ -232,16 +232,14 @@ describe('Load3D', () => {
|
||||
})
|
||||
|
||||
describe('recording controls', () => {
|
||||
it('renders the record control in regular (non-preview) mode', () => {
|
||||
it('renders RecordingControls in regular (non-preview) mode', () => {
|
||||
renderLoad3D({ stateOverrides: { isPreview: ref(false) } })
|
||||
expect(screen.getByTestId('record-menu-control')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('recording-controls')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the record control in preview mode', () => {
|
||||
it('hides RecordingControls in preview mode', () => {
|
||||
renderLoad3D({ stateOverrides: { isPreview: ref(true) } })
|
||||
expect(
|
||||
screen.queryByTestId('record-menu-control')
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('recording-controls')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -15,39 +15,25 @@
|
||||
:is-preview="isPreview"
|
||||
/>
|
||||
<div class="pointer-events-none absolute top-0 left-0 size-full">
|
||||
<Load3DMenuBar
|
||||
<Load3DControls
|
||||
v-model:scene-config="sceneConfig"
|
||||
v-model:model-config="modelConfig"
|
||||
v-model:camera-config="cameraConfig"
|
||||
v-model:light-config="lightConfig"
|
||||
v-model:is-recording="isRecording"
|
||||
v-model:has-recording="hasRecording"
|
||||
v-model:recording-duration="recordingDuration"
|
||||
:can-use-gizmo="canUseGizmo"
|
||||
:can-use-lighting="canUseLighting"
|
||||
:can-export="canExport"
|
||||
:can-use-hdri="canUseHdri"
|
||||
:can-use-background-image="canUseBackgroundImage"
|
||||
:can-fit-to-viewer="canFitToViewer"
|
||||
:can-center-camera-on-model="canCenterCameraOnModel"
|
||||
:node="node as LGraphNode"
|
||||
:enable-viewer="enable3DViewer"
|
||||
:can-use-recording="canUseRecording && !isPreview"
|
||||
:material-modes="materialModes"
|
||||
:has-skeleton="hasSkeleton"
|
||||
:source-format="sourceFormat"
|
||||
@update-background-image="handleBackgroundImageUpdate"
|
||||
@update-hdri-file="handleHDRIFileUpdate"
|
||||
@export-model="handleExportModel"
|
||||
@fit-to-viewer="handleFitToViewer"
|
||||
@center-camera="handleCenterCameraOnModel"
|
||||
@update-hdri-file="handleHDRIFileUpdate"
|
||||
@toggle-gizmo="handleToggleGizmo"
|
||||
@set-gizmo-mode="handleSetGizmoMode"
|
||||
@reset-gizmo-transform="handleResetGizmoTransform"
|
||||
@start-recording="handleStartRecording"
|
||||
@stop-recording="handleStopRecording"
|
||||
@export-recording="handleExportRecording"
|
||||
@clear-recording="handleClearRecording"
|
||||
/>
|
||||
<AnimationControls
|
||||
v-if="animations && animations.length > 0"
|
||||
@@ -60,6 +46,59 @@
|
||||
@seek="handleSeek"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="pointer-events-auto absolute top-12 right-2 z-20 flex flex-col gap-2"
|
||||
>
|
||||
<div
|
||||
v-if="canFitToViewer || canCenterCameraOnModel"
|
||||
class="flex flex-col rounded-lg bg-backdrop/30"
|
||||
>
|
||||
<Button
|
||||
v-if="canFitToViewer"
|
||||
v-tooltip.left="{
|
||||
value: $t('load3d.fitToViewer'),
|
||||
showDelay: 300
|
||||
}"
|
||||
size="icon"
|
||||
variant="textonly"
|
||||
class="rounded-full"
|
||||
:aria-label="$t('load3d.fitToViewer')"
|
||||
@click="handleFitToViewer"
|
||||
>
|
||||
<i class="pi pi-window-maximize text-lg text-base-foreground" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="canCenterCameraOnModel"
|
||||
v-tooltip.left="{
|
||||
value: $t('load3d.centerCameraOnModel'),
|
||||
showDelay: 300
|
||||
}"
|
||||
size="icon"
|
||||
variant="textonly"
|
||||
class="rounded-full"
|
||||
:aria-label="$t('load3d.centerCameraOnModel')"
|
||||
@click="handleCenterCameraOnModel"
|
||||
>
|
||||
<i class="pi pi-compass text-lg text-base-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ViewerControls
|
||||
v-if="enable3DViewer && node"
|
||||
:node="node as LGraphNode"
|
||||
/>
|
||||
|
||||
<RecordingControls
|
||||
v-if="canUseRecording && !isPreview"
|
||||
v-model:is-recording="isRecording"
|
||||
v-model:has-recording="hasRecording"
|
||||
v-model:recording-duration="recordingDuration"
|
||||
@start-recording="handleStartRecording"
|
||||
@stop-recording="handleStopRecording"
|
||||
@export-recording="handleExportRecording"
|
||||
@clear-recording="handleClearRecording"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -67,9 +106,12 @@
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import Load3DMenuBar from '@/components/load3d/Load3DMenuBar.vue'
|
||||
import Load3DControls from '@/components/load3d/Load3DControls.vue'
|
||||
import Load3DScene from '@/components/load3d/Load3DScene.vue'
|
||||
import AnimationControls from '@/components/load3d/controls/AnimationControls.vue'
|
||||
import RecordingControls from '@/components/load3d/controls/RecordingControls.vue'
|
||||
import ViewerControls from '@/components/load3d/controls/ViewerControls.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useLoad3d } from '@/composables/useLoad3d'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
@@ -150,11 +192,11 @@ const {
|
||||
handleHDRIFileUpdate,
|
||||
handleExportModel,
|
||||
handleModelDrop,
|
||||
handleFitToViewer,
|
||||
handleCenterCameraOnModel,
|
||||
handleToggleGizmo,
|
||||
handleSetGizmoMode,
|
||||
handleResetGizmoTransform,
|
||||
handleFitToViewer,
|
||||
handleCenterCameraOnModel,
|
||||
cleanup
|
||||
} = useLoad3d(node as Ref<LGraphNode | null>)
|
||||
|
||||
|
||||
@@ -1,242 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import Load3DMenuBar from '@/components/load3d/Load3DMenuBar.vue'
|
||||
import type {
|
||||
CameraConfig,
|
||||
LightConfig,
|
||||
ModelConfig,
|
||||
SceneConfig
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
function makeSceneConfig(): SceneConfig {
|
||||
return {
|
||||
showGrid: true,
|
||||
backgroundColor: '#000000',
|
||||
backgroundImage: '',
|
||||
backgroundRenderMode: 'tiled'
|
||||
}
|
||||
}
|
||||
|
||||
function makeModelConfig(): ModelConfig {
|
||||
return {
|
||||
upDirection: 'original',
|
||||
materialMode: 'original',
|
||||
showSkeleton: false,
|
||||
gizmo: {
|
||||
enabled: false,
|
||||
mode: 'translate',
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
rotation: { x: 0, y: 0, z: 0 },
|
||||
scale: { x: 1, y: 1, z: 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function makeCameraConfig(): CameraConfig {
|
||||
return { cameraType: 'perspective', fov: 75 }
|
||||
}
|
||||
|
||||
function makeLightConfig(): LightConfig {
|
||||
return {
|
||||
intensity: 5,
|
||||
hdri: {
|
||||
enabled: false,
|
||||
hdriPath: '',
|
||||
showAsBackground: false,
|
||||
intensity: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type RenderProps = Partial<ComponentProps<typeof Load3DMenuBar>>
|
||||
|
||||
function renderMenuBar(overrides: RenderProps = {}) {
|
||||
const result = render(Load3DMenuBar, {
|
||||
props: {
|
||||
sceneConfig: makeSceneConfig(),
|
||||
modelConfig: makeModelConfig(),
|
||||
cameraConfig: makeCameraConfig(),
|
||||
lightConfig: makeLightConfig(),
|
||||
...overrides
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
directives: { tooltip: () => {} }
|
||||
}
|
||||
})
|
||||
return { ...result, user: userEvent.setup() }
|
||||
}
|
||||
|
||||
async function selectCategory(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
label: string
|
||||
) {
|
||||
await openCategoryMenu(user)
|
||||
await user.click(screen.getByRole('button', { name: label }))
|
||||
}
|
||||
|
||||
async function openCategoryMenu(user: ReturnType<typeof userEvent.setup>) {
|
||||
await user.click(screen.getByRole('button', { name: /Scene/ }))
|
||||
}
|
||||
|
||||
describe('Load3DMenuBar', () => {
|
||||
it('shows scene controls by default', () => {
|
||||
renderMenuBar()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Show grid' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('toggles showGrid on the bound config when the grid button is clicked', async () => {
|
||||
const sceneConfig = makeSceneConfig()
|
||||
const { user } = renderMenuBar({ sceneConfig })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Show grid' }))
|
||||
|
||||
expect(sceneConfig.showGrid).toBe(false)
|
||||
})
|
||||
|
||||
it('emits fitToViewer when the fit button is clicked', async () => {
|
||||
const onFitToViewer = vi.fn()
|
||||
const { user } = renderMenuBar({ onFitToViewer })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Fit to Viewer' }))
|
||||
|
||||
expect(onFitToViewer).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('emits centerCamera when the center button is clicked', async () => {
|
||||
const onCenterCamera = vi.fn()
|
||||
const { user } = renderMenuBar({ onCenterCamera })
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Center Camera on Model' })
|
||||
)
|
||||
|
||||
expect(onCenterCamera).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('hides the center button when canCenterCameraOnModel is false', () => {
|
||||
renderMenuBar({ canCenterCameraOnModel: false })
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Center Camera on Model' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('toggles the gizmo and reveals the mode controls inline', async () => {
|
||||
const onToggleGizmo = vi.fn()
|
||||
const onSetGizmoMode = vi.fn()
|
||||
const { user } = renderMenuBar({ onToggleGizmo, onSetGizmoMode })
|
||||
|
||||
await selectCategory(user, 'Gizmo')
|
||||
// The chip and the enable toggle share the 'Gizmo' name; click the toggle.
|
||||
const gizmoButtons = screen.getAllByRole('button', { name: 'Gizmo' })
|
||||
await user.click(gizmoButtons[gizmoButtons.length - 1])
|
||||
expect(onToggleGizmo).toHaveBeenCalledWith(true)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Rotate' }))
|
||||
expect(onSetGizmoMode).toHaveBeenCalledWith('rotate')
|
||||
})
|
||||
|
||||
it('shows the hdri upload inline without an extra popover', async () => {
|
||||
const { user } = renderMenuBar()
|
||||
|
||||
await selectCategory(user, 'HDRI')
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Upload' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('forwards removeHdri as updateHdriFile(null) when a file is loaded', async () => {
|
||||
const onUpdateHdriFile = vi.fn()
|
||||
const lightConfig = makeLightConfig()
|
||||
lightConfig.hdri = {
|
||||
enabled: true,
|
||||
hdriPath: 'env.hdr',
|
||||
showAsBackground: false,
|
||||
intensity: 1
|
||||
}
|
||||
const { user } = renderMenuBar({ lightConfig, onUpdateHdriFile })
|
||||
|
||||
await selectCategory(user, 'HDRI')
|
||||
await user.click(screen.getByRole('button', { name: 'Remove' }))
|
||||
|
||||
expect(onUpdateHdriFile).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('emits startRecording when the record button is clicked', async () => {
|
||||
const onStartRecording = vi.fn()
|
||||
const { user } = renderMenuBar({ onStartRecording })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Record' }))
|
||||
|
||||
expect(onStartRecording).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('forwards exportRecording from the recording menu once a recording exists', async () => {
|
||||
const onExportRecording = vi.fn()
|
||||
const { user } = renderMenuBar({
|
||||
hasRecording: true,
|
||||
isRecording: false,
|
||||
onExportRecording
|
||||
})
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Video recording of the scene [mp4]' })
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'Download Recording' }))
|
||||
|
||||
expect(onExportRecording).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('omits the gizmo category when canUseGizmo is false', async () => {
|
||||
const { user } = renderMenuBar({ canUseGizmo: false })
|
||||
await openCategoryMenu(user)
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Gizmo' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('switches to the camera category and shows its controls', async () => {
|
||||
const { user } = renderMenuBar()
|
||||
await openCategoryMenu(user)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Camera' }))
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Perspective' })
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Show grid' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('omits the light category when canUseLighting is false', async () => {
|
||||
const { user } = renderMenuBar({ canUseLighting: false })
|
||||
await openCategoryMenu(user)
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Light' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides scene controls when sceneConfig is undefined', () => {
|
||||
renderMenuBar({ sceneConfig: undefined })
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Show grid' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,337 +0,0 @@
|
||||
<template>
|
||||
<div class="pointer-events-none absolute inset-0 flex flex-col">
|
||||
<div
|
||||
ref="topBarRef"
|
||||
class="pointer-events-auto flex h-10 items-center gap-1 bg-interface-menu-surface px-2"
|
||||
@wheel.stop
|
||||
>
|
||||
<Popover v-model:open="catMenuOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<button
|
||||
:class="chipClass"
|
||||
type="button"
|
||||
data-testid="load3d-category-menu"
|
||||
>
|
||||
{{ activeLabel }}
|
||||
<i class="icon-[lucide--chevron-down] size-4 opacity-70" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
:side-offset="8"
|
||||
:class="panelClass"
|
||||
>
|
||||
<button
|
||||
v-for="c in categoryDefs"
|
||||
:key="c.key"
|
||||
type="button"
|
||||
:class="
|
||||
cn(
|
||||
rowClass,
|
||||
activeCategory === c.key && 'bg-button-active-surface'
|
||||
)
|
||||
"
|
||||
@click="selectCategory(c.key)"
|
||||
>
|
||||
{{ c.label }}
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<div class="mx-1 h-5 w-px shrink-0 bg-interface-menu-stroke" />
|
||||
|
||||
<SceneMenuGroup
|
||||
v-if="activeCategory === 'scene' && sceneConfig"
|
||||
v-model:config="sceneConfig"
|
||||
v-model:fov="cameraFov"
|
||||
:compact
|
||||
:can-use-background-image="canUseBackgroundImage"
|
||||
:hdri-active="hdriActive"
|
||||
@update-background-image="emit('updateBackgroundImage', $event)"
|
||||
/>
|
||||
<ModelMenuGroup
|
||||
v-else-if="activeCategory === 'model' && modelConfig"
|
||||
v-model:config="modelConfig"
|
||||
:compact
|
||||
:material-modes="materialModes"
|
||||
:has-skeleton="hasSkeleton"
|
||||
/>
|
||||
<CameraMenuGroup
|
||||
v-else-if="activeCategory === 'camera' && cameraConfig"
|
||||
v-model:config="cameraConfig"
|
||||
:compact
|
||||
/>
|
||||
<LightMenuGroup
|
||||
v-else-if="activeCategory === 'light' && lightConfig && modelConfig"
|
||||
v-model:config="lightConfig"
|
||||
:compact
|
||||
:is-original-material="isOriginalMaterial"
|
||||
/>
|
||||
<HdriMenuGroup
|
||||
v-else-if="activeCategory === 'hdri' && lightConfig"
|
||||
v-model:config="lightConfig"
|
||||
:compact
|
||||
:scene-has-image="sceneHasImage"
|
||||
@update-hdri-file="emit('updateHdriFile', $event)"
|
||||
/>
|
||||
<GizmoMenuGroup
|
||||
v-else-if="activeCategory === 'gizmo' && modelConfig"
|
||||
v-model:config="modelConfig"
|
||||
:compact
|
||||
@toggle-gizmo="emit('toggleGizmo', $event)"
|
||||
@set-gizmo-mode="emit('setGizmoMode', $event)"
|
||||
@reset-gizmo-transform="emit('resetGizmoTransform')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
:class="
|
||||
cn('flex-1', isRecording && 'border-2 border-node-component-executing')
|
||||
"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="pointer-events-auto flex h-10 items-center justify-between gap-1 bg-interface-menu-surface px-2"
|
||||
@wheel.stop
|
||||
>
|
||||
<div class="flex items-center gap-1">
|
||||
<RecordMenuControl
|
||||
v-if="canUseRecording"
|
||||
v-model:is-recording="isRecording"
|
||||
v-model:has-recording="hasRecording"
|
||||
v-model:recording-duration="recordingDuration"
|
||||
:compact
|
||||
@start-recording="emit('startRecording')"
|
||||
@stop-recording="emit('stopRecording')"
|
||||
@export-recording="emit('exportRecording')"
|
||||
@clear-recording="emit('clearRecording')"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<ViewerControls
|
||||
v-if="enableViewer && node"
|
||||
:node="node as LGraphNode"
|
||||
/>
|
||||
<button
|
||||
v-if="canFitToViewer"
|
||||
v-tooltip.top="tip(t('load3d.fitToViewer'))"
|
||||
:class="iconBtnClass"
|
||||
type="button"
|
||||
:aria-label="t('load3d.fitToViewer')"
|
||||
@click="emit('fitToViewer')"
|
||||
>
|
||||
<i class="icon-[lucide--scan] size-4" />
|
||||
</button>
|
||||
<button
|
||||
v-if="canCenterCameraOnModel"
|
||||
v-tooltip.top="tip(t('load3d.centerCameraOnModel'))"
|
||||
:class="iconBtnClass"
|
||||
type="button"
|
||||
:aria-label="t('load3d.centerCameraOnModel')"
|
||||
@click="emit('centerCamera')"
|
||||
>
|
||||
<i class="icon-[lucide--crosshair] size-4" />
|
||||
</button>
|
||||
<Popover v-if="canExport" v-model:open="exportOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<button
|
||||
v-tooltip.top="tip(t('load3d.export'))"
|
||||
:class="iconBtnClass"
|
||||
type="button"
|
||||
:aria-label="t('load3d.export')"
|
||||
>
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="top"
|
||||
align="end"
|
||||
:side-offset="8"
|
||||
:class="panelClass"
|
||||
>
|
||||
<button
|
||||
v-for="format in exportFormats"
|
||||
:key="format.value"
|
||||
type="button"
|
||||
:class="rowClass"
|
||||
@click="onExport(format.value)"
|
||||
>
|
||||
{{ format.label }}
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useElementSize } from '@vueuse/core'
|
||||
import { PopoverTrigger } from 'reka-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import CameraMenuGroup from '@/components/load3d/menubar/CameraMenuGroup.vue'
|
||||
import GizmoMenuGroup from '@/components/load3d/menubar/GizmoMenuGroup.vue'
|
||||
import HdriMenuGroup from '@/components/load3d/menubar/HdriMenuGroup.vue'
|
||||
import LightMenuGroup from '@/components/load3d/menubar/LightMenuGroup.vue'
|
||||
import {
|
||||
chipClass,
|
||||
iconBtnClass,
|
||||
panelClass,
|
||||
rowClass,
|
||||
tip
|
||||
} from '@/components/load3d/menubar/menuBarStyles'
|
||||
import ModelMenuGroup from '@/components/load3d/menubar/ModelMenuGroup.vue'
|
||||
import RecordMenuControl from '@/components/load3d/menubar/RecordMenuControl.vue'
|
||||
import SceneMenuGroup from '@/components/load3d/menubar/SceneMenuGroup.vue'
|
||||
import ViewerControls from '@/components/load3d/controls/ViewerControls.vue'
|
||||
import Popover from '@/components/ui/popover/Popover.vue'
|
||||
import PopoverContent from '@/components/ui/popover/PopoverContent.vue'
|
||||
import { getExportFormatOptions } from '@/extensions/core/load3d/constants'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type {
|
||||
CameraConfig,
|
||||
GizmoMode,
|
||||
LightConfig,
|
||||
MaterialMode,
|
||||
ModelConfig,
|
||||
SceneConfig
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const {
|
||||
canUseLighting = true,
|
||||
canUseHdri = true,
|
||||
canUseGizmo = true,
|
||||
canExport = true,
|
||||
canUseBackgroundImage = true,
|
||||
canFitToViewer = true,
|
||||
canCenterCameraOnModel = true,
|
||||
canUseRecording = true,
|
||||
enableViewer = false,
|
||||
node = null,
|
||||
materialModes = ['original', 'clay', 'normal', 'wireframe'],
|
||||
hasSkeleton = false,
|
||||
sourceFormat = null
|
||||
} = defineProps<{
|
||||
canUseLighting?: boolean
|
||||
canUseHdri?: boolean
|
||||
canUseGizmo?: boolean
|
||||
canExport?: boolean
|
||||
canUseBackgroundImage?: boolean
|
||||
canFitToViewer?: boolean
|
||||
canCenterCameraOnModel?: boolean
|
||||
canUseRecording?: boolean
|
||||
enableViewer?: boolean
|
||||
node?: LGraphNode | null
|
||||
materialModes?: readonly MaterialMode[]
|
||||
hasSkeleton?: boolean
|
||||
sourceFormat?: string | null
|
||||
}>()
|
||||
|
||||
const sceneConfig = defineModel<SceneConfig>('sceneConfig')
|
||||
const modelConfig = defineModel<ModelConfig>('modelConfig')
|
||||
const cameraConfig = defineModel<CameraConfig>('cameraConfig')
|
||||
const lightConfig = defineModel<LightConfig>('lightConfig')
|
||||
const isRecording = defineModel<boolean>('isRecording')
|
||||
const hasRecording = defineModel<boolean>('hasRecording')
|
||||
const recordingDuration = defineModel<number>('recordingDuration')
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'updateBackgroundImage', file: File | null): void
|
||||
(e: 'updateHdriFile', file: File | null): void
|
||||
(e: 'exportModel', format: string): void
|
||||
(e: 'fitToViewer'): void
|
||||
(e: 'centerCamera'): void
|
||||
(e: 'toggleGizmo', enabled: boolean): void
|
||||
(e: 'setGizmoMode', mode: GizmoMode): void
|
||||
(e: 'resetGizmoTransform'): void
|
||||
(e: 'startRecording'): void
|
||||
(e: 'stopRecording'): void
|
||||
(e: 'exportRecording'): void
|
||||
(e: 'clearRecording'): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const categoryDefs = computed(() =>
|
||||
[
|
||||
{ key: 'scene', label: t('load3d.scene'), show: !!sceneConfig.value },
|
||||
{
|
||||
key: 'model',
|
||||
label: t('load3d.model3d'),
|
||||
show: !!modelConfig.value
|
||||
},
|
||||
{ key: 'camera', label: t('load3d.camera'), show: !!cameraConfig.value },
|
||||
{
|
||||
key: 'light',
|
||||
label: t('load3d.light'),
|
||||
show: canUseLighting && !!lightConfig.value && !!modelConfig.value
|
||||
},
|
||||
{
|
||||
key: 'hdri',
|
||||
label: t('load3d.hdri.label'),
|
||||
show: canUseHdri && !!lightConfig.value
|
||||
},
|
||||
{
|
||||
key: 'gizmo',
|
||||
label: t('load3d.gizmo.label'),
|
||||
show: canUseGizmo && !!modelConfig.value
|
||||
}
|
||||
].filter((c) => c.show)
|
||||
)
|
||||
|
||||
const activeCategory = ref('scene')
|
||||
const activeLabel = computed(
|
||||
() =>
|
||||
categoryDefs.value.find((c) => c.key === activeCategory.value)?.label ?? ''
|
||||
)
|
||||
watch(categoryDefs, (defs) => {
|
||||
if (!defs.some((c) => c.key === activeCategory.value)) {
|
||||
activeCategory.value = defs[0]?.key ?? 'scene'
|
||||
}
|
||||
})
|
||||
|
||||
const catMenuOpen = ref(false)
|
||||
const exportOpen = ref(false)
|
||||
|
||||
const sceneHasImage = computed(
|
||||
() =>
|
||||
!!sceneConfig.value?.backgroundImage &&
|
||||
sceneConfig.value.backgroundImage !== ''
|
||||
)
|
||||
const hdriActive = computed(
|
||||
() =>
|
||||
!!lightConfig.value?.hdri?.hdriPath && !!lightConfig.value?.hdri?.enabled
|
||||
)
|
||||
const isOriginalMaterial = computed(
|
||||
() => modelConfig.value?.materialMode === 'original'
|
||||
)
|
||||
const cameraFov = computed({
|
||||
get: () => cameraConfig.value?.fov ?? 0,
|
||||
set: (value) => {
|
||||
if (cameraConfig.value) cameraConfig.value.fov = value
|
||||
}
|
||||
})
|
||||
|
||||
const exportFormats = computed(() => getExportFormatOptions(sourceFormat))
|
||||
|
||||
const topBarRef = ref<HTMLElement | null>(null)
|
||||
const { width: topW } = useElementSize(topBarRef)
|
||||
const compactWidthThreshold = 480
|
||||
const compact = computed(
|
||||
() => topW.value > 0 && topW.value < compactWidthThreshold
|
||||
)
|
||||
|
||||
function selectCategory(key: string) {
|
||||
activeCategory.value = key
|
||||
catMenuOpen.value = false
|
||||
}
|
||||
|
||||
function onExport(format: string) {
|
||||
emit('exportModel', format)
|
||||
exportOpen.value = false
|
||||
}
|
||||
</script>
|
||||
205
src/components/load3d/controls/RecordingControls.test.ts
Normal file
205
src/components/load3d/controls/RecordingControls.test.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import RecordingControls from '@/components/load3d/controls/RecordingControls.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
load3d: {
|
||||
startRecording: 'Start recording',
|
||||
stopRecording: 'Stop recording',
|
||||
exportRecording: 'Export recording',
|
||||
clearRecording: 'Clear recording'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
type RenderOpts = {
|
||||
hasRecording?: boolean
|
||||
isRecording?: boolean
|
||||
recordingDuration?: number
|
||||
onStartRecording?: () => void
|
||||
onStopRecording?: () => void
|
||||
onExportRecording?: () => void
|
||||
onClearRecording?: () => void
|
||||
}
|
||||
|
||||
function renderComponent(opts: RenderOpts = {}) {
|
||||
const hasRecording = ref<boolean>(opts.hasRecording ?? false)
|
||||
const isRecording = ref<boolean>(opts.isRecording ?? false)
|
||||
const recordingDuration = ref<number>(opts.recordingDuration ?? 0)
|
||||
|
||||
const utils = render(RecordingControls, {
|
||||
props: {
|
||||
hasRecording: hasRecording.value,
|
||||
'onUpdate:hasRecording': (v: boolean | undefined) => {
|
||||
if (v !== undefined) hasRecording.value = v
|
||||
},
|
||||
isRecording: isRecording.value,
|
||||
'onUpdate:isRecording': (v: boolean | undefined) => {
|
||||
if (v !== undefined) isRecording.value = v
|
||||
},
|
||||
recordingDuration: recordingDuration.value,
|
||||
'onUpdate:recordingDuration': (v: number | undefined) => {
|
||||
if (v !== undefined) recordingDuration.value = v
|
||||
},
|
||||
onStartRecording: opts.onStartRecording,
|
||||
onStopRecording: opts.onStopRecording,
|
||||
onExportRecording: opts.onExportRecording,
|
||||
onClearRecording: opts.onClearRecording
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
directives: { tooltip: () => {} }
|
||||
}
|
||||
})
|
||||
|
||||
return { ...utils, user: userEvent.setup() }
|
||||
}
|
||||
|
||||
describe('RecordingControls', () => {
|
||||
it('shows the start-recording button initially', () => {
|
||||
renderComponent()
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Start recording' })
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Stop recording' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the stop-recording button while recording is in progress', () => {
|
||||
renderComponent({ isRecording: true })
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Stop recording' })
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Start recording' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('emits startRecording when the button is clicked from a stopped state', async () => {
|
||||
const onStartRecording = vi.fn()
|
||||
const onStopRecording = vi.fn()
|
||||
const { user } = renderComponent({
|
||||
isRecording: false,
|
||||
onStartRecording,
|
||||
onStopRecording
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Start recording' }))
|
||||
|
||||
expect(onStartRecording).toHaveBeenCalledOnce()
|
||||
expect(onStopRecording).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('emits stopRecording when the button is clicked from a recording state', async () => {
|
||||
const onStartRecording = vi.fn()
|
||||
const onStopRecording = vi.fn()
|
||||
const { user } = renderComponent({
|
||||
isRecording: true,
|
||||
onStartRecording,
|
||||
onStopRecording
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Stop recording' }))
|
||||
|
||||
expect(onStopRecording).toHaveBeenCalledOnce()
|
||||
expect(onStartRecording).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hides the export and clear buttons when there is no recording', () => {
|
||||
renderComponent({ hasRecording: false, isRecording: false })
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Export recording' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Clear recording' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the export and clear buttons once a recording exists', () => {
|
||||
renderComponent({ hasRecording: true, isRecording: false })
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Export recording' })
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Clear recording' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the export and clear buttons during a new recording even if a previous one exists', () => {
|
||||
renderComponent({ hasRecording: true, isRecording: true })
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Export recording' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Clear recording' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('emits exportRecording and clearRecording from their respective buttons', async () => {
|
||||
const onExportRecording = vi.fn()
|
||||
const onClearRecording = vi.fn()
|
||||
const { user } = renderComponent({
|
||||
hasRecording: true,
|
||||
isRecording: false,
|
||||
onExportRecording,
|
||||
onClearRecording
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Export recording' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Clear recording' }))
|
||||
|
||||
expect(onExportRecording).toHaveBeenCalledOnce()
|
||||
expect(onClearRecording).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('renders the formatted duration as MM:SS once a recording exists', () => {
|
||||
renderComponent({
|
||||
hasRecording: true,
|
||||
isRecording: false,
|
||||
recordingDuration: 75
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('load3d-recording-duration')).toHaveTextContent(
|
||||
'01:15'
|
||||
)
|
||||
})
|
||||
|
||||
it('hides the duration display while a recording is in progress', () => {
|
||||
renderComponent({
|
||||
hasRecording: true,
|
||||
isRecording: true,
|
||||
recordingDuration: 30
|
||||
})
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('load3d-recording-duration')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the duration display when recordingDuration is zero', () => {
|
||||
renderComponent({
|
||||
hasRecording: true,
|
||||
isRecording: false,
|
||||
recordingDuration: 0
|
||||
})
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('load3d-recording-duration')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
126
src/components/load3d/controls/RecordingControls.vue
Normal file
126
src/components/load3d/controls/RecordingControls.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<div class="relative rounded-lg bg-backdrop/30">
|
||||
<div class="flex flex-col gap-2">
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: isRecording
|
||||
? $t('load3d.stopRecording')
|
||||
: $t('load3d.startRecording'),
|
||||
showDelay: 300
|
||||
}"
|
||||
size="icon"
|
||||
variant="textonly"
|
||||
:class="
|
||||
cn(
|
||||
'rounded-full',
|
||||
isRecording && 'recording-button-blink text-red-500'
|
||||
)
|
||||
"
|
||||
:aria-label="
|
||||
isRecording ? $t('load3d.stopRecording') : $t('load3d.startRecording')
|
||||
"
|
||||
@click="toggleRecording"
|
||||
>
|
||||
<i
|
||||
:class="[
|
||||
'pi',
|
||||
isRecording ? 'pi-circle-fill' : 'pi-video',
|
||||
'text-lg text-base-foreground'
|
||||
]"
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-if="hasRecording && !isRecording"
|
||||
v-tooltip.right="{
|
||||
value: $t('load3d.exportRecording'),
|
||||
showDelay: 300
|
||||
}"
|
||||
size="icon"
|
||||
variant="textonly"
|
||||
class="rounded-full"
|
||||
:aria-label="$t('load3d.exportRecording')"
|
||||
@click="handleExportRecording"
|
||||
>
|
||||
<i class="pi pi-download text-lg text-base-foreground" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-if="hasRecording && !isRecording"
|
||||
v-tooltip.right="{
|
||||
value: $t('load3d.clearRecording'),
|
||||
showDelay: 300
|
||||
}"
|
||||
size="icon"
|
||||
variant="textonly"
|
||||
class="rounded-full"
|
||||
:aria-label="$t('load3d.clearRecording')"
|
||||
@click="handleClearRecording"
|
||||
>
|
||||
<i class="pi pi-trash text-lg text-base-foreground" />
|
||||
</Button>
|
||||
|
||||
<div
|
||||
v-if="recordingDuration && recordingDuration > 0 && !isRecording"
|
||||
class="mt-1 text-center text-xs text-base-foreground"
|
||||
data-testid="load3d-recording-duration"
|
||||
>
|
||||
{{ formatDuration(recordingDuration) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const hasRecording = defineModel<boolean>('hasRecording')
|
||||
const isRecording = defineModel<boolean>('isRecording')
|
||||
const recordingDuration = defineModel<number>('recordingDuration')
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'startRecording'): void
|
||||
(e: 'stopRecording'): void
|
||||
(e: 'exportRecording'): void
|
||||
(e: 'clearRecording'): void
|
||||
}>()
|
||||
|
||||
function toggleRecording() {
|
||||
if (isRecording.value) {
|
||||
emit('stopRecording')
|
||||
} else {
|
||||
emit('startRecording')
|
||||
}
|
||||
}
|
||||
|
||||
function handleExportRecording() {
|
||||
emit('exportRecording')
|
||||
}
|
||||
|
||||
function handleClearRecording() {
|
||||
emit('clearRecording')
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const remainingSeconds = Math.floor(seconds % 60)
|
||||
return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.recording-button-blink {
|
||||
animation: blink 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,47 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import CameraMenuGroup from '@/components/load3d/menubar/CameraMenuGroup.vue'
|
||||
import type { CameraConfig } from '@/extensions/core/load3d/interfaces'
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
function makeConfig(overrides: Partial<CameraConfig> = {}): CameraConfig {
|
||||
return { cameraType: 'perspective', fov: 75, ...overrides }
|
||||
}
|
||||
|
||||
function renderGroup(config = makeConfig()) {
|
||||
const result = render(CameraMenuGroup, {
|
||||
props: { config },
|
||||
global: { plugins: [i18n], directives: { tooltip: () => {} } }
|
||||
})
|
||||
return { ...result, user: userEvent.setup(), config }
|
||||
}
|
||||
|
||||
describe('CameraMenuGroup', () => {
|
||||
it('switches the projection type', async () => {
|
||||
const { user, config } = renderGroup()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Perspective' }))
|
||||
|
||||
expect(config.cameraType).toBe('orthographic')
|
||||
})
|
||||
|
||||
it('offers the FOV control only for a perspective camera', () => {
|
||||
renderGroup(makeConfig({ cameraType: 'orthographic' }))
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'FOV' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Orthographic' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,86 +0,0 @@
|
||||
<template>
|
||||
<button
|
||||
v-tooltip.bottom="tip(t('load3d.menuBar.switchProjection'))"
|
||||
:class="actionClass(false)"
|
||||
type="button"
|
||||
:aria-label="compact ? t('load3d.menuBar.switchProjection') : undefined"
|
||||
@click="switchCamera"
|
||||
>
|
||||
<i class="icon-[lucide--camera] size-4" />
|
||||
<span v-if="!compact">{{ cameraTypeLabel }}</span>
|
||||
</button>
|
||||
|
||||
<Popover v-if="isPerspective">
|
||||
<PopoverTrigger as-child>
|
||||
<button
|
||||
v-tooltip.bottom="tip(t('load3d.menuBar.fov'))"
|
||||
:class="actionClass(false)"
|
||||
type="button"
|
||||
:aria-label="compact ? t('load3d.menuBar.fov') : undefined"
|
||||
>
|
||||
<i class="icon-[lucide--focus] size-4" />
|
||||
<span v-if="!compact">{{ t('load3d.menuBar.fov') }}</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
:side-offset="8"
|
||||
:class="cn(panelClass, 'w-56')"
|
||||
>
|
||||
<div class="flex flex-col gap-2 p-1">
|
||||
<span class="text-sm text-base-foreground">{{ t('load3d.fov') }}</span>
|
||||
<Slider
|
||||
:model-value="[fov]"
|
||||
:min="10"
|
||||
:max="150"
|
||||
:step="1"
|
||||
class="w-full"
|
||||
@update:model-value="setFov"
|
||||
/>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import {
|
||||
actionClass,
|
||||
panelClass,
|
||||
tip
|
||||
} from '@/components/load3d/menubar/menuBarStyles'
|
||||
import Popover from '@/components/ui/popover/Popover.vue'
|
||||
import PopoverContent from '@/components/ui/popover/PopoverContent.vue'
|
||||
import Slider from '@/components/ui/slider/Slider.vue'
|
||||
import type { CameraConfig } from '@/extensions/core/load3d/interfaces'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { PopoverTrigger } from 'reka-ui'
|
||||
|
||||
const { compact = false } = defineProps<{
|
||||
compact?: boolean
|
||||
}>()
|
||||
|
||||
const config = defineModel<CameraConfig>('config')
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const cameraType = computed(() => config.value?.cameraType)
|
||||
const isPerspective = computed(() => cameraType.value === 'perspective')
|
||||
const cameraTypeLabel = computed(() =>
|
||||
cameraType.value ? t(`load3d.cameraType.${cameraType.value}`) : ''
|
||||
)
|
||||
const fov = computed(() => config.value?.fov ?? 0)
|
||||
|
||||
function switchCamera() {
|
||||
if (!config.value) return
|
||||
config.value.cameraType =
|
||||
config.value.cameraType === 'perspective' ? 'orthographic' : 'perspective'
|
||||
}
|
||||
|
||||
function setFov(value?: number[]) {
|
||||
if (config.value && value?.length) config.value.fov = value[0]
|
||||
}
|
||||
</script>
|
||||
@@ -1,72 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import GizmoMenuGroup from '@/components/load3d/menubar/GizmoMenuGroup.vue'
|
||||
import type { ModelConfig } from '@/extensions/core/load3d/interfaces'
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
function makeConfig(enabled: boolean): ModelConfig {
|
||||
return {
|
||||
upDirection: 'original',
|
||||
materialMode: 'original',
|
||||
showSkeleton: false,
|
||||
gizmo: {
|
||||
enabled,
|
||||
mode: 'translate',
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
rotation: { x: 0, y: 0, z: 0 },
|
||||
scale: { x: 1, y: 1, z: 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type Props = {
|
||||
config: ModelConfig
|
||||
onToggleGizmo?: (enabled: boolean) => void
|
||||
onSetGizmoMode?: (mode: string) => void
|
||||
}
|
||||
|
||||
function renderGroup(props: Props) {
|
||||
const result = render(GizmoMenuGroup, {
|
||||
props,
|
||||
global: { plugins: [i18n], directives: { tooltip: () => {} } }
|
||||
})
|
||||
return { ...result, user: userEvent.setup() }
|
||||
}
|
||||
|
||||
describe('GizmoMenuGroup', () => {
|
||||
it('enables the gizmo and reveals the mode controls', async () => {
|
||||
const config = makeConfig(false)
|
||||
const onToggleGizmo = vi.fn()
|
||||
const { user } = renderGroup({ config, onToggleGizmo })
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Rotate' })
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Gizmo' }))
|
||||
|
||||
expect(onToggleGizmo).toHaveBeenCalledWith(true)
|
||||
expect(config.gizmo?.enabled).toBe(true)
|
||||
expect(screen.getByRole('button', { name: 'Rotate' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('sets the transform mode', async () => {
|
||||
const config = makeConfig(true)
|
||||
const onSetGizmoMode = vi.fn()
|
||||
const { user } = renderGroup({ config, onSetGizmoMode })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Rotate' }))
|
||||
|
||||
expect(onSetGizmoMode).toHaveBeenCalledWith('rotate')
|
||||
expect(config.gizmo?.mode).toBe('rotate')
|
||||
})
|
||||
})
|
||||
@@ -1,105 +0,0 @@
|
||||
<template>
|
||||
<button
|
||||
v-tooltip.bottom="tip(t('load3d.gizmo.toggle'))"
|
||||
:class="actionClass(gizmoEnabled)"
|
||||
:aria-pressed="gizmoEnabled"
|
||||
type="button"
|
||||
:aria-label="compact ? t('load3d.gizmo.toggle') : undefined"
|
||||
@click="toggleGizmo"
|
||||
>
|
||||
<i class="icon-[lucide--axis-3d] size-4" />
|
||||
<span v-if="!compact">{{ t('load3d.gizmo.toggle') }}</span>
|
||||
</button>
|
||||
|
||||
<template v-if="gizmoEnabled">
|
||||
<button
|
||||
v-tooltip.bottom="tip(t('load3d.gizmo.translate'))"
|
||||
:class="actionClass(gizmoMode === 'translate')"
|
||||
:aria-pressed="gizmoMode === 'translate'"
|
||||
type="button"
|
||||
:aria-label="compact ? t('load3d.gizmo.translate') : undefined"
|
||||
@click="setGizmoMode('translate')"
|
||||
>
|
||||
<i class="icon-[lucide--move] size-4" />
|
||||
<span v-if="!compact">{{ t('load3d.gizmo.translate') }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-tooltip.bottom="tip(t('load3d.gizmo.rotate'))"
|
||||
:class="actionClass(gizmoMode === 'rotate')"
|
||||
:aria-pressed="gizmoMode === 'rotate'"
|
||||
type="button"
|
||||
:aria-label="compact ? t('load3d.gizmo.rotate') : undefined"
|
||||
@click="setGizmoMode('rotate')"
|
||||
>
|
||||
<i class="icon-[lucide--rotate-3d] size-4" />
|
||||
<span v-if="!compact">{{ t('load3d.gizmo.rotate') }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-tooltip.bottom="tip(t('load3d.gizmo.scale'))"
|
||||
:class="actionClass(gizmoMode === 'scale')"
|
||||
:aria-pressed="gizmoMode === 'scale'"
|
||||
type="button"
|
||||
:aria-label="compact ? t('load3d.gizmo.scale') : undefined"
|
||||
@click="setGizmoMode('scale')"
|
||||
>
|
||||
<i class="icon-[lucide--scale-3d] size-4" />
|
||||
<span v-if="!compact">{{ t('load3d.gizmo.scale') }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-tooltip.bottom="tip(t('load3d.gizmo.reset'))"
|
||||
:class="actionClass(false)"
|
||||
type="button"
|
||||
:aria-label="compact ? t('load3d.gizmo.reset') : undefined"
|
||||
@click="resetGizmoTransform"
|
||||
>
|
||||
<i class="icon-[lucide--rotate-ccw] size-4" />
|
||||
<span v-if="!compact">{{ t('load3d.gizmo.reset') }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { actionClass, tip } from '@/components/load3d/menubar/menuBarStyles'
|
||||
import type {
|
||||
GizmoMode,
|
||||
ModelConfig
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
|
||||
const { compact = false } = defineProps<{
|
||||
compact?: boolean
|
||||
}>()
|
||||
|
||||
const config = defineModel<ModelConfig>('config')
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'toggleGizmo', enabled: boolean): void
|
||||
(e: 'setGizmoMode', mode: GizmoMode): void
|
||||
(e: 'resetGizmoTransform'): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const gizmoEnabled = computed(() => config.value?.gizmo?.enabled ?? false)
|
||||
const gizmoMode = computed(() => config.value?.gizmo?.mode ?? 'translate')
|
||||
|
||||
function toggleGizmo() {
|
||||
const gizmo = config.value?.gizmo
|
||||
if (!gizmo) return
|
||||
gizmo.enabled = !gizmo.enabled
|
||||
emit('toggleGizmo', gizmo.enabled)
|
||||
}
|
||||
|
||||
function setGizmoMode(mode: GizmoMode) {
|
||||
const gizmo = config.value?.gizmo
|
||||
if (!gizmo) return
|
||||
gizmo.mode = mode
|
||||
emit('setGizmoMode', mode)
|
||||
}
|
||||
|
||||
function resetGizmoTransform() {
|
||||
emit('resetGizmoTransform')
|
||||
}
|
||||
</script>
|
||||
@@ -1,74 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import HdriMenuGroup from '@/components/load3d/menubar/HdriMenuGroup.vue'
|
||||
import type {
|
||||
HDRIConfig,
|
||||
LightConfig
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
function makeConfig(hdri?: Partial<HDRIConfig>): LightConfig {
|
||||
return {
|
||||
intensity: 5,
|
||||
hdri: hdri
|
||||
? {
|
||||
enabled: false,
|
||||
hdriPath: '',
|
||||
showAsBackground: false,
|
||||
intensity: 1,
|
||||
...hdri
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
|
||||
type Props = {
|
||||
config?: LightConfig
|
||||
sceneHasImage?: boolean
|
||||
onUpdateHdriFile?: (file: File | null) => void
|
||||
}
|
||||
|
||||
function renderGroup(props: Props = {}) {
|
||||
const result = render(HdriMenuGroup, {
|
||||
props: { config: makeConfig({}), ...props },
|
||||
global: { plugins: [i18n], directives: { tooltip: () => {} } }
|
||||
})
|
||||
return { ...result, user: userEvent.setup() }
|
||||
}
|
||||
|
||||
describe('HdriMenuGroup', () => {
|
||||
it('shows the upload button when no HDRI is loaded', () => {
|
||||
renderGroup()
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Upload' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the upload when a background image is set and no HDRI exists', () => {
|
||||
renderGroup({ config: makeConfig({ hdriPath: '' }), sceneHasImage: true })
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Upload' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('toggles enabled and forwards removal once a file is loaded', async () => {
|
||||
const onUpdateHdriFile = vi.fn()
|
||||
const config = makeConfig({ hdriPath: 'env.hdr', enabled: false })
|
||||
const { user } = renderGroup({ config, onUpdateHdriFile })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'HDRI' }))
|
||||
expect(config.hdri?.enabled).toBe(true)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Remove' }))
|
||||
expect(onUpdateHdriFile).toHaveBeenCalledWith(null)
|
||||
})
|
||||
})
|
||||
@@ -1,130 +0,0 @@
|
||||
<template>
|
||||
<template v-if="!sceneHasImage || hdriPath">
|
||||
<button
|
||||
v-tooltip.bottom="
|
||||
tip(
|
||||
hdriPath ? t('load3d.hdri.changeFile') : t('load3d.hdri.uploadFile')
|
||||
)
|
||||
"
|
||||
:class="actionClass(false)"
|
||||
type="button"
|
||||
:aria-label="
|
||||
compact
|
||||
? hdriPath
|
||||
? t('load3d.hdri.changeFile')
|
||||
: t('load3d.hdri.uploadFile')
|
||||
: undefined
|
||||
"
|
||||
@click="hdriFileRef?.click()"
|
||||
>
|
||||
<i class="icon-[lucide--upload] size-4" />
|
||||
<span v-if="!compact">{{
|
||||
hdriPath ? t('load3d.hdri.changeFile') : t('load3d.hdri.uploadFile')
|
||||
}}</span>
|
||||
</button>
|
||||
<input
|
||||
ref="hdriFileRef"
|
||||
type="file"
|
||||
:accept="SUPPORTED_HDRI_EXTENSIONS_ACCEPT"
|
||||
class="pointer-events-none absolute size-0 opacity-0"
|
||||
@change="onHdriFilePicked"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-if="hdriPath">
|
||||
<button
|
||||
v-tooltip.bottom="tip(t('load3d.hdri.label'))"
|
||||
:class="actionClass(hdriEnabled)"
|
||||
:aria-pressed="hdriEnabled"
|
||||
type="button"
|
||||
:aria-label="compact ? t('load3d.hdri.label') : undefined"
|
||||
@click="toggleHdriEnabled"
|
||||
>
|
||||
<i class="icon-[lucide--globe] size-4" />
|
||||
<span v-if="!compact">{{ t('load3d.hdri.label') }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-tooltip.bottom="tip(t('load3d.hdri.showAsBackground'))"
|
||||
:class="actionClass(hdriShowAsBackground)"
|
||||
:aria-pressed="hdriShowAsBackground"
|
||||
type="button"
|
||||
:aria-label="compact ? t('load3d.hdri.showAsBackground') : undefined"
|
||||
@click="toggleHdriShowAsBackground"
|
||||
>
|
||||
<i class="icon-[lucide--image] size-4" />
|
||||
<span v-if="!compact">{{ t('load3d.hdri.showAsBackground') }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-tooltip.bottom="tip(t('load3d.hdri.removeFile'))"
|
||||
:class="actionClass(false)"
|
||||
type="button"
|
||||
:aria-label="compact ? t('load3d.hdri.removeFile') : undefined"
|
||||
@click="removeHdri"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
<span v-if="!compact">{{ t('load3d.hdri.removeFile') }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { actionClass, tip } from '@/components/load3d/menubar/menuBarStyles'
|
||||
import {
|
||||
SUPPORTED_HDRI_EXTENSIONS,
|
||||
SUPPORTED_HDRI_EXTENSIONS_ACCEPT
|
||||
} from '@/extensions/core/load3d/constants'
|
||||
import type { LightConfig } from '@/extensions/core/load3d/interfaces'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
|
||||
const { compact = false, sceneHasImage = false } = defineProps<{
|
||||
compact?: boolean
|
||||
sceneHasImage?: boolean
|
||||
}>()
|
||||
|
||||
const config = defineModel<LightConfig>('config')
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'updateHdriFile', file: File | null): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const hdriPath = computed(() => config.value?.hdri?.hdriPath ?? '')
|
||||
const hdriEnabled = computed(() => config.value?.hdri?.enabled ?? false)
|
||||
const hdriShowAsBackground = computed(
|
||||
() => config.value?.hdri?.showAsBackground ?? false
|
||||
)
|
||||
|
||||
const hdriFileRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
function onHdriFilePicked(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0] ?? null
|
||||
input.value = ''
|
||||
if (file) {
|
||||
const ext = `.${file.name.split('.').pop()?.toLowerCase() ?? ''}`
|
||||
if (!SUPPORTED_HDRI_EXTENSIONS.has(ext)) {
|
||||
useToastStore().addAlert(t('toastMessages.unsupportedHDRIFormat'))
|
||||
return
|
||||
}
|
||||
}
|
||||
emit('updateHdriFile', file)
|
||||
}
|
||||
|
||||
function toggleHdriEnabled() {
|
||||
const hdri = config.value?.hdri
|
||||
if (hdri) hdri.enabled = !hdri.enabled
|
||||
}
|
||||
|
||||
function toggleHdriShowAsBackground() {
|
||||
const hdri = config.value?.hdri
|
||||
if (hdri) hdri.showAsBackground = !hdri.showAsBackground
|
||||
}
|
||||
|
||||
function removeHdri() {
|
||||
emit('updateHdriFile', null)
|
||||
}
|
||||
</script>
|
||||
@@ -1,74 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import LightMenuGroup from '@/components/load3d/menubar/LightMenuGroup.vue'
|
||||
import type { LightConfig } from '@/extensions/core/load3d/interfaces'
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
const settingValues: Record<string, number> = {
|
||||
'Comfy.Load3D.LightIntensityMinimum': 1,
|
||||
'Comfy.Load3D.LightIntensityMaximum': 10,
|
||||
'Comfy.Load3D.LightAdjustmentIncrement': 0.1
|
||||
}
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({ get: (key: string) => settingValues[key] })
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
function renderGroup(isOriginalMaterial: boolean) {
|
||||
const config: LightConfig = { intensity: 5 }
|
||||
return render(LightMenuGroup, {
|
||||
props: { config, isOriginalMaterial },
|
||||
global: { plugins: [i18n], directives: { tooltip: () => {} } }
|
||||
})
|
||||
}
|
||||
|
||||
describe('LightMenuGroup', () => {
|
||||
it('shows the intensity control for the original material', () => {
|
||||
renderGroup(true)
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Intensity' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('explains intensity is unavailable for other materials', () => {
|
||||
renderGroup(false)
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Intensity' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Original material only')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('drives HDRI intensity (0-5) when an HDRI environment is active', async () => {
|
||||
const config: LightConfig = {
|
||||
intensity: 5,
|
||||
hdri: {
|
||||
enabled: true,
|
||||
hdriPath: 'env.hdr',
|
||||
showAsBackground: false,
|
||||
intensity: 2
|
||||
}
|
||||
}
|
||||
render(LightMenuGroup, {
|
||||
props: { config, isOriginalMaterial: true },
|
||||
global: { plugins: [i18n], directives: { tooltip: () => {} } }
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Intensity' }))
|
||||
|
||||
const slider = screen.getByRole('slider')
|
||||
expect(slider).toHaveAttribute('aria-valuemax', '5')
|
||||
expect(slider).toHaveAttribute('aria-valuenow', '2')
|
||||
})
|
||||
})
|
||||
@@ -1,105 +0,0 @@
|
||||
<template>
|
||||
<Popover v-if="isOriginalMaterial">
|
||||
<PopoverTrigger as-child>
|
||||
<button
|
||||
v-tooltip.bottom="tip(t('load3d.menuBar.intensity'))"
|
||||
:class="actionClass(false)"
|
||||
type="button"
|
||||
:aria-label="compact ? t('load3d.menuBar.intensity') : undefined"
|
||||
>
|
||||
<i class="icon-[lucide--sun] size-4" />
|
||||
<span v-if="!compact">{{ t('load3d.menuBar.intensity') }}</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
:side-offset="8"
|
||||
:class="cn(panelClass, 'w-56')"
|
||||
>
|
||||
<div class="flex flex-col gap-2 p-1">
|
||||
<span class="text-sm text-base-foreground">{{
|
||||
t('load3d.lightIntensity')
|
||||
}}</span>
|
||||
<Slider
|
||||
:model-value="[sliderValue]"
|
||||
:min="sliderMin"
|
||||
:max="sliderMax"
|
||||
:step="sliderStep"
|
||||
class="w-full"
|
||||
@update:model-value="onIntensityUpdate"
|
||||
/>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<span v-else class="px-2 text-sm text-muted">{{
|
||||
t('load3d.menuBar.originalMaterialOnly')
|
||||
}}</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import {
|
||||
actionClass,
|
||||
panelClass,
|
||||
tip
|
||||
} from '@/components/load3d/menubar/menuBarStyles'
|
||||
import Popover from '@/components/ui/popover/Popover.vue'
|
||||
import PopoverContent from '@/components/ui/popover/PopoverContent.vue'
|
||||
import Slider from '@/components/ui/slider/Slider.vue'
|
||||
import type { LightConfig } from '@/extensions/core/load3d/interfaces'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { PopoverTrigger } from 'reka-ui'
|
||||
|
||||
const { compact = false, isOriginalMaterial = false } = defineProps<{
|
||||
compact?: boolean
|
||||
isOriginalMaterial?: boolean
|
||||
}>()
|
||||
|
||||
const config = defineModel<LightConfig>('config')
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const lightIntensityMinimum = settingStore.get(
|
||||
'Comfy.Load3D.LightIntensityMinimum'
|
||||
)
|
||||
const lightIntensityMaximum = settingStore.get(
|
||||
'Comfy.Load3D.LightIntensityMaximum'
|
||||
)
|
||||
const lightAdjustmentIncrement = settingStore.get(
|
||||
'Comfy.Load3D.LightAdjustmentIncrement'
|
||||
)
|
||||
|
||||
const usesHdriIntensity = computed(
|
||||
() => !!config.value?.hdri?.hdriPath?.length && !!config.value?.hdri?.enabled
|
||||
)
|
||||
|
||||
const sliderMin = computed(() =>
|
||||
usesHdriIntensity.value ? 0 : lightIntensityMinimum
|
||||
)
|
||||
const sliderMax = computed(() =>
|
||||
usesHdriIntensity.value ? 5 : lightIntensityMaximum
|
||||
)
|
||||
const sliderStep = computed(() =>
|
||||
usesHdriIntensity.value ? 0.1 : lightAdjustmentIncrement
|
||||
)
|
||||
const sliderValue = computed(() =>
|
||||
usesHdriIntensity.value
|
||||
? (config.value?.hdri?.intensity ?? 1)
|
||||
: (config.value?.intensity ?? lightIntensityMinimum)
|
||||
)
|
||||
|
||||
function onIntensityUpdate(value?: number[]) {
|
||||
if (!value?.length || !config.value) return
|
||||
const next = value[0]
|
||||
if (usesHdriIntensity.value) {
|
||||
if (config.value.hdri) config.value.hdri.intensity = next
|
||||
} else {
|
||||
config.value.intensity = next
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,69 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import ModelMenuGroup from '@/components/load3d/menubar/ModelMenuGroup.vue'
|
||||
import type { ModelConfig } from '@/extensions/core/load3d/interfaces'
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
function makeConfig(overrides: Partial<ModelConfig> = {}): ModelConfig {
|
||||
return {
|
||||
upDirection: 'original',
|
||||
materialMode: 'original',
|
||||
showSkeleton: false,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function renderGroup(
|
||||
props: { config?: ModelConfig; hasSkeleton?: boolean } = {}
|
||||
) {
|
||||
const result = render(ModelMenuGroup, {
|
||||
props: { config: makeConfig(), ...props },
|
||||
global: { plugins: [i18n], directives: { tooltip: () => {} } }
|
||||
})
|
||||
return { ...result, user: userEvent.setup() }
|
||||
}
|
||||
|
||||
describe('ModelMenuGroup', () => {
|
||||
it('sets the up direction from the popover', async () => {
|
||||
const config = makeConfig()
|
||||
const { user } = renderGroup({ config })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Up Direction' }))
|
||||
await user.click(screen.getByRole('button', { name: '+Y' }))
|
||||
|
||||
expect(config.upDirection).toBe('+y')
|
||||
})
|
||||
|
||||
it('sets the material mode from the popover', async () => {
|
||||
const config = makeConfig()
|
||||
const { user } = renderGroup({ config })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Material' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Wireframe' }))
|
||||
|
||||
expect(config.materialMode).toBe('wireframe')
|
||||
})
|
||||
|
||||
it('toggles the skeleton only when supported', async () => {
|
||||
const config = makeConfig({ showSkeleton: false })
|
||||
const { user, rerender } = renderGroup({ config, hasSkeleton: false })
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Skeleton' })
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
await rerender({ config, hasSkeleton: true })
|
||||
await user.click(screen.getByRole('button', { name: 'Skeleton' }))
|
||||
|
||||
expect(config.showSkeleton).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,135 +0,0 @@
|
||||
<template>
|
||||
<Popover>
|
||||
<PopoverTrigger as-child>
|
||||
<button
|
||||
v-tooltip.bottom="tip(t('load3d.menuBar.upDirection'))"
|
||||
:class="actionClass(false)"
|
||||
type="button"
|
||||
:aria-label="compact ? t('load3d.menuBar.upDirection') : undefined"
|
||||
>
|
||||
<i class="icon-[lucide--move-3d] size-4" />
|
||||
<span v-if="!compact">{{ t('load3d.menuBar.upDirection') }}</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
:side-offset="8"
|
||||
:class="panelClass"
|
||||
>
|
||||
<button
|
||||
v-for="d in upDirections"
|
||||
:key="d"
|
||||
type="button"
|
||||
:class="cn(rowClass, upDirection === d && 'bg-button-active-surface')"
|
||||
@click="setUpDirection(d)"
|
||||
>
|
||||
{{ d.toUpperCase() }}
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Popover v-if="materialModes.length">
|
||||
<PopoverTrigger as-child>
|
||||
<button
|
||||
v-tooltip.bottom="tip(t('load3d.menuBar.material'))"
|
||||
:class="actionClass(false)"
|
||||
type="button"
|
||||
:aria-label="compact ? t('load3d.menuBar.material') : undefined"
|
||||
>
|
||||
<i class="icon-[lucide--box] size-4" />
|
||||
<span v-if="!compact">{{ t('load3d.menuBar.material') }}</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
:side-offset="8"
|
||||
:class="panelClass"
|
||||
>
|
||||
<button
|
||||
v-for="m in materialModes"
|
||||
:key="m"
|
||||
type="button"
|
||||
:class="cn(rowClass, materialMode === m && 'bg-button-active-surface')"
|
||||
@click="setMaterialMode(m)"
|
||||
>
|
||||
{{ t(`load3d.materialModes.${m}`) }}
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<button
|
||||
v-if="hasSkeleton"
|
||||
v-tooltip.bottom="tip(t('load3d.menuBar.skeleton'))"
|
||||
:class="actionClass(showSkeleton)"
|
||||
:aria-pressed="showSkeleton"
|
||||
type="button"
|
||||
:aria-label="compact ? t('load3d.menuBar.skeleton') : undefined"
|
||||
@click="toggleSkeleton"
|
||||
>
|
||||
<i class="icon-[lucide--bone] size-4" />
|
||||
<span v-if="!compact">{{ t('load3d.menuBar.skeleton') }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import {
|
||||
actionClass,
|
||||
panelClass,
|
||||
rowClass,
|
||||
tip
|
||||
} from '@/components/load3d/menubar/menuBarStyles'
|
||||
import Popover from '@/components/ui/popover/Popover.vue'
|
||||
import PopoverContent from '@/components/ui/popover/PopoverContent.vue'
|
||||
import type {
|
||||
MaterialMode,
|
||||
ModelConfig,
|
||||
UpDirection
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { PopoverTrigger } from 'reka-ui'
|
||||
|
||||
const {
|
||||
compact = false,
|
||||
hasSkeleton = false,
|
||||
materialModes = ['original', 'clay', 'normal', 'wireframe']
|
||||
} = defineProps<{
|
||||
compact?: boolean
|
||||
hasSkeleton?: boolean
|
||||
materialModes?: readonly MaterialMode[]
|
||||
}>()
|
||||
|
||||
const config = defineModel<ModelConfig>('config')
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const upDirection = computed(() => config.value?.upDirection)
|
||||
const materialMode = computed(() => config.value?.materialMode)
|
||||
const showSkeleton = computed(() => config.value?.showSkeleton ?? false)
|
||||
|
||||
const upDirections: UpDirection[] = [
|
||||
'original',
|
||||
'-x',
|
||||
'+x',
|
||||
'-y',
|
||||
'+y',
|
||||
'-z',
|
||||
'+z'
|
||||
]
|
||||
|
||||
function setUpDirection(direction: UpDirection) {
|
||||
if (config.value) config.value.upDirection = direction
|
||||
}
|
||||
|
||||
function setMaterialMode(mode: MaterialMode) {
|
||||
if (config.value) config.value.materialMode = mode
|
||||
}
|
||||
|
||||
function toggleSkeleton() {
|
||||
if (config.value) config.value.showSkeleton = !config.value.showSkeleton
|
||||
}
|
||||
</script>
|
||||
@@ -1,138 +0,0 @@
|
||||
import { render, screen, within } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import RecordMenuControl from '@/components/load3d/menubar/RecordMenuControl.vue'
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
type Props = {
|
||||
isRecording?: boolean
|
||||
hasRecording?: boolean
|
||||
recordingDuration?: number
|
||||
onStartRecording?: () => void
|
||||
onStopRecording?: () => void
|
||||
onExportRecording?: () => void
|
||||
onClearRecording?: () => void
|
||||
}
|
||||
|
||||
function renderControl(props: Props = {}) {
|
||||
const result = render(RecordMenuControl, {
|
||||
props,
|
||||
global: { plugins: [i18n], directives: { tooltip: () => {} } }
|
||||
})
|
||||
return { ...result, user: userEvent.setup() }
|
||||
}
|
||||
|
||||
async function openRecordingMenu(user: ReturnType<typeof userEvent.setup>) {
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Video recording of the scene [mp4]' })
|
||||
)
|
||||
}
|
||||
|
||||
describe('RecordMenuControl', () => {
|
||||
it('starts recording when idle', async () => {
|
||||
const onStartRecording = vi.fn()
|
||||
const { user } = renderControl({ isRecording: false, onStartRecording })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Record' }))
|
||||
|
||||
expect(onStartRecording).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('stops recording when active', async () => {
|
||||
const onStopRecording = vi.fn()
|
||||
const { user } = renderControl({ isRecording: true, onStopRecording })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Stop recording' }))
|
||||
|
||||
expect(onStopRecording).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('shows the recording duration once a recording exists', () => {
|
||||
renderControl({
|
||||
isRecording: false,
|
||||
hasRecording: true,
|
||||
recordingDuration: 65
|
||||
})
|
||||
|
||||
expect(screen.getByText('01:05')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('downloads the recording from the menu', async () => {
|
||||
const onExportRecording = vi.fn()
|
||||
const { user } = renderControl({
|
||||
hasRecording: true,
|
||||
recordingDuration: 4,
|
||||
onExportRecording
|
||||
})
|
||||
|
||||
await openRecordingMenu(user)
|
||||
await user.click(screen.getByRole('button', { name: 'Download Recording' }))
|
||||
|
||||
expect(onExportRecording).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('starts a new recording from the menu', async () => {
|
||||
const onStartRecording = vi.fn()
|
||||
const { user } = renderControl({
|
||||
hasRecording: true,
|
||||
recordingDuration: 4,
|
||||
onStartRecording
|
||||
})
|
||||
|
||||
await openRecordingMenu(user)
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Start New Recording' })
|
||||
)
|
||||
|
||||
expect(onStartRecording).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('deletes the recording from the menu', async () => {
|
||||
const onClearRecording = vi.fn()
|
||||
const { user } = renderControl({
|
||||
hasRecording: true,
|
||||
recordingDuration: 4,
|
||||
onClearRecording
|
||||
})
|
||||
|
||||
await openRecordingMenu(user)
|
||||
await user.click(
|
||||
within(screen.getByRole('dialog')).getByRole('button', {
|
||||
name: 'Delete Recording'
|
||||
})
|
||||
)
|
||||
|
||||
expect(onClearRecording).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('deletes the recording via the chip dismiss button', async () => {
|
||||
const onClearRecording = vi.fn()
|
||||
const { user } = renderControl({
|
||||
hasRecording: true,
|
||||
recordingDuration: 4,
|
||||
onClearRecording
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Delete Recording' }))
|
||||
|
||||
expect(onClearRecording).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('shows only the stop control while recording is in progress', () => {
|
||||
renderControl({ isRecording: true, hasRecording: true })
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', {
|
||||
name: 'Video recording of the scene [mp4]'
|
||||
})
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,141 +0,0 @@
|
||||
<template>
|
||||
<button
|
||||
v-if="isRecording"
|
||||
v-tooltip.top="tip(t('load3d.menuBar.stopRecording'))"
|
||||
:class="chipClass"
|
||||
type="button"
|
||||
:aria-label="t('load3d.menuBar.stopRecording')"
|
||||
@click="emit('stopRecording')"
|
||||
>
|
||||
<span class="size-2 animate-pulse rounded-full bg-red-500" />
|
||||
<span v-if="!compact">{{ t('load3d.menuBar.recording') }}</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-else-if="hasRecording"
|
||||
class="flex shrink-0 items-center gap-0.5 rounded-lg bg-button-active-surface py-0.5 pr-0.5 pl-1 text-sm text-base-foreground"
|
||||
>
|
||||
<Popover v-model:open="menuOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<button
|
||||
v-tooltip.top="tip(t('load3d.menuBar.videoRecordingTooltip'))"
|
||||
class="focus-visible:ring-ring flex items-center gap-1.5 rounded-md border-0 bg-transparent px-1 py-0.5 text-sm text-base-foreground transition-colors outline-none hover:bg-button-hover-surface focus-visible:ring-1"
|
||||
type="button"
|
||||
:aria-label="t('load3d.menuBar.videoRecordingTooltip')"
|
||||
data-testid="load3d-recording-duration"
|
||||
>
|
||||
<i class="icon-[lucide--film] size-4" />
|
||||
{{ formatDuration(recordingDuration ?? 0) }}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
:side-offset="8"
|
||||
:class="panelClass"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
:class="cn(rowClass, 'gap-2')"
|
||||
@click="downloadRecording"
|
||||
>
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
{{ t('load3d.menuBar.downloadRecording') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="cn(rowClass, 'gap-2')"
|
||||
@click="startNewRecording"
|
||||
>
|
||||
<i class="icon-[lucide--video] size-4" />
|
||||
{{ t('load3d.menuBar.startNewRecording') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="cn(rowClass, 'gap-2')"
|
||||
@click="deleteRecording"
|
||||
>
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
{{ t('load3d.menuBar.deleteRecording') }}
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<button
|
||||
v-tooltip.top="tip(t('load3d.menuBar.deleteRecording'))"
|
||||
class="focus-visible:ring-ring flex size-6 items-center justify-center rounded-md border-0 bg-transparent text-base-foreground transition-colors outline-none hover:bg-button-hover-surface focus-visible:ring-1"
|
||||
type="button"
|
||||
:aria-label="t('load3d.menuBar.deleteRecording')"
|
||||
@click="emit('clearRecording')"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-else
|
||||
v-tooltip.top="tip(t('load3d.menuBar.record'))"
|
||||
:class="chipClass"
|
||||
type="button"
|
||||
:aria-label="compact ? t('load3d.menuBar.record') : undefined"
|
||||
@click="emit('startRecording')"
|
||||
>
|
||||
<i class="icon-[lucide--video] size-4" />
|
||||
<span v-if="!compact">{{ t('load3d.menuBar.record') }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PopoverTrigger } from 'reka-ui'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import {
|
||||
chipClass,
|
||||
panelClass,
|
||||
rowClass,
|
||||
tip
|
||||
} from '@/components/load3d/menubar/menuBarStyles'
|
||||
import Popover from '@/components/ui/popover/Popover.vue'
|
||||
import PopoverContent from '@/components/ui/popover/PopoverContent.vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { compact = false } = defineProps<{
|
||||
compact?: boolean
|
||||
}>()
|
||||
|
||||
const isRecording = defineModel<boolean>('isRecording')
|
||||
const hasRecording = defineModel<boolean>('hasRecording')
|
||||
const recordingDuration = defineModel<number>('recordingDuration')
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'startRecording'): void
|
||||
(e: 'stopRecording'): void
|
||||
(e: 'exportRecording'): void
|
||||
(e: 'clearRecording'): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const menuOpen = ref(false)
|
||||
|
||||
function downloadRecording() {
|
||||
menuOpen.value = false
|
||||
emit('exportRecording')
|
||||
}
|
||||
|
||||
function startNewRecording() {
|
||||
menuOpen.value = false
|
||||
emit('startRecording')
|
||||
}
|
||||
|
||||
function deleteRecording() {
|
||||
menuOpen.value = false
|
||||
emit('clearRecording')
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number) {
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const remainingSeconds = Math.floor(seconds % 60)
|
||||
return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
</script>
|
||||
@@ -1,108 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import SceneMenuGroup from '@/components/load3d/menubar/SceneMenuGroup.vue'
|
||||
import type { SceneConfig } from '@/extensions/core/load3d/interfaces'
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
function makeConfig(overrides: Partial<SceneConfig> = {}): SceneConfig {
|
||||
return {
|
||||
showGrid: true,
|
||||
backgroundColor: '#000000',
|
||||
backgroundImage: '',
|
||||
backgroundRenderMode: 'tiled',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
type Props = {
|
||||
config?: SceneConfig
|
||||
fov?: number
|
||||
hdriActive?: boolean
|
||||
canUseBackgroundImage?: boolean
|
||||
onUpdateBackgroundImage?: (file: File | null) => void
|
||||
}
|
||||
|
||||
function renderGroup(props: Props = {}) {
|
||||
const result = render(SceneMenuGroup, {
|
||||
props: { config: makeConfig(), ...props },
|
||||
global: { plugins: [i18n], directives: { tooltip: () => {} } }
|
||||
})
|
||||
return { ...result, user: userEvent.setup() }
|
||||
}
|
||||
|
||||
describe('SceneMenuGroup', () => {
|
||||
it('toggles showGrid on the bound config', async () => {
|
||||
const config = makeConfig({ showGrid: true })
|
||||
const { user } = renderGroup({ config })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Show grid' }))
|
||||
|
||||
expect(config.showGrid).toBe(false)
|
||||
})
|
||||
|
||||
it('hides background color and image controls while HDRI is active', () => {
|
||||
renderGroup({ hdriActive: true })
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'BG Color' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'BG Image' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the image upload when background images are not allowed', () => {
|
||||
renderGroup({ canUseBackgroundImage: false })
|
||||
|
||||
expect(screen.getByRole('button', { name: 'BG Color' })).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'BG Image' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows panorama and remove once a background image exists', async () => {
|
||||
const onUpdateBackgroundImage = vi.fn()
|
||||
const { user } = renderGroup({
|
||||
config: makeConfig({ backgroundImage: 'bg.png' }),
|
||||
onUpdateBackgroundImage
|
||||
})
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Panorama' })).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'Remove BG' }))
|
||||
|
||||
expect(onUpdateBackgroundImage).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('exposes the FOV control while a panorama background is active', () => {
|
||||
renderGroup({
|
||||
config: makeConfig({
|
||||
backgroundImage: 'bg.png',
|
||||
backgroundRenderMode: 'panorama'
|
||||
}),
|
||||
fov: 75
|
||||
})
|
||||
|
||||
expect(screen.getByRole('button', { name: 'FOV' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('clears the file input so the same image can be re-picked', async () => {
|
||||
const onUpdateBackgroundImage = vi.fn()
|
||||
const { user } = renderGroup({ onUpdateBackgroundImage })
|
||||
const input = screen.getByTestId<HTMLInputElement>('scene-bg-image-input')
|
||||
const file = new File(['x'], 'bg.png', { type: 'image/png' })
|
||||
|
||||
await user.upload(input, file)
|
||||
|
||||
expect(onUpdateBackgroundImage).toHaveBeenCalledWith(file)
|
||||
expect(input.value).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -1,190 +0,0 @@
|
||||
<template>
|
||||
<button
|
||||
v-tooltip.bottom="tip(t('load3d.menuBar.showGrid'))"
|
||||
:class="actionClass(showGrid)"
|
||||
:aria-pressed="showGrid"
|
||||
type="button"
|
||||
:aria-label="compact ? t('load3d.menuBar.showGrid') : undefined"
|
||||
@click="toggleGrid"
|
||||
>
|
||||
<i class="icon-[lucide--grid-3x3] size-4" />
|
||||
<span v-if="!compact">{{ t('load3d.menuBar.showGrid') }}</span>
|
||||
</button>
|
||||
|
||||
<template v-if="!hasImage && !hdriActive">
|
||||
<button
|
||||
v-tooltip.bottom="tip(t('load3d.menuBar.bgColor'))"
|
||||
:class="actionClass(false)"
|
||||
type="button"
|
||||
:aria-label="compact ? t('load3d.menuBar.bgColor') : undefined"
|
||||
@click="colorRef?.click()"
|
||||
>
|
||||
<i class="icon-[lucide--palette] size-4" />
|
||||
<span v-if="!compact">{{ t('load3d.menuBar.bgColor') }}</span>
|
||||
</button>
|
||||
<input
|
||||
ref="colorRef"
|
||||
type="color"
|
||||
class="pointer-events-none absolute size-0 opacity-0"
|
||||
:value="bgColor"
|
||||
@input="setBackgroundColor"
|
||||
/>
|
||||
<template v-if="canUseBackgroundImage">
|
||||
<button
|
||||
v-tooltip.bottom="tip(t('load3d.menuBar.bgImage'))"
|
||||
:class="actionClass(false)"
|
||||
type="button"
|
||||
:aria-label="compact ? t('load3d.menuBar.bgImage') : undefined"
|
||||
@click="bgImageRef?.click()"
|
||||
>
|
||||
<i class="icon-[lucide--image] size-4" />
|
||||
<span v-if="!compact">{{ t('load3d.menuBar.bgImage') }}</span>
|
||||
</button>
|
||||
<input
|
||||
ref="bgImageRef"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="pointer-events-none absolute size-0 opacity-0"
|
||||
data-testid="scene-bg-image-input"
|
||||
@change="onBackgroundImagePicked"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template v-if="hasImage">
|
||||
<button
|
||||
v-tooltip.bottom="tip(t('load3d.menuBar.panorama'))"
|
||||
:class="actionClass(isPanorama)"
|
||||
:aria-pressed="isPanorama"
|
||||
type="button"
|
||||
:aria-label="compact ? t('load3d.menuBar.panorama') : undefined"
|
||||
@click="togglePanorama"
|
||||
>
|
||||
<i class="icon-[lucide--globe] size-4" />
|
||||
<span v-if="!compact">{{ t('load3d.menuBar.panorama') }}</span>
|
||||
</button>
|
||||
<Popover v-if="isPanorama">
|
||||
<PopoverTrigger as-child>
|
||||
<button
|
||||
v-tooltip.bottom="tip(t('load3d.menuBar.fov'))"
|
||||
:class="actionClass(false)"
|
||||
type="button"
|
||||
:aria-label="compact ? t('load3d.menuBar.fov') : undefined"
|
||||
>
|
||||
<i class="icon-[lucide--focus] size-4" />
|
||||
<span v-if="!compact">{{ t('load3d.menuBar.fov') }}</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
:side-offset="8"
|
||||
:class="cn(panelClass, 'w-56')"
|
||||
>
|
||||
<div class="flex flex-col gap-2 p-1">
|
||||
<span class="text-sm text-base-foreground">{{
|
||||
t('load3d.fov')
|
||||
}}</span>
|
||||
<Slider
|
||||
:model-value="[fovValue]"
|
||||
:min="10"
|
||||
:max="150"
|
||||
:step="1"
|
||||
class="w-full"
|
||||
@update:model-value="setFov"
|
||||
/>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<button
|
||||
v-tooltip.bottom="tip(t('load3d.menuBar.removeBackground'))"
|
||||
:class="actionClass(false)"
|
||||
type="button"
|
||||
:aria-label="compact ? t('load3d.menuBar.removeBackground') : undefined"
|
||||
@click="removeBackgroundImage"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
<span v-if="!compact">{{ t('load3d.menuBar.removeBackground') }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import {
|
||||
actionClass,
|
||||
panelClass,
|
||||
tip
|
||||
} from '@/components/load3d/menubar/menuBarStyles'
|
||||
import Popover from '@/components/ui/popover/Popover.vue'
|
||||
import PopoverContent from '@/components/ui/popover/PopoverContent.vue'
|
||||
import Slider from '@/components/ui/slider/Slider.vue'
|
||||
import type { SceneConfig } from '@/extensions/core/load3d/interfaces'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { PopoverTrigger } from 'reka-ui'
|
||||
|
||||
const {
|
||||
compact = false,
|
||||
canUseBackgroundImage = true,
|
||||
hdriActive = false
|
||||
} = defineProps<{
|
||||
compact?: boolean
|
||||
canUseBackgroundImage?: boolean
|
||||
hdriActive?: boolean
|
||||
}>()
|
||||
|
||||
const config = defineModel<SceneConfig>('config')
|
||||
const fov = defineModel<number>('fov')
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'updateBackgroundImage', file: File | null): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const showGrid = computed(() => config.value?.showGrid ?? false)
|
||||
const bgColor = computed(() => config.value?.backgroundColor ?? '#000000')
|
||||
const hasImage = computed(
|
||||
() => !!config.value?.backgroundImage && config.value.backgroundImage !== ''
|
||||
)
|
||||
const isPanorama = computed(
|
||||
() => config.value?.backgroundRenderMode === 'panorama'
|
||||
)
|
||||
const fovValue = computed(() => fov.value ?? 10)
|
||||
|
||||
const colorRef = ref<HTMLInputElement | null>(null)
|
||||
const bgImageRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
function toggleGrid() {
|
||||
if (config.value) config.value.showGrid = !config.value.showGrid
|
||||
}
|
||||
|
||||
function setBackgroundColor(event: Event) {
|
||||
if (config.value) {
|
||||
config.value.backgroundColor = (event.target as HTMLInputElement).value
|
||||
}
|
||||
}
|
||||
|
||||
function onBackgroundImagePicked(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
input.value = ''
|
||||
if (file) emit('updateBackgroundImage', file)
|
||||
}
|
||||
|
||||
function removeBackgroundImage() {
|
||||
emit('updateBackgroundImage', null)
|
||||
}
|
||||
|
||||
function togglePanorama() {
|
||||
if (!config.value) return
|
||||
config.value.backgroundRenderMode =
|
||||
config.value.backgroundRenderMode === 'panorama' ? 'tiled' : 'panorama'
|
||||
}
|
||||
|
||||
function setFov(value?: number[]) {
|
||||
if (value?.length) fov.value = value[0]
|
||||
}
|
||||
</script>
|
||||
@@ -1,24 +0,0 @@
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
export const chipClass =
|
||||
'flex shrink-0 items-center gap-1.5 rounded-lg border-0 bg-interface-menu-surface px-2.5 py-1 text-sm text-base-foreground outline-none transition-colors hover:bg-button-active-surface focus-visible:ring-1 focus-visible:ring-ring'
|
||||
|
||||
export const iconBtnClass =
|
||||
'flex size-8 items-center justify-center rounded-md border-0 bg-transparent text-base-foreground outline-none transition-colors hover:bg-button-hover-surface focus-visible:ring-1 focus-visible:ring-ring'
|
||||
|
||||
export const panelClass =
|
||||
'w-48 max-h-80 overflow-y-auto flex flex-col gap-0.5 p-1.5 rounded-lg border-border-default bg-interface-menu-surface shadow-interface'
|
||||
|
||||
export const rowClass =
|
||||
'flex w-full cursor-pointer items-center rounded-md border-0 bg-transparent px-2 py-1.5 text-left text-sm text-base-foreground outline-none hover:bg-button-hover-surface focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-inset'
|
||||
|
||||
export function actionClass(active: boolean) {
|
||||
return cn(
|
||||
'focus-visible:ring-ring flex shrink-0 items-center gap-1.5 rounded-md border-0 bg-transparent px-2 py-1 text-sm text-base-foreground transition-colors outline-none hover:bg-button-hover-surface focus-visible:ring-1',
|
||||
active && 'bg-button-active-surface'
|
||||
)
|
||||
}
|
||||
|
||||
export function tip(label: string) {
|
||||
return { value: label, showDelay: 300 }
|
||||
}
|
||||
@@ -32,7 +32,7 @@ describe('PaletteSwatchRow', () => {
|
||||
|
||||
it('appends a color when the add button is clicked', async () => {
|
||||
const { emitted } = renderRow(['#ff0000'])
|
||||
await userEvent.click(screen.getByRole('button', { name: '+' }))
|
||||
await userEvent.click(screen.getByRole('button'))
|
||||
expect(lastEmit(emitted)).toEqual(['#ff0000', '#ffffff'])
|
||||
})
|
||||
|
||||
@@ -44,14 +44,18 @@ describe('PaletteSwatchRow', () => {
|
||||
|
||||
it('hides the add button once the max is reached', () => {
|
||||
renderRow(['#a', '#b'], 2)
|
||||
expect(screen.queryByRole('button', { name: '+' })).toBeNull()
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
})
|
||||
|
||||
it('opens the color picker when a swatch is clicked', async () => {
|
||||
const { container } = renderRow(['#ff0000'])
|
||||
const swatch = container.querySelector('[data-index="0"]')!
|
||||
await userEvent.click(swatch)
|
||||
expect(swatch.getAttribute('data-state')).toBe('open')
|
||||
it('writes a picked color back through the hidden color input', async () => {
|
||||
const { container, emitted } = renderRow(['#ff0000', '#00ff00'])
|
||||
await fireEvent.click(container.querySelector('[data-index="1"]')!)
|
||||
const input = container.querySelector(
|
||||
'input[type="color"]'
|
||||
) as HTMLInputElement
|
||||
input.value = '#0000ff'
|
||||
await fireEvent.input(input)
|
||||
expect(lastEmit(emitted)).toEqual(['#ff0000', '#0000ff'])
|
||||
})
|
||||
|
||||
it('starts a drag on pointer down without emitting', async () => {
|
||||
|
||||
@@ -1,25 +1,17 @@
|
||||
<template>
|
||||
<div ref="container" class="flex flex-wrap items-center gap-1">
|
||||
<ColorPicker
|
||||
<div
|
||||
v-for="(hex, i) in modelValue"
|
||||
:key="i"
|
||||
:model-value="hex"
|
||||
:alpha="false"
|
||||
@update:model-value="(value) => updateAt(i, value)"
|
||||
>
|
||||
<template #trigger>
|
||||
<button
|
||||
type="button"
|
||||
:data-index="i"
|
||||
:data-hex="hex"
|
||||
class="relative size-5 cursor-pointer rounded-sm border border-component-node-border p-0"
|
||||
:style="{ background: hex }"
|
||||
:title="t('palette.swatchTitle')"
|
||||
@contextmenu.prevent.stop="remove(i)"
|
||||
@pointerdown="onPointerDown(i, $event)"
|
||||
/>
|
||||
</template>
|
||||
</ColorPicker>
|
||||
:key="`${i}-${hex}`"
|
||||
:data-index="i"
|
||||
:data-hex="hex"
|
||||
class="relative size-5 cursor-pointer rounded-sm border border-component-node-border"
|
||||
:style="{ background: hex }"
|
||||
:title="t('palette.swatchTitle')"
|
||||
@click="openPicker(i, $event)"
|
||||
@contextmenu.prevent.stop="remove(i)"
|
||||
@pointerdown="onPointerDown(i, $event)"
|
||||
/>
|
||||
<button
|
||||
v-if="modelValue.length < max"
|
||||
type="button"
|
||||
@@ -29,6 +21,12 @@
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<input
|
||||
ref="picker"
|
||||
type="color"
|
||||
class="pointer-events-none absolute size-0 opacity-0"
|
||||
@input="onPickerInput"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -36,7 +34,6 @@
|
||||
import { useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ColorPicker from '@/components/ui/color-picker/ColorPicker.vue'
|
||||
import { usePaletteSwatchRow } from '@/composables/palette/usePaletteSwatchRow'
|
||||
|
||||
const { max = 5 } = defineProps<{ max?: number }>()
|
||||
@@ -44,9 +41,8 @@ const modelValue = defineModel<string[]>({ required: true })
|
||||
const { t } = useI18n()
|
||||
|
||||
const container = useTemplateRef<HTMLDivElement>('container')
|
||||
const picker = useTemplateRef<HTMLInputElement>('picker')
|
||||
|
||||
const { updateAt, remove, addColor, onPointerDown } = usePaletteSwatchRow({
|
||||
modelValue,
|
||||
container
|
||||
})
|
||||
const { openPicker, onPickerInput, remove, addColor, onPointerDown } =
|
||||
usePaletteSwatchRow({ modelValue, container, picker })
|
||||
</script>
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
<template>
|
||||
<section :class="cn('group flex min-w-0 flex-col py-2', className)">
|
||||
<div class="flex min-h-8 w-full items-center gap-2 px-3">
|
||||
<button
|
||||
type="button"
|
||||
class="focus-visible:ring-ring flex min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-sm border-0 bg-transparent p-0 text-left outline-none focus-visible:ring-1"
|
||||
:aria-expanded="!collapse"
|
||||
:aria-controls="bodyId"
|
||||
@click="collapse = !collapse"
|
||||
<component
|
||||
:is="collapsible ? 'button' : 'div'"
|
||||
:type="collapsible ? 'button' : undefined"
|
||||
:class="
|
||||
cn(
|
||||
'flex min-w-0 flex-1 items-center gap-2 rounded-sm border-0 bg-transparent p-0 text-left',
|
||||
collapsible &&
|
||||
'focus-visible:ring-ring cursor-pointer outline-none focus-visible:ring-1'
|
||||
)
|
||||
"
|
||||
:aria-expanded="collapsible ? !collapse : undefined"
|
||||
:aria-controls="collapsible ? bodyId : undefined"
|
||||
@click="collapsible && (collapse = !collapse)"
|
||||
>
|
||||
<span
|
||||
class="flex h-4 min-w-4 shrink-0 items-center justify-center rounded-full bg-destructive-background-hover px-1 text-2xs/none font-semibold text-white tabular-nums"
|
||||
@@ -16,9 +23,10 @@
|
||||
<span class="min-w-0 flex-1 truncate text-sm text-base-foreground">
|
||||
{{ title }}
|
||||
</span>
|
||||
</button>
|
||||
</component>
|
||||
<slot name="actions" />
|
||||
<button
|
||||
v-if="collapsible"
|
||||
type="button"
|
||||
class="focus-visible:ring-ring flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-sm border-0 bg-transparent p-0 outline-none focus-visible:ring-1"
|
||||
:aria-expanded="!collapse"
|
||||
@@ -40,7 +48,7 @@
|
||||
</button>
|
||||
</div>
|
||||
<TransitionCollapse>
|
||||
<div v-if="!collapse" :id="bodyId">
|
||||
<div v-if="!(collapsible && collapse)" :id="bodyId">
|
||||
<slot />
|
||||
</div>
|
||||
</TransitionCollapse>
|
||||
@@ -57,10 +65,13 @@ import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCol
|
||||
const {
|
||||
title,
|
||||
count,
|
||||
collapsible = true,
|
||||
class: className
|
||||
} = defineProps<{
|
||||
title: string
|
||||
count: number
|
||||
/** When false, the section always renders expanded with no toggle UI. */
|
||||
collapsible?: boolean
|
||||
class?: string
|
||||
}>()
|
||||
|
||||
|
||||
@@ -89,9 +89,10 @@ function seedTwoErrorGroups(pinia: TestingPinia) {
|
||||
})
|
||||
}
|
||||
|
||||
function renderList(pinia: TestingPinia) {
|
||||
function renderList(pinia: TestingPinia, props: Record<string, unknown> = {}) {
|
||||
const user = userEvent.setup()
|
||||
render(ErrorGroupList, {
|
||||
const { rerender } = render(ErrorGroupList, {
|
||||
props,
|
||||
global: {
|
||||
plugins: [PrimeVue, testI18n, pinia],
|
||||
stubs: {
|
||||
@@ -101,7 +102,7 @@ function renderList(pinia: TestingPinia) {
|
||||
}
|
||||
}
|
||||
})
|
||||
return { user }
|
||||
return { user, rerender }
|
||||
}
|
||||
|
||||
function createPinia() {
|
||||
@@ -236,4 +237,68 @@ describe('ErrorGroupList selection emphasis', () => {
|
||||
expect(strip).toHaveTextContent('2 nodes — 2 errors')
|
||||
})
|
||||
})
|
||||
|
||||
it('carousel ignores collapse state, renders no toggles, and snaps to matches', async () => {
|
||||
const scrollTo = vi
|
||||
.spyOn(Element.prototype, 'scrollTo')
|
||||
.mockImplementation(() => {})
|
||||
const pinia = createPinia()
|
||||
seedTwoErrorGroups(pinia)
|
||||
const { user, rerender } = renderList(pinia)
|
||||
const canvasStore = useCanvasStore(pinia)
|
||||
|
||||
const loaderSection = getSectionByTitle('Validation failed')
|
||||
const [loaderHeader] = within(loaderSection).getAllByRole('button')
|
||||
await user.click(loaderHeader)
|
||||
expect(isSectionExpanded(loaderSection)).toBe(false)
|
||||
|
||||
await rerender({ carousel: true })
|
||||
|
||||
expect(
|
||||
within(getSectionByTitle('Validation failed')).getByText('LoaderNode'),
|
||||
'the carousel ignores collapse state carried over from the list'
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByLabelText('Collapse'),
|
||||
'the carousel renders no collapse affordances'
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.queryByLabelText('Expand')).not.toBeInTheDocument()
|
||||
|
||||
const sections = screen.getAllByTestId('error-group-execution')
|
||||
const matchedIndex = 1
|
||||
const nodeInSecondSlide = within(sections[matchedIndex]).queryByText(
|
||||
'Missing connection'
|
||||
)
|
||||
? SAMPLER_NODE
|
||||
: LOADER_NODE
|
||||
|
||||
canvasStore.selectedItems = fromAny<
|
||||
typeof canvasStore.selectedItems,
|
||||
unknown
|
||||
>([nodeInSecondSlide])
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
scrollTo,
|
||||
'selection snaps to the matched slide, not slide 0'
|
||||
).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ left: matchedIndex * 8 })
|
||||
)
|
||||
})
|
||||
expect(
|
||||
within(getSectionByTitle('Validation failed')).getByText('LoaderNode'),
|
||||
'slides stay expanded instead of collapsing'
|
||||
).toBeInTheDocument()
|
||||
|
||||
const dots = within(screen.getByTestId('error-carousel-dots')).getAllByRole(
|
||||
'button'
|
||||
)
|
||||
expect(dots).toHaveLength(2)
|
||||
scrollTo.mockClear()
|
||||
await user.click(dots[0])
|
||||
expect(scrollTo, 'dot buttons scroll to their slide').toHaveBeenCalledWith(
|
||||
expect.objectContaining({ left: 0 })
|
||||
)
|
||||
|
||||
scrollTo.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<div class="flex min-w-0 flex-col">
|
||||
<!-- Search bar + collapse toggle -->
|
||||
<div
|
||||
v-if="showSearch"
|
||||
class="flex min-w-0 shrink-0 items-center border-b border-interface-stroke px-4 pt-1 pb-4"
|
||||
>
|
||||
<AsyncSearchInput v-model="searchQuery" class="flex-1" />
|
||||
@@ -26,10 +27,16 @@
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="overflow-hidden rounded-lg border border-secondary-background"
|
||||
:class="
|
||||
cn(
|
||||
!carousel &&
|
||||
'overflow-hidden rounded-lg border border-secondary-background'
|
||||
)
|
||||
"
|
||||
>
|
||||
<!-- Errors summary hero -->
|
||||
<div
|
||||
v-if="!carousel"
|
||||
data-testid="errors-summary-hero"
|
||||
class="flex items-center gap-2 bg-base-foreground/5 p-2"
|
||||
>
|
||||
@@ -56,7 +63,14 @@
|
||||
<div
|
||||
data-testid="selection-context-strip"
|
||||
role="status"
|
||||
class="flex items-center border-t border-secondary-background px-3 pt-3.5 pb-1.5"
|
||||
:class="
|
||||
cn(
|
||||
'flex items-center px-3',
|
||||
carousel
|
||||
? 'mb-2 rounded-md border border-secondary-background py-1.5'
|
||||
: 'border-t border-secondary-background pt-3.5 pb-1.5'
|
||||
)
|
||||
"
|
||||
>
|
||||
<i18n-t
|
||||
:keypath="strip.keypath"
|
||||
@@ -78,246 +92,308 @@
|
||||
</div>
|
||||
|
||||
<!-- Group by Class Type -->
|
||||
<TransitionGroup tag="div" name="list-scale" class="relative">
|
||||
<ErrorCardSection
|
||||
v-for="group in filteredGroups"
|
||||
:key="group.groupKey"
|
||||
:data-testid="'error-group-' + group.type.replaceAll('_', '-')"
|
||||
:title="group.displayTitle"
|
||||
:count="group.count"
|
||||
:collapse="isSectionCollapsed(group.groupKey) && !isSearching"
|
||||
class="border-t border-secondary-background first:border-t-0"
|
||||
@update:collapse="setSectionCollapsed(group.groupKey, $event)"
|
||||
<div
|
||||
ref="carouselRef"
|
||||
:class="
|
||||
cn(
|
||||
carousel && 'scrollbar-hide snap-x snap-mandatory overflow-x-auto'
|
||||
)
|
||||
"
|
||||
>
|
||||
<TransitionGroup
|
||||
tag="div"
|
||||
name="list-scale"
|
||||
:class="cn('relative', carousel && 'flex gap-2')"
|
||||
>
|
||||
<template #actions>
|
||||
<Button
|
||||
v-if="
|
||||
group.type === 'missing_node' &&
|
||||
missingNodePacks.length > 0 &&
|
||||
shouldShowInstallButton
|
||||
"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="shrink-0"
|
||||
:disabled="isInstallingAll"
|
||||
@click.stop="installAll"
|
||||
>
|
||||
<DotSpinner v-if="isInstallingAll" duration="1s" :size="12" />
|
||||
{{
|
||||
isInstallingAll
|
||||
? t('rightSidePanel.missingNodePacks.installing')
|
||||
: t('rightSidePanel.missingNodePacks.installAll')
|
||||
}}
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="group.type === 'swap_nodes'"
|
||||
v-tooltip.top="
|
||||
t(
|
||||
'nodeReplacement.replaceAllWarning',
|
||||
'Replaces all available nodes in this group.'
|
||||
)
|
||||
"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="shrink-0"
|
||||
@click.stop="handleReplaceAll()"
|
||||
>
|
||||
{{ t('nodeReplacement.replaceAll', 'Replace All') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="
|
||||
group.type === 'missing_model' &&
|
||||
showMissingModelHeaderRefresh
|
||||
"
|
||||
data-testid="missing-model-header-refresh"
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
class="shrink-0 rounded-lg hover:bg-transparent hover:text-base-foreground"
|
||||
:aria-label="t('rightSidePanel.missingModels.refresh')"
|
||||
:aria-busy="missingModelStore.isRefreshingMissingModels"
|
||||
:aria-disabled="missingModelStore.isRefreshingMissingModels"
|
||||
@click.stop="handleMissingModelRefresh"
|
||||
>
|
||||
<DotSpinner
|
||||
v-if="missingModelStore.isRefreshingMissingModels"
|
||||
aria-hidden="true"
|
||||
duration="1s"
|
||||
:size="12"
|
||||
/>
|
||||
<i
|
||||
v-else
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--refresh-cw] size-4 shrink-0"
|
||||
/>
|
||||
</Button>
|
||||
<span
|
||||
v-if="
|
||||
group.type === 'missing_model' &&
|
||||
showMissingModelHeaderRefresh
|
||||
"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
class="sr-only"
|
||||
>
|
||||
{{
|
||||
missingModelStore.isRefreshingMissingModels
|
||||
? t('rightSidePanel.missingModels.refreshing')
|
||||
: ''
|
||||
}}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-if="group.displayMessage"
|
||||
data-testid="error-group-display-message"
|
||||
class="px-3 py-1"
|
||||
<ErrorCardSection
|
||||
v-for="group in filteredGroups"
|
||||
:key="group.groupKey"
|
||||
:data-testid="'error-group-' + group.type.replaceAll('_', '-')"
|
||||
:title="group.displayTitle"
|
||||
:count="group.count"
|
||||
:collapsible="!carousel"
|
||||
:collapse="
|
||||
!carousel && isSectionCollapsed(group.groupKey) && !isSearching
|
||||
"
|
||||
:class="
|
||||
cn(
|
||||
carousel
|
||||
? 'max-h-[30vh] w-full shrink-0 snap-center overflow-y-auto rounded-lg border border-secondary-background'
|
||||
: 'border-t border-secondary-background first:border-t-0'
|
||||
)
|
||||
"
|
||||
@update:collapse="setSectionCollapsed(group.groupKey, $event)"
|
||||
>
|
||||
<p
|
||||
class="m-0 text-xs/normal wrap-break-word whitespace-pre-wrap text-base-foreground/50"
|
||||
<template #actions>
|
||||
<Button
|
||||
v-if="
|
||||
group.type === 'missing_node' &&
|
||||
missingNodePacks.length > 0 &&
|
||||
shouldShowInstallButton
|
||||
"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="shrink-0"
|
||||
:disabled="isInstallingAll"
|
||||
@click.stop="installAll"
|
||||
>
|
||||
<DotSpinner v-if="isInstallingAll" duration="1s" :size="12" />
|
||||
{{
|
||||
isInstallingAll
|
||||
? t('rightSidePanel.missingNodePacks.installing')
|
||||
: t('rightSidePanel.missingNodePacks.installAll')
|
||||
}}
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="group.type === 'swap_nodes'"
|
||||
v-tooltip.top="
|
||||
t(
|
||||
'nodeReplacement.replaceAllWarning',
|
||||
'Replaces all available nodes in this group.'
|
||||
)
|
||||
"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="shrink-0"
|
||||
@click.stop="handleReplaceAll()"
|
||||
>
|
||||
{{ t('nodeReplacement.replaceAll', 'Replace All') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="
|
||||
group.type === 'missing_model' &&
|
||||
showMissingModelHeaderRefresh
|
||||
"
|
||||
data-testid="missing-model-header-refresh"
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
class="shrink-0 rounded-lg hover:bg-transparent hover:text-base-foreground"
|
||||
:aria-label="t('rightSidePanel.missingModels.refresh')"
|
||||
:aria-busy="missingModelStore.isRefreshingMissingModels"
|
||||
:aria-disabled="missingModelStore.isRefreshingMissingModels"
|
||||
@click.stop="handleMissingModelRefresh"
|
||||
>
|
||||
<DotSpinner
|
||||
v-if="missingModelStore.isRefreshingMissingModels"
|
||||
aria-hidden="true"
|
||||
duration="1s"
|
||||
:size="12"
|
||||
/>
|
||||
<i
|
||||
v-else
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--refresh-cw] size-4 shrink-0"
|
||||
/>
|
||||
</Button>
|
||||
<span
|
||||
v-if="
|
||||
group.type === 'missing_model' &&
|
||||
showMissingModelHeaderRefresh
|
||||
"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
class="sr-only"
|
||||
>
|
||||
{{
|
||||
missingModelStore.isRefreshingMissingModels
|
||||
? t('rightSidePanel.missingModels.refreshing')
|
||||
: ''
|
||||
}}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-if="group.displayMessage"
|
||||
data-testid="error-group-display-message"
|
||||
class="px-3 py-1"
|
||||
>
|
||||
{{ group.displayMessage }}
|
||||
</p>
|
||||
</div>
|
||||
<p
|
||||
class="m-0 text-xs/normal wrap-break-word whitespace-pre-wrap text-base-foreground/50"
|
||||
>
|
||||
{{ group.displayMessage }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Missing Node Packs -->
|
||||
<MissingNodeCard
|
||||
v-if="group.type === 'missing_node'"
|
||||
:show-info-button="shouldShowManagerButtons"
|
||||
:missing-pack-groups="missingPackGroups"
|
||||
:highlighted-node-ids="selectionMatchedAssetNodeIds"
|
||||
@locate-node="handleLocateMissingNode"
|
||||
@open-manager-info="handleOpenManagerInfo"
|
||||
/>
|
||||
<!-- Missing Node Packs -->
|
||||
<MissingNodeCard
|
||||
v-if="group.type === 'missing_node'"
|
||||
:show-info-button="shouldShowManagerButtons"
|
||||
:missing-pack-groups="missingPackGroups"
|
||||
:highlighted-node-ids="selectionMatchedAssetNodeIds"
|
||||
@locate-node="handleLocateMissingNode"
|
||||
@open-manager-info="handleOpenManagerInfo"
|
||||
/>
|
||||
|
||||
<!-- Swap Nodes -->
|
||||
<SwapNodesCard
|
||||
v-if="group.type === 'swap_nodes'"
|
||||
:swap-node-groups="swapNodeGroups"
|
||||
:highlighted-node-ids="selectionMatchedAssetNodeIds"
|
||||
@locate-node="handleLocateMissingNode"
|
||||
@replace="handleReplaceGroup"
|
||||
/>
|
||||
<!-- Swap Nodes -->
|
||||
<SwapNodesCard
|
||||
v-if="group.type === 'swap_nodes'"
|
||||
:swap-node-groups="swapNodeGroups"
|
||||
:highlighted-node-ids="selectionMatchedAssetNodeIds"
|
||||
@locate-node="handleLocateMissingNode"
|
||||
@replace="handleReplaceGroup"
|
||||
/>
|
||||
|
||||
<!-- Execution Errors -->
|
||||
<div v-if="isExecutionItemListGroup(group)" class="px-3">
|
||||
<ul class="m-0 list-none space-y-1 p-0">
|
||||
<li
|
||||
v-for="item in getExecutionItemList(group)"
|
||||
:key="item.key"
|
||||
<!-- Execution Errors -->
|
||||
<div v-if="isExecutionItemListGroup(group)" class="px-3">
|
||||
<ul class="m-0 list-none space-y-1 p-0">
|
||||
<li
|
||||
v-for="item in getExecutionItemList(group)"
|
||||
:key="item.key"
|
||||
:aria-current="
|
||||
isCardInSelection(item.cardId) ? 'true' : undefined
|
||||
"
|
||||
:class="
|
||||
cn(
|
||||
'min-w-0',
|
||||
selectionEmphasisClass(isCardInSelection(item.cardId))
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span class="flex min-w-0 flex-1 items-center gap-1">
|
||||
<button
|
||||
v-tooltip.top="{
|
||||
value: item.displayDetails || undefined,
|
||||
showDelay: 300
|
||||
}"
|
||||
type="button"
|
||||
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-xs/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
|
||||
@click="handleLocateNode(item.nodeId)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
<Button
|
||||
v-if="item.displayDetails"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:class="
|
||||
cn(
|
||||
'size-6 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset',
|
||||
isExecutionItemDetailExpanded(item.key) &&
|
||||
'bg-secondary-background-selected text-base-foreground hover:bg-secondary-background-selected'
|
||||
)
|
||||
"
|
||||
:aria-label="
|
||||
t('rightSidePanel.infoFor', { item: item.label })
|
||||
"
|
||||
:aria-controls="getExecutionItemDetailId(item.key)"
|
||||
:aria-expanded="
|
||||
isExecutionItemDetailExpanded(item.key)
|
||||
"
|
||||
@click.stop="toggleExecutionItemDetail(item.key)"
|
||||
>
|
||||
<i class="icon-[lucide--info] size-3.5" />
|
||||
</Button>
|
||||
</span>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
|
||||
:aria-label="
|
||||
t('rightSidePanel.locateNodeFor', {
|
||||
item: item.label
|
||||
})
|
||||
"
|
||||
@click.stop="handleLocateNode(item.nodeId)"
|
||||
>
|
||||
<i class="icon-[lucide--locate] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<TransitionCollapse>
|
||||
<p
|
||||
v-if="
|
||||
item.displayDetails &&
|
||||
isExecutionItemDetailExpanded(item.key)
|
||||
"
|
||||
:id="getExecutionItemDetailId(item.key)"
|
||||
class="m-0 mt-0.5 pr-10 text-2xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
>
|
||||
{{ item.displayDetails }}
|
||||
</p>
|
||||
</TransitionCollapse>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="group.type === 'execution'"
|
||||
class="space-y-3 px-3"
|
||||
>
|
||||
<ErrorNodeCard
|
||||
v-for="card in group.cards"
|
||||
:key="card.id"
|
||||
:card="card"
|
||||
:aria-current="
|
||||
isCardInSelection(item.cardId) ? 'true' : undefined
|
||||
isCardInSelection(card.id) ? 'true' : undefined
|
||||
"
|
||||
:class="
|
||||
cn(
|
||||
'min-w-0',
|
||||
selectionEmphasisClass(isCardInSelection(item.cardId))
|
||||
selectionEmphasisClass(isCardInSelection(card.id)),
|
||||
isCardInSelection(card.id) && '-my-1 py-1'
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span class="flex min-w-0 flex-1 items-center gap-1">
|
||||
<button
|
||||
v-tooltip.top="{
|
||||
value: item.displayDetails || undefined,
|
||||
showDelay: 300
|
||||
}"
|
||||
type="button"
|
||||
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-xs/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
|
||||
@click="handleLocateNode(item.nodeId)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
<Button
|
||||
v-if="item.displayDetails"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:class="
|
||||
cn(
|
||||
'size-6 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset',
|
||||
isExecutionItemDetailExpanded(item.key) &&
|
||||
'bg-secondary-background-selected text-base-foreground hover:bg-secondary-background-selected'
|
||||
)
|
||||
"
|
||||
:aria-label="
|
||||
t('rightSidePanel.infoFor', { item: item.label })
|
||||
"
|
||||
:aria-controls="getExecutionItemDetailId(item.key)"
|
||||
:aria-expanded="isExecutionItemDetailExpanded(item.key)"
|
||||
@click.stop="toggleExecutionItemDetail(item.key)"
|
||||
>
|
||||
<i class="icon-[lucide--info] size-3.5" />
|
||||
</Button>
|
||||
</span>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
|
||||
:aria-label="
|
||||
t('rightSidePanel.locateNodeFor', {
|
||||
item: item.label
|
||||
})
|
||||
"
|
||||
@click.stop="handleLocateNode(item.nodeId)"
|
||||
>
|
||||
<i class="icon-[lucide--locate] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<TransitionCollapse>
|
||||
<p
|
||||
v-if="
|
||||
item.displayDetails &&
|
||||
isExecutionItemDetailExpanded(item.key)
|
||||
"
|
||||
:id="getExecutionItemDetailId(item.key)"
|
||||
class="m-0 mt-0.5 pr-10 text-2xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
>
|
||||
{{ item.displayDetails }}
|
||||
</p>
|
||||
</TransitionCollapse>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-else-if="group.type === 'execution'" class="space-y-3 px-3">
|
||||
<ErrorNodeCard
|
||||
v-for="card in group.cards"
|
||||
:key="card.id"
|
||||
:card="card"
|
||||
:aria-current="isCardInSelection(card.id) ? 'true' : undefined"
|
||||
:class="
|
||||
cn(
|
||||
selectionEmphasisClass(isCardInSelection(card.id)),
|
||||
isCardInSelection(card.id) && '-my-1 py-1'
|
||||
)
|
||||
"
|
||||
@locate-node="handleLocateNode"
|
||||
@copy-to-clipboard="copyToClipboard"
|
||||
@locate-node="handleLocateNode"
|
||||
@copy-to-clipboard="copyToClipboard"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Missing Models -->
|
||||
<MissingModelCard
|
||||
v-if="group.type === 'missing_model'"
|
||||
:missing-model-groups="missingModelGroups"
|
||||
:highlighted-node-ids="selectionMatchedAssetNodeIds"
|
||||
@locate-model="handleLocateAssetNode"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Missing Models -->
|
||||
<MissingModelCard
|
||||
v-if="group.type === 'missing_model'"
|
||||
:missing-model-groups="missingModelGroups"
|
||||
:highlighted-node-ids="selectionMatchedAssetNodeIds"
|
||||
@locate-model="handleLocateAssetNode"
|
||||
/>
|
||||
<!-- Missing Media -->
|
||||
<MissingMediaCard
|
||||
v-if="group.type === 'missing_media'"
|
||||
:missing-media-groups="missingMediaGroups"
|
||||
:highlighted-node-ids="selectionMatchedAssetNodeIds"
|
||||
@locate-node="handleLocateAssetNode"
|
||||
/>
|
||||
</ErrorCardSection>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
|
||||
<!-- Missing Media -->
|
||||
<MissingMediaCard
|
||||
v-if="group.type === 'missing_media'"
|
||||
:missing-media-groups="missingMediaGroups"
|
||||
:highlighted-node-ids="selectionMatchedAssetNodeIds"
|
||||
@locate-node="handleLocateAssetNode"
|
||||
<!-- Carousel position indicator -->
|
||||
<div
|
||||
v-if="carousel && filteredGroups.length > 1"
|
||||
data-testid="error-carousel-dots"
|
||||
class="flex justify-center pt-0.5"
|
||||
>
|
||||
<button
|
||||
v-for="(group, index) in filteredGroups"
|
||||
:key="group.groupKey"
|
||||
type="button"
|
||||
:aria-label="group.displayTitle"
|
||||
:aria-current="index === activeSlide ? 'true' : undefined"
|
||||
class="flex size-6 cursor-pointer appearance-none items-center justify-center border-0 bg-transparent p-0"
|
||||
@click="scrollToSlide(index)"
|
||||
>
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'rounded-full transition-all',
|
||||
index === activeSlide ? 'size-2' : 'size-1.5',
|
||||
hasSelectionEmphasis &&
|
||||
selectionMatchedGroupKeys.has(group.groupKey)
|
||||
? 'bg-primary-background-hover'
|
||||
: index === activeSlide
|
||||
? 'bg-base-foreground'
|
||||
: 'bg-base-foreground/30'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</ErrorCardSection>
|
||||
</TransitionGroup>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useElementSize, useScroll } from '@vueuse/core'
|
||||
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
@@ -357,6 +433,12 @@ interface ExecutionItemListEntry {
|
||||
displayDetails?: string
|
||||
}
|
||||
|
||||
const { showSearch = true, carousel } = defineProps<{
|
||||
showSearch?: boolean
|
||||
/** Render error groups as horizontally swipeable cards (narrow screens). */
|
||||
carousel?: boolean
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
const { focusNode } = useFocusNode()
|
||||
@@ -484,6 +566,35 @@ const strip = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
// Matches the carousel track's gap-2 (8px) between slides
|
||||
const CAROUSEL_SLIDE_GAP_PX = 8
|
||||
const carouselRef = useTemplateRef<HTMLElement>('carouselRef')
|
||||
const { x: carouselScrollX } = useScroll(carouselRef)
|
||||
const { width: carouselWidth } = useElementSize(carouselRef)
|
||||
|
||||
const activeSlide = computed(() => {
|
||||
if (carouselWidth.value === 0) return 0
|
||||
const stride = carouselWidth.value + CAROUSEL_SLIDE_GAP_PX
|
||||
return Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
filteredGroups.value.length - 1,
|
||||
Math.round(carouselScrollX.value / stride)
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
function scrollToSlide(index: number) {
|
||||
const track = carouselRef.value
|
||||
if (!track) return
|
||||
// clientWidth, not the ResizeObserver-driven carouselWidth: on the
|
||||
// immediate-watch mount path the observer has not delivered yet
|
||||
track.scrollTo({
|
||||
left: index * (track.clientWidth + CAROUSEL_SLIDE_GAP_PX),
|
||||
behavior: 'smooth'
|
||||
})
|
||||
}
|
||||
|
||||
function isCardInSelection(cardId: string): boolean {
|
||||
return selectionMatchedCardIds.value.has(cardId)
|
||||
}
|
||||
@@ -498,23 +609,32 @@ const selectionEmphasisSignature = computed(() =>
|
||||
: ''
|
||||
)
|
||||
|
||||
/**
|
||||
* Selection acts as emphasis, not a filter: expand the groups containing
|
||||
* the selected nodes' errors and collapse the rest. When the emphasis ends
|
||||
* (selection cleared or moved to a node without errors), re-expand all
|
||||
* groups so the tab reads as the workflow overview again.
|
||||
*/
|
||||
// Emphasis per layout: the list expands matched groups and collapses the
|
||||
// rest; the carousel never collapses (a collapsed slide reads as an empty
|
||||
// card) and snaps to the first matched slide instead.
|
||||
watch(
|
||||
selectionEmphasisSignature,
|
||||
(signature, previousSignature) => {
|
||||
if (!signature) {
|
||||
if (!previousSignature) return
|
||||
// Restore regardless of layout: collapse state persists across the
|
||||
// carousel (which ignores it), so a stale emphasis must not resurface
|
||||
// when the panel returns to the list layout.
|
||||
for (const groupKey of Object.keys(collapseState)) {
|
||||
setSectionCollapsed(groupKey, false)
|
||||
}
|
||||
return
|
||||
}
|
||||
const matchedKeys = selectionMatchedGroupKeys.value
|
||||
if (carousel) {
|
||||
const matchedIndex = filteredGroups.value.findIndex((group) =>
|
||||
matchedKeys.has(group.groupKey)
|
||||
)
|
||||
if (matchedIndex < 0) return
|
||||
// nextTick: with immediate:true the carousel element isn't mounted yet
|
||||
void nextTick(() => scrollToSlide(matchedIndex))
|
||||
return
|
||||
}
|
||||
for (const group of allErrorGroups.value) {
|
||||
setSectionCollapsed(group.groupKey, !matchedKeys.has(group.groupKey))
|
||||
}
|
||||
|
||||
@@ -14,27 +14,20 @@ import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import ColorPickerPanel from './ColorPickerPanel.vue'
|
||||
|
||||
const { alpha = true } = defineProps<{
|
||||
defineProps<{
|
||||
class?: string
|
||||
disabled?: boolean
|
||||
alpha?: boolean
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<string>({ default: '#000000' })
|
||||
|
||||
function readHsva(hex: string): HSVA {
|
||||
const next = hexToHsva(hex || '#000000')
|
||||
if (!alpha) next.a = 100
|
||||
return next
|
||||
}
|
||||
|
||||
const hsva = ref<HSVA>(readHsva(modelValue.value))
|
||||
const hsva = ref<HSVA>(hexToHsva(modelValue.value || '#000000'))
|
||||
const displayMode = ref<'hex' | 'rgba'>('hex')
|
||||
|
||||
watch(modelValue, (newVal) => {
|
||||
const current = hsvaToHex(hsva.value)
|
||||
if (newVal !== current) {
|
||||
hsva.value = readHsva(newVal)
|
||||
hsva.value = hexToHsva(newVal || '#000000')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -74,51 +67,49 @@ const contentStyle = useModalLiftedZIndex(isOpen)
|
||||
<template>
|
||||
<PopoverRoot v-model:open="isOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<slot name="trigger">
|
||||
<button
|
||||
type="button"
|
||||
:disabled="$props.disabled"
|
||||
:class="
|
||||
cn(
|
||||
'flex h-8 w-full items-center overflow-clip rounded-lg border border-transparent bg-component-node-widget-background pr-2 outline-none hover:bg-component-node-widget-background-hovered disabled:cursor-not-allowed disabled:opacity-50',
|
||||
isOpen && 'border-node-stroke',
|
||||
$props.class
|
||||
)
|
||||
"
|
||||
<button
|
||||
type="button"
|
||||
:disabled="$props.disabled"
|
||||
:class="
|
||||
cn(
|
||||
'flex h-8 w-full items-center overflow-clip rounded-lg border border-transparent bg-component-node-widget-background pr-2 outline-none hover:bg-component-node-widget-background-hovered disabled:cursor-not-allowed disabled:opacity-50',
|
||||
isOpen && 'border-node-stroke',
|
||||
$props.class
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="flex size-8 shrink-0 items-center justify-center">
|
||||
<div class="relative size-4 overflow-hidden rounded-sm">
|
||||
<div
|
||||
class="absolute inset-0"
|
||||
:style="{
|
||||
backgroundImage:
|
||||
'repeating-conic-gradient(#808080 0% 25%, transparent 0% 50%)',
|
||||
backgroundSize: '4px 4px'
|
||||
}"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0"
|
||||
:style="{ backgroundColor: previewColor }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-1 items-center justify-between pl-1 text-xs text-component-node-foreground"
|
||||
>
|
||||
<div class="flex size-8 shrink-0 items-center justify-center">
|
||||
<div class="relative size-4 overflow-hidden rounded-sm">
|
||||
<div
|
||||
class="absolute inset-0"
|
||||
:style="{
|
||||
backgroundImage:
|
||||
'repeating-conic-gradient(#808080 0% 25%, transparent 0% 50%)',
|
||||
backgroundSize: '4px 4px'
|
||||
}"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0"
|
||||
:style="{ backgroundColor: previewColor }"
|
||||
/>
|
||||
<template v-if="displayMode === 'hex'">
|
||||
<span>{{ displayHex }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex gap-2">
|
||||
<span>{{ baseRgb.r }}</span>
|
||||
<span>{{ baseRgb.g }}</span>
|
||||
<span>{{ baseRgb.b }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-1 items-center justify-between pl-1 text-xs text-component-node-foreground"
|
||||
>
|
||||
<template v-if="displayMode === 'hex'">
|
||||
<span>{{ displayHex }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex gap-2">
|
||||
<span>{{ baseRgb.r }}</span>
|
||||
<span>{{ baseRgb.g }}</span>
|
||||
<span>{{ baseRgb.b }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<span>{{ hsva.a }}%</span>
|
||||
</div>
|
||||
</button>
|
||||
</slot>
|
||||
</template>
|
||||
<span>{{ hsva.a }}%</span>
|
||||
</div>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverPortal>
|
||||
<PopoverContent
|
||||
@@ -132,7 +123,6 @@ const contentStyle = useModalLiftedZIndex(isOpen)
|
||||
<ColorPickerPanel
|
||||
v-model:hsva="hsva"
|
||||
v-model:display-mode="displayMode"
|
||||
:alpha
|
||||
/>
|
||||
</PopoverContent>
|
||||
</PopoverPortal>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user