mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-28 17:05:14 +00:00
Compare commits
1 Commits
fix/career
...
glary/fix-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e10cf97ee |
@@ -133,28 +133,30 @@ function scrollToDepartment(deptKey: string) {
|
||||
:href="role.jobUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="border-primary-warm-gray/20 group flex items-start justify-between gap-4 border-b py-5"
|
||||
class="border-primary-warm-gray/20 group flex items-center justify-between border-b py-5"
|
||||
data-testid="careers-role-link"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div
|
||||
<div class="min-w-0">
|
||||
<span
|
||||
class="text-primary-comfy-canvas text-base font-medium md:text-lg"
|
||||
>
|
||||
{{ role.title }}
|
||||
</div>
|
||||
<div
|
||||
class="text-primary-warm-gray mt-1 flex flex-wrap gap-x-4 gap-y-1 text-sm"
|
||||
>
|
||||
<span>{{ role.department }}</span>
|
||||
<span>{{ role.location }}</span>
|
||||
</div>
|
||||
</span>
|
||||
<span class="text-primary-warm-gray ml-3 text-sm">
|
||||
{{ role.department }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="ml-4 flex shrink-0 items-center gap-3">
|
||||
<span class="text-primary-warm-gray text-sm">
|
||||
{{ role.location }}
|
||||
</span>
|
||||
<img
|
||||
src="/icons/arrow-up-right.svg"
|
||||
alt=""
|
||||
class="size-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<img
|
||||
src="/icons/arrow-up-right.svg"
|
||||
alt=""
|
||||
class="mt-1 size-5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
type NavDropdownItem = {
|
||||
export type NavDropdownItem = {
|
||||
label: string
|
||||
href: string
|
||||
badge?: string
|
||||
|
||||
@@ -32,7 +32,7 @@ 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) : ''
|
||||
const githubStars = rawStars !== null ? formatStarCount(rawStars) : ''
|
||||
|
||||
const gtmId = 'GTM-NP9JM6K7'
|
||||
const gtmEnabled = import.meta.env.PROD
|
||||
|
||||
@@ -14,7 +14,7 @@ const DEFAULT_BASE_URL = 'https://api.ashbyhq.com'
|
||||
const DEFAULT_TIMEOUT_MS = 10_000
|
||||
const RETRY_DELAYS_MS = [1_000, 2_000, 4_000]
|
||||
|
||||
interface DroppedRole {
|
||||
export interface DroppedRole {
|
||||
title: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ const DEFAULT_BASE_URL = 'https://cloud.comfy.org'
|
||||
const DEFAULT_TIMEOUT_MS = 10_000
|
||||
const RETRY_DELAYS_MS = [1_000, 2_000, 4_000]
|
||||
|
||||
interface DroppedNode {
|
||||
export interface DroppedNode {
|
||||
name: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { fetchGitHubStars, formatStarCount } from './github'
|
||||
import {
|
||||
fetchGitHubStars,
|
||||
formatStarCount,
|
||||
resetGitHubStarsFetcherForTests
|
||||
} from './github'
|
||||
|
||||
describe('fetchGitHubStars', () => {
|
||||
const savedOverride = process.env.WEBSITE_GITHUB_STARS_OVERRIDE
|
||||
|
||||
afterEach(() => {
|
||||
resetGitHubStarsFetcherForTests()
|
||||
vi.restoreAllMocks()
|
||||
if (savedOverride === undefined)
|
||||
delete process.env.WEBSITE_GITHUB_STARS_OVERRIDE
|
||||
@@ -27,6 +32,67 @@ describe('fetchGitHubStars', () => {
|
||||
'WEBSITE_GITHUB_STARS_OVERRIDE must be a non-negative integer'
|
||||
)
|
||||
})
|
||||
|
||||
it('memoizes concurrent fetches for the same repo to one network call', async () => {
|
||||
const fetchImpl = vi.fn(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ stargazers_count: 110000 }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' }
|
||||
})
|
||||
)
|
||||
|
||||
const [a, b, c] = await Promise.all([
|
||||
fetchGitHubStars('Comfy-Org', 'ComfyUI', fetchImpl as typeof fetch),
|
||||
fetchGitHubStars('Comfy-Org', 'ComfyUI', fetchImpl as typeof fetch),
|
||||
fetchGitHubStars('Comfy-Org', 'ComfyUI', fetchImpl as typeof fetch)
|
||||
])
|
||||
|
||||
expect(a).toBe(110000)
|
||||
expect(b).toBe(110000)
|
||||
expect(c).toBe(110000)
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keys the in-flight cache by owner/repo', async () => {
|
||||
const fetchImpl = vi.fn(async (url: string | URL | Request) => {
|
||||
const href = typeof url === 'string' ? url : url.toString()
|
||||
const count = href.includes('other-repo') ? 42 : 110000
|
||||
return new Response(JSON.stringify({ stargazers_count: count }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' }
|
||||
})
|
||||
})
|
||||
|
||||
const [comfy, other] = await Promise.all([
|
||||
fetchGitHubStars('Comfy-Org', 'ComfyUI', fetchImpl as typeof fetch),
|
||||
fetchGitHubStars('Comfy-Org', 'other-repo', fetchImpl as typeof fetch)
|
||||
])
|
||||
|
||||
expect(comfy).toBe(110000)
|
||||
expect(other).toBe(42)
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('returns null when GitHub responds non-2xx', async () => {
|
||||
const fetchImpl = vi.fn(
|
||||
async () => new Response('rate limited', { status: 403 })
|
||||
)
|
||||
|
||||
await expect(
|
||||
fetchGitHubStars('Comfy-Org', 'ComfyUI', fetchImpl as typeof fetch)
|
||||
).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when fetch throws', async () => {
|
||||
const fetchImpl = vi.fn(async () => {
|
||||
throw new Error('network down')
|
||||
})
|
||||
|
||||
await expect(
|
||||
fetchGitHubStars('Comfy-Org', 'ComfyUI', fetchImpl as typeof fetch)
|
||||
).resolves.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatStarCount', () => {
|
||||
|
||||
@@ -1,22 +1,50 @@
|
||||
const inflight = new Map<string, Promise<number | null>>()
|
||||
|
||||
export function resetGitHubStarsFetcherForTests(): void {
|
||||
inflight.clear()
|
||||
}
|
||||
|
||||
export async function fetchGitHubStars(
|
||||
owner: string,
|
||||
repo: string
|
||||
repo: string,
|
||||
fetchImpl: typeof fetch = fetch
|
||||
): Promise<number | null> {
|
||||
const override = readGitHubStarsOverride()
|
||||
if (override !== undefined) return override
|
||||
|
||||
const key = `${owner}/${repo}`
|
||||
const cached = inflight.get(key)
|
||||
if (cached) return cached
|
||||
|
||||
const request = doFetch(owner, repo, fetchImpl)
|
||||
inflight.set(key, request)
|
||||
return request
|
||||
}
|
||||
|
||||
async function doFetch(
|
||||
owner: string,
|
||||
repo: string,
|
||||
fetchImpl: typeof fetch
|
||||
): Promise<number | null> {
|
||||
try {
|
||||
const res = await fetch(`https://api.github.com/repos/${owner}/${repo}`, {
|
||||
headers: { Accept: 'application/vnd.github.v3+json' }
|
||||
})
|
||||
const res = await fetchImpl(
|
||||
`https://api.github.com/repos/${owner}/${repo}`,
|
||||
{ headers: { Accept: 'application/vnd.github.v3+json' } }
|
||||
)
|
||||
if (!res.ok) return null
|
||||
const data = await res.json()
|
||||
return data.stargazers_count ?? null
|
||||
const data: unknown = await res.json()
|
||||
return readStargazerCount(data)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function readStargazerCount(data: unknown): number | null {
|
||||
if (data === null || typeof data !== 'object') return null
|
||||
const count = (data as { stargazers_count?: unknown }).stargazers_count
|
||||
return typeof count === 'number' ? count : null
|
||||
}
|
||||
|
||||
export function formatStarCount(count: number): string {
|
||||
if (count >= 1_000_000) {
|
||||
const m = count / 1_000_000
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 24 KiB |
@@ -1,87 +0,0 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
async function waitForRootCanvasReady(page: Page) {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const state = await page.evaluate(() => ({
|
||||
rootId: window.app?.rootGraph?.id ?? '',
|
||||
canvasGraphId: window.app?.canvas?.graph?.id ?? ''
|
||||
}))
|
||||
return state.rootId !== '' && state.canvasGraphId === state.rootId
|
||||
})
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
async function expectCanvasOnRootGraph(page: Page) {
|
||||
await expect
|
||||
.poll(async () =>
|
||||
page.evaluate(() => ({
|
||||
rootId: window.app!.rootGraph.id,
|
||||
canvasGraphId: window.app!.canvas.graph?.id,
|
||||
hash: window.location.hash
|
||||
}))
|
||||
)
|
||||
.toEqual({
|
||||
rootId: expect.any(String),
|
||||
canvasGraphId: expect.stringMatching(/.+/),
|
||||
hash: expect.stringMatching(/^#.+/)
|
||||
})
|
||||
const state = await page.evaluate(() => ({
|
||||
rootId: window.app!.rootGraph.id,
|
||||
canvasGraphId: window.app!.canvas.graph?.id,
|
||||
hash: window.location.hash
|
||||
}))
|
||||
expect(state.canvasGraphId).toBe(state.rootId)
|
||||
expect(state.hash).toBe(`#${state.rootId}`)
|
||||
}
|
||||
|
||||
test.describe(
|
||||
'Subgraph hash validation (FE-559)',
|
||||
{ tag: ['@subgraph'] },
|
||||
() => {
|
||||
test('redirects URL and canvas to root for a non-existent subgraph hash', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await waitForRootCanvasReady(comfyPage.page)
|
||||
const rootId = await comfyPage.page.evaluate(
|
||||
() => window.app!.rootGraph.id
|
||||
)
|
||||
const phantomId = '11111111-1111-4111-8111-111111111111'
|
||||
expect(phantomId).not.toBe(rootId)
|
||||
|
||||
await comfyPage.page.evaluate((hash) => {
|
||||
window.location.hash = hash
|
||||
}, `#${phantomId}`)
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.page.evaluate(() => window.location.hash), {
|
||||
timeout: 5000
|
||||
})
|
||||
.toBe(`#${rootId}`)
|
||||
await expectCanvasOnRootGraph(comfyPage.page)
|
||||
})
|
||||
|
||||
test('redirects URL and canvas to root when hash is malformed', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await waitForRootCanvasReady(comfyPage.page)
|
||||
const rootId = await comfyPage.page.evaluate(
|
||||
() => window.app!.rootGraph.id
|
||||
)
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.location.hash = '#not-a-valid-uuid'
|
||||
})
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.page.evaluate(() => window.location.hash), {
|
||||
timeout: 5000
|
||||
})
|
||||
.toBe(`#${rootId}`)
|
||||
await expectCanvasOnRootGraph(comfyPage.page)
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -43,6 +43,7 @@ const config: KnipConfig = {
|
||||
'@iconify/json',
|
||||
'@primeuix/forms',
|
||||
'@primeuix/styled',
|
||||
'@primeuix/utils',
|
||||
'@primevue/icons'
|
||||
],
|
||||
ignore: [
|
||||
|
||||
@@ -97,7 +97,7 @@
|
||||
"axios": "catalog:",
|
||||
"chart.js": "^4.5.0",
|
||||
"cva": "catalog:",
|
||||
"dompurify": "catalog:",
|
||||
"dompurify": "^3.2.5",
|
||||
"dotenv": "catalog:",
|
||||
"es-toolkit": "^1.39.9",
|
||||
"extendable-media-recorder": "^9.2.27",
|
||||
@@ -193,7 +193,7 @@
|
||||
"unplugin-icons": "catalog:",
|
||||
"unplugin-typegpu": "catalog:",
|
||||
"unplugin-vue-components": "catalog:",
|
||||
"uuid": "catalog:",
|
||||
"uuid": "^11.1.0",
|
||||
"vite": "catalog:",
|
||||
"vite-plugin-dts": "catalog:",
|
||||
"vite-plugin-html": "catalog:",
|
||||
|
||||
@@ -1892,17 +1892,3 @@ audio.comfy-audio.empty-audio-widget {
|
||||
300% 14px;
|
||||
background-attachment: local, local, scroll, scroll;
|
||||
}
|
||||
|
||||
/*
|
||||
PrimeVue overlays teleport to body. When a Reka modal dialog is open it sets
|
||||
body { pointer-events: none } via DismissableLayer, which propagates to the
|
||||
body-portaled overlays and makes them unclickable. PrimeVue's own Dialog
|
||||
sets pointer-events inline, but Select / ColorPicker / Popover / Autocomplete
|
||||
overlays do not, so they need to opt in here.
|
||||
*/
|
||||
.p-select-overlay,
|
||||
.p-colorpicker-panel,
|
||||
.p-popover,
|
||||
.p-autocomplete-overlay {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
1683
pnpm-lock.yaml
generated
1683
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -17,7 +17,7 @@ catalog:
|
||||
'@formkit/auto-animate': ^0.9.0
|
||||
'@iconify-json/lucide': ^1.1.178
|
||||
'@iconify/json': ^2.2.380
|
||||
'@iconify/tailwind4': ^1.2.3
|
||||
'@iconify/tailwind4': ^1.2.0
|
||||
'@iconify/utils': ^3.1.0
|
||||
'@intlify/eslint-plugin-vue-i18n': ^4.1.1
|
||||
'@lobehub/i18n-cli': ^1.26.1
|
||||
@@ -66,10 +66,10 @@ catalog:
|
||||
'@webgpu/types': ^0.1.66
|
||||
algoliasearch: ^5.21.0
|
||||
astro: ^5.10.0
|
||||
axios: ^1.15.2
|
||||
axios: ^1.13.5
|
||||
cross-env: ^10.1.0
|
||||
cva: 1.0.0-beta.4
|
||||
dompurify: ^3.4.5
|
||||
dompurify: ^3.3.1
|
||||
dotenv: ^16.4.5
|
||||
eslint: ^9.39.1
|
||||
eslint-config-prettier: ^10.1.8
|
||||
@@ -87,12 +87,12 @@ catalog:
|
||||
glob: ^13.0.6
|
||||
globals: ^16.5.0
|
||||
gsap: ^3.14.2
|
||||
happy-dom: ^20.8.9
|
||||
happy-dom: ^20.0.11
|
||||
husky: ^9.1.7
|
||||
jiti: 2.6.1
|
||||
jsdom: ^27.4.0
|
||||
jsonata: ^2.1.0
|
||||
knip: ^6.14.1
|
||||
knip: ^6.3.1
|
||||
lenis: ^1.3.21
|
||||
lint-staged: ^16.2.7
|
||||
markdown-table: ^3.0.4
|
||||
@@ -108,13 +108,13 @@ catalog:
|
||||
pretty-bytes: ^7.1.0
|
||||
primeicons: ^7.0.0
|
||||
primevue: ^4.2.5
|
||||
reka-ui: 2.5.0
|
||||
reka-ui: ^2.5.0
|
||||
rollup-plugin-visualizer: ^6.0.4
|
||||
storybook: ^10.2.10
|
||||
stylelint: ^16.26.1
|
||||
tailwindcss: ^4.3.0
|
||||
tailwindcss-primeui: ^0.6.1
|
||||
three: ^0.184.0
|
||||
tailwindcss-primeui: ^0.6.1
|
||||
tsx: ^4.15.6
|
||||
tw-animate-css: ^1.3.8
|
||||
typegpu: ^0.8.2
|
||||
@@ -123,14 +123,13 @@ catalog:
|
||||
unplugin-icons: ^22.5.0
|
||||
unplugin-typegpu: 0.8.0
|
||||
unplugin-vue-components: ^30.0.0
|
||||
uuid: ^11.1.1
|
||||
vee-validate: ^4.15.1
|
||||
vite: ^8.0.13
|
||||
vite: ^8.0.0
|
||||
vite-plugin-dts: ^4.5.4
|
||||
vite-plugin-html: ^3.2.2
|
||||
vite-plugin-vue-devtools: ^8.0.0
|
||||
vitest: ^4.0.16
|
||||
vue: ^3.5.34
|
||||
vue: ^3.5.13
|
||||
vue-component-type-helpers: ^3.2.1
|
||||
vue-eslint-parser: ^10.4.0
|
||||
vue-i18n: ^9.14.5
|
||||
@@ -161,13 +160,3 @@ overrides:
|
||||
vite: 'catalog:'
|
||||
'@tiptap/pm': 2.27.2
|
||||
'@types/eslint': '-'
|
||||
protobufjs: ~7.6.0
|
||||
flatted: ~3.4.2
|
||||
defu: ~6.1.7
|
||||
# Security overrides (see pnpm.overrides in package.json for the actual pins):
|
||||
# protobufjs ~7.6.0 — CVE-2026-41242 (CVSS 9.8): arbitrary code execution.
|
||||
# Transitive via firebase, posthog-js. Remove after firebase upgrades protobufjs.
|
||||
# flatted ~3.4.2 — GHSA-x7hr-w5r2-h6qg: prototype pollution.
|
||||
# Transitive via eslint flat-cache@4.0.1. Dev-only. Remove after eslint upgrades flat-cache.
|
||||
# defu ~6.1.7 — GHSA-47f6-5gq3-vx9c: prototype pollution.
|
||||
# Transitive via reka-ui, c12, unplugin-typegpu. Remove after reka-ui upgrades defu.
|
||||
|
||||
@@ -42,8 +42,7 @@ import type { StyleValue } from 'vue'
|
||||
|
||||
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
|
||||
import { useMediaCache } from '@/services/mediaCacheService'
|
||||
|
||||
type ClassValue = string | Record<string, boolean> | ClassValue[]
|
||||
import type { ClassValue } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const {
|
||||
src,
|
||||
|
||||
@@ -8,10 +8,6 @@ import { defineComponent, h } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
|
||||
import {
|
||||
onRekaFocusOutside,
|
||||
onRekaPointerDownOutside
|
||||
} from '@/components/dialog/rekaPrimeVueBridge'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const i18n = createI18n({
|
||||
@@ -194,88 +190,3 @@ describe('GlobalDialog Reka parity with PrimeVue', () => {
|
||||
expect(store.isDialogOpen('reka-esc-blocked')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldPreventRekaDismiss', () => {
|
||||
function makeEvent(target: Element | null) {
|
||||
let prevented = false
|
||||
return {
|
||||
detail: { originalEvent: { target } },
|
||||
preventDefault: () => {
|
||||
prevented = true
|
||||
},
|
||||
get defaultPrevented() {
|
||||
return prevented
|
||||
}
|
||||
} as unknown as CustomEvent<{ originalEvent: PointerEvent }> & {
|
||||
defaultPrevented: boolean
|
||||
}
|
||||
}
|
||||
|
||||
it.for([
|
||||
'p-select-overlay',
|
||||
'p-colorpicker-panel',
|
||||
'p-popover',
|
||||
'p-autocomplete-overlay',
|
||||
'p-overlay-mask',
|
||||
'p-dialog'
|
||||
])('prevents dismiss when target is inside %s', (className) => {
|
||||
const overlay = document.createElement('div')
|
||||
overlay.className = className
|
||||
const inner = document.createElement('button')
|
||||
overlay.appendChild(inner)
|
||||
document.body.appendChild(overlay)
|
||||
|
||||
const event = makeEvent(inner)
|
||||
onRekaPointerDownOutside({ dismissableMask: undefined }, event)
|
||||
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
overlay.remove()
|
||||
})
|
||||
|
||||
it('allows dismiss when target is outside any PrimeVue overlay', () => {
|
||||
const event = makeEvent(document.body)
|
||||
onRekaPointerDownOutside({ dismissableMask: undefined }, event)
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
})
|
||||
|
||||
it('prevents dismiss when dismissableMask is false even outside an overlay', () => {
|
||||
const event = makeEvent(document.body)
|
||||
onRekaPointerDownOutside({ dismissableMask: false }, event)
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
})
|
||||
|
||||
it.for(['p-dialog', 'p-select-overlay'])(
|
||||
'focus-outside on a sibling %s portal does not dismiss the parent',
|
||||
(className) => {
|
||||
const overlay = document.createElement('div')
|
||||
overlay.className = className
|
||||
const inner = document.createElement('button')
|
||||
overlay.appendChild(inner)
|
||||
document.body.appendChild(overlay)
|
||||
|
||||
const event = makeEvent(inner)
|
||||
onRekaFocusOutside(event)
|
||||
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
overlay.remove()
|
||||
}
|
||||
)
|
||||
|
||||
it('focus-outside still dismisses when focus moves to a non-portal element', () => {
|
||||
const event = makeEvent(document.body)
|
||||
onRekaFocusOutside(event)
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
})
|
||||
|
||||
it('focus-outside on a sibling Reka portal does not dismiss the parent', () => {
|
||||
const portal = document.createElement('div')
|
||||
portal.setAttribute('role', 'dialog')
|
||||
document.body.appendChild(portal)
|
||||
|
||||
const event = makeEvent(portal)
|
||||
onRekaFocusOutside(event)
|
||||
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
portal.remove()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,14 +8,9 @@
|
||||
@update:open="(open) => onRekaOpenChange(item.key, open)"
|
||||
>
|
||||
<DialogPortal>
|
||||
<DialogOverlay
|
||||
v-reka-z-index
|
||||
:class="item.dialogComponentProps.overlayClass"
|
||||
/>
|
||||
<DialogOverlay />
|
||||
<DialogContent
|
||||
v-reka-z-index
|
||||
:size="item.dialogComponentProps.size ?? 'md'"
|
||||
:maximized="!!item.dialogComponentProps.maximized"
|
||||
:class="item.dialogComponentProps.contentClass"
|
||||
:aria-labelledby="item.key"
|
||||
@escape-key-down="
|
||||
@@ -24,51 +19,34 @@
|
||||
e.preventDefault()
|
||||
"
|
||||
@pointer-down-outside="
|
||||
(e) => onRekaPointerDownOutside(item.dialogComponentProps, e)
|
||||
(e) =>
|
||||
item.dialogComponentProps.dismissableMask === false &&
|
||||
e.preventDefault()
|
||||
"
|
||||
@focus-outside="onRekaFocusOutside"
|
||||
@mousedown="() => dialogStore.riseDialog({ key: item.key })"
|
||||
>
|
||||
<template v-if="item.dialogComponentProps.headless">
|
||||
<DialogHeader v-if="!item.dialogComponentProps.headless">
|
||||
<component
|
||||
:is="item.headerComponent"
|
||||
v-if="item.headerComponent"
|
||||
v-bind="item.headerProps"
|
||||
:id="item.key"
|
||||
/>
|
||||
<DialogTitle v-else :id="item.key">
|
||||
{{ item.title || ' ' }}
|
||||
</DialogTitle>
|
||||
<DialogClose v-if="item.dialogComponentProps.closable !== false" />
|
||||
</DialogHeader>
|
||||
<div class="flex-1 overflow-auto px-4 py-2">
|
||||
<component
|
||||
:is="item.component"
|
||||
v-bind="item.contentProps"
|
||||
:maximized="item.dialogComponentProps.maximized"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<DialogHeader>
|
||||
<component
|
||||
:is="item.headerComponent"
|
||||
v-if="item.headerComponent"
|
||||
v-bind="item.headerProps"
|
||||
:id="item.key"
|
||||
/>
|
||||
<DialogTitle v-else :id="item.key">
|
||||
{{ item.title || ' ' }}
|
||||
</DialogTitle>
|
||||
<div class="flex items-center gap-1">
|
||||
<DialogMaximize
|
||||
v-if="item.dialogComponentProps.maximizable"
|
||||
:maximized="!!item.dialogComponentProps.maximized"
|
||||
@toggle="toggleMaximize(item)"
|
||||
/>
|
||||
<DialogClose
|
||||
v-if="item.dialogComponentProps.closable !== false"
|
||||
/>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<div class="flex-1 overflow-auto px-4 py-2">
|
||||
<component
|
||||
:is="item.component"
|
||||
v-bind="item.contentProps"
|
||||
:maximized="item.dialogComponentProps.maximized"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter v-if="item.footerComponent">
|
||||
<component :is="item.footerComponent" v-bind="item.footerProps" />
|
||||
</DialogFooter>
|
||||
</template>
|
||||
</div>
|
||||
<DialogFooter v-if="item.footerComponent">
|
||||
<component :is="item.footerComponent" v-bind="item.footerProps" />
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
@@ -77,6 +55,7 @@
|
||||
v-model:visible="item.visible"
|
||||
class="global-dialog"
|
||||
v-bind="item.dialogComponentProps"
|
||||
:pt="getDialogPt(item)"
|
||||
:aria-labelledby="item.key"
|
||||
>
|
||||
<template #header>
|
||||
@@ -107,25 +86,29 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { merge } from 'es-toolkit/compat'
|
||||
import PrimeDialog from 'primevue/dialog'
|
||||
import type { DialogPassThroughOptions } from 'primevue/dialog'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import Dialog from '@/components/ui/dialog/Dialog.vue'
|
||||
import DialogClose from '@/components/ui/dialog/DialogClose.vue'
|
||||
import DialogContent from '@/components/ui/dialog/DialogContent.vue'
|
||||
import DialogFooter from '@/components/ui/dialog/DialogFooter.vue'
|
||||
import DialogHeader from '@/components/ui/dialog/DialogHeader.vue'
|
||||
import DialogMaximize from '@/components/ui/dialog/DialogMaximize.vue'
|
||||
import DialogOverlay from '@/components/ui/dialog/DialogOverlay.vue'
|
||||
import DialogPortal from '@/components/ui/dialog/DialogPortal.vue'
|
||||
import DialogTitle from '@/components/ui/dialog/DialogTitle.vue'
|
||||
import {
|
||||
onRekaFocusOutside,
|
||||
onRekaPointerDownOutside
|
||||
} from '@/components/dialog/rekaPrimeVueBridge'
|
||||
import { vRekaZIndex } from '@/components/dialog/vRekaZIndex'
|
||||
import type { DialogInstance } from '@/stores/dialogStore'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import type { DialogComponentProps, DialogInstance } from '@/stores/dialogStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
const teamWorkspacesEnabled = computed(
|
||||
() => isCloud && flags.teamWorkspacesEnabled
|
||||
)
|
||||
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
function isRekaItem(item: DialogInstance) {
|
||||
@@ -136,8 +119,20 @@ function onRekaOpenChange(key: string, open: boolean) {
|
||||
if (!open) dialogStore.closeDialog({ key })
|
||||
}
|
||||
|
||||
function toggleMaximize(item: DialogInstance) {
|
||||
item.dialogComponentProps.maximized = !item.dialogComponentProps.maximized
|
||||
function getDialogPt(item: {
|
||||
key: string
|
||||
dialogComponentProps: DialogComponentProps
|
||||
}): DialogPassThroughOptions {
|
||||
const isWorkspaceSettingsDialog =
|
||||
item.key === 'global-settings' && teamWorkspacesEnabled.value
|
||||
const basePt = item.dialogComponentProps.pt || {}
|
||||
|
||||
if (isWorkspaceSettingsDialog) {
|
||||
return merge(basePt, {
|
||||
mask: { class: 'p-8' }
|
||||
})
|
||||
}
|
||||
return basePt
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -168,6 +163,19 @@ function toggleMaximize(item: DialogInstance) {
|
||||
}
|
||||
}
|
||||
|
||||
/* Workspace mode: wider settings dialog */
|
||||
.settings-dialog-workspace {
|
||||
width: 100%;
|
||||
max-width: 1440px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.settings-dialog-workspace .p-dialog-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.manager-dialog {
|
||||
height: 80vh;
|
||||
max-width: 1724px;
|
||||
|
||||
@@ -244,7 +244,7 @@
|
||||
<ContextMenuPortal>
|
||||
<ContextMenuContent
|
||||
:style="keybindingOverlayContentStyle"
|
||||
class="z-1800 min-w-56 rounded-lg border border-border-subtle bg-base-background px-2 py-3 shadow-interface"
|
||||
class="z-1200 min-w-56 rounded-lg border border-border-subtle bg-base-background px-2 py-3 shadow-interface"
|
||||
>
|
||||
<ContextMenuItem
|
||||
class="flex cursor-pointer items-center gap-2 rounded-sm px-3 py-2 text-sm text-text-primary outline-none select-none hover:bg-node-component-surface-hovered focus:bg-node-component-surface-hovered data-disabled:cursor-default data-disabled:opacity-50"
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
// PrimeVue overlays (Select, ColorPicker, Popover, Autocomplete, stacked
|
||||
// PrimeVue Dialogs) teleport to body. Reka treats clicks on body-portaled
|
||||
// elements as outside its dialog and would auto-dismiss on the first
|
||||
// interaction, tearing the overlay down mid-interaction. Treat any
|
||||
// PrimeVue overlay click as inside.
|
||||
const PRIMEVUE_OVERLAY_SELECTORS =
|
||||
'.p-select-overlay, .p-colorpicker-panel, .p-popover, .p-autocomplete-overlay, .p-overlay, .p-overlay-mask, .p-dialog'
|
||||
|
||||
// Reka portals its own dialogs / popovers / menus into the body too. When a
|
||||
// nested Reka layer opens on top of a non-modal parent, the parent's
|
||||
// DismissableLayer sees the focus shift / pointer-down as "outside" and would
|
||||
// dismiss itself. These selectors cover the portaled roots so we can treat
|
||||
// interactions on them as inside.
|
||||
const REKA_PORTAL_SELECTORS =
|
||||
'[data-reka-popper-content-wrapper], [data-reka-dialog-content], [data-reka-menu-content], [data-reka-context-menu-content], [role="dialog"], [role="menu"], [role="listbox"], [role="tooltip"]'
|
||||
|
||||
const OUTSIDE_LAYER_SELECTORS = `${PRIMEVUE_OVERLAY_SELECTORS}, ${REKA_PORTAL_SELECTORS}`
|
||||
|
||||
type OutsideEvent = CustomEvent<{ originalEvent: Event }>
|
||||
|
||||
function isInsideOverlay(target: EventTarget | null): boolean {
|
||||
return (
|
||||
target instanceof Element &&
|
||||
target.closest(OUTSIDE_LAYER_SELECTORS) !== null
|
||||
)
|
||||
}
|
||||
|
||||
export function onRekaPointerDownOutside(
|
||||
options: { dismissableMask?: boolean },
|
||||
event: OutsideEvent
|
||||
) {
|
||||
if (isInsideOverlay(event.detail.originalEvent.target)) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
if (options.dismissableMask === false) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
// Focus / interact-outside fires when focus moves to a sibling portal (a
|
||||
// nested Reka or PrimeVue dialog teleported to body). Without this guard a
|
||||
// non-modal Reka dialog would dismiss itself the moment a nested dialog
|
||||
// receives focus.
|
||||
export function onRekaFocusOutside(event: OutsideEvent) {
|
||||
if (isInsideOverlay(event.detail.originalEvent.target)) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { ZIndex } from '@primeuix/utils/zindex'
|
||||
import type { Directive } from 'vue'
|
||||
|
||||
// Both Reka and PrimeVue dialogs can appear at any depth in dialogStack, in
|
||||
// any order. PrimeVue auto-increments a per-key z-index counter so later
|
||||
// dialogs always cover earlier ones; Reka uses a static z-1700 class which
|
||||
// can lose to an already-open PrimeVue dialog. Registering Reka's content
|
||||
// element with the same ZIndex counter (key 'modal', base 1700) makes both
|
||||
// renderers share one stacking sequence: whichever dialog opens last wins.
|
||||
export const vRekaZIndex: Directive<HTMLElement> = {
|
||||
mounted(el) {
|
||||
ZIndex.set('modal', el, 1700)
|
||||
},
|
||||
beforeUnmount(el) {
|
||||
ZIndex.clear(el)
|
||||
}
|
||||
}
|
||||
@@ -10,13 +10,11 @@ import { dialogContentVariants } from './dialog.variants'
|
||||
|
||||
const {
|
||||
size,
|
||||
maximized = false,
|
||||
class: customClass = '',
|
||||
...restProps
|
||||
} = defineProps<
|
||||
DialogContentProps & {
|
||||
size?: DialogContentSize
|
||||
maximized?: boolean
|
||||
class?: HTMLAttributes['class']
|
||||
}
|
||||
>()
|
||||
@@ -28,7 +26,7 @@ const forwarded = useForwardPropsEmits(restProps, emits)
|
||||
<template>
|
||||
<DialogContent
|
||||
v-bind="forwarded"
|
||||
:class="cn(dialogContentVariants({ size, maximized }), customClass)"
|
||||
:class="cn(dialogContentVariants({ size }), customClass)"
|
||||
>
|
||||
<slot />
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const { maximized = false } = defineProps<{ maximized?: boolean }>()
|
||||
const emit = defineEmits<{ toggle: [] }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Button
|
||||
:aria-label="maximized ? t('g.restoreDialog') : t('g.maximizeDialog')"
|
||||
size="icon"
|
||||
variant="muted-textonly"
|
||||
@click="emit('toggle')"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
maximized ? 'icon-[lucide--minimize-2]' : 'icon-[lucide--maximize-2]'
|
||||
"
|
||||
/>
|
||||
</Button>
|
||||
</template>
|
||||
@@ -2,7 +2,7 @@ import type { VariantProps } from 'cva'
|
||||
import { cva } from 'cva'
|
||||
|
||||
export const dialogContentVariants = cva({
|
||||
base: 'fixed z-1700 flex flex-col rounded-lg border border-border-subtle bg-base-background shadow-lg outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
base: 'fixed top-1/2 left-1/2 z-1700 flex max-h-[85vh] w-[calc(100vw-1rem)] -translate-x-1/2 -translate-y-1/2 flex-col rounded-lg border border-border-subtle bg-base-background shadow-lg outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
variants: {
|
||||
size: {
|
||||
sm: 'sm:max-w-sm',
|
||||
@@ -10,19 +10,14 @@ export const dialogContentVariants = cva({
|
||||
lg: 'sm:max-w-3xl',
|
||||
xl: 'sm:max-w-5xl',
|
||||
full: 'sm:max-w-[calc(100vw-1rem)]'
|
||||
},
|
||||
maximized: {
|
||||
true: 'inset-2 top-2 left-2 size-auto max-h-none max-w-none sm:max-w-none',
|
||||
false: 'top-1/2 left-1/2 max-h-[85vh] w-[calc(100vw-1rem)] -translate-1/2'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'md',
|
||||
maximized: false
|
||||
size: 'md'
|
||||
}
|
||||
})
|
||||
|
||||
type DialogContentVariants = VariantProps<typeof dialogContentVariants>
|
||||
export type DialogContentVariants = VariantProps<typeof dialogContentVariants>
|
||||
|
||||
export type DialogContentSize = NonNullable<DialogContentVariants['size']>
|
||||
|
||||
|
||||
@@ -192,7 +192,6 @@ describe('useLoad3d', () => {
|
||||
rotation: { x: 0, y: 0, z: 0 },
|
||||
scale: { x: 1, y: 1, z: 1 }
|
||||
}),
|
||||
getModelInfo: vi.fn().mockReturnValue(null),
|
||||
captureThumbnail: vi.fn().mockResolvedValue('data:image/png;base64,test'),
|
||||
setAnimationTime: vi.fn(),
|
||||
renderer: {
|
||||
@@ -1355,46 +1354,6 @@ describe('useLoad3d', () => {
|
||||
expect(composable.modelConfig.value.gizmo!.mode).toBe('rotate')
|
||||
})
|
||||
|
||||
it('gizmoTransformChange mirrors the live scene into Scene Config models', async () => {
|
||||
const modelTransform = {
|
||||
uuid: 'abc',
|
||||
name: 'mesh',
|
||||
type: 'Mesh',
|
||||
position: { x: 5, y: 6, z: 7 },
|
||||
rotation: { x: 0.5, y: 0.6, z: 0.7, order: 'XYZ' },
|
||||
quaternion: { x: 0, y: 0, z: 0, w: 1 },
|
||||
scale: { x: 3, y: 3, z: 3 },
|
||||
up: { x: 0, y: 1, z: 0 },
|
||||
visible: true,
|
||||
matrix: new Array(16).fill(0)
|
||||
}
|
||||
vi.mocked(mockLoad3d.getModelInfo!).mockReturnValue(modelTransform)
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
const addEventCalls = vi.mocked(mockLoad3d.addEventListener!).mock.calls
|
||||
const handler = addEventCalls.find(
|
||||
([event]) => event === 'gizmoTransformChange'
|
||||
)![1] as (data: unknown) => void
|
||||
|
||||
handler({
|
||||
position: { x: 5, y: 6, z: 7 },
|
||||
rotation: { x: 0.5, y: 0.6, z: 0.7 },
|
||||
scale: { x: 3, y: 3, z: 3 },
|
||||
enabled: true,
|
||||
mode: 'rotate'
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
expect(composable.sceneConfig.value.models).toEqual([modelTransform])
|
||||
const savedScene = mockNode.properties['Scene Config'] as {
|
||||
models: unknown[]
|
||||
}
|
||||
expect(savedScene.models).toEqual([modelTransform])
|
||||
})
|
||||
|
||||
it('should reset gizmo config on model switch (not first load)', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
@@ -789,11 +789,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
}
|
||||
}
|
||||
|
||||
const syncSceneModels = () => {
|
||||
const modelInfo = load3d?.getModelInfo()
|
||||
sceneConfig.value.models = modelInfo ? [modelInfo] : []
|
||||
}
|
||||
|
||||
const eventConfig = {
|
||||
materialModeChange: (value: string) => {
|
||||
modelConfig.value.materialMode = value as MaterialMode
|
||||
@@ -865,7 +860,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
]
|
||||
hasSkeleton.value = load3d?.hasSkeleton() ?? false
|
||||
applyGizmoConfigToLoad3d()
|
||||
syncSceneModels()
|
||||
isFirstModelLoad = false
|
||||
},
|
||||
modelReady: () => {
|
||||
@@ -942,7 +936,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
modelConfig.value.gizmo.enabled = data.enabled
|
||||
modelConfig.value.gizmo.mode = data.mode
|
||||
}
|
||||
syncSceneModels()
|
||||
}
|
||||
} as const
|
||||
|
||||
@@ -968,7 +961,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
const transform = load3d.getGizmoTransform()
|
||||
modelConfig.value.gizmo.position = transform.position
|
||||
modelConfig.value.gizmo.scale = transform.scale
|
||||
syncSceneModels()
|
||||
}
|
||||
|
||||
const handleResetGizmoTransform = () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
|
||||
interface ResolvedPreviewChainStep {
|
||||
export interface ResolvedPreviewChainStep {
|
||||
rootGraphId: UUID
|
||||
hostNodeLocator: string
|
||||
exposure: PreviewExposure
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
|
||||
|
||||
import { parseNodePropertyArray } from './parseNodePropertyArray'
|
||||
|
||||
const previewExposureSchema = z.object({
|
||||
export const previewExposureSchema = z.object({
|
||||
name: z.string(),
|
||||
sourceNodeId: z.string(),
|
||||
sourcePreviewName: z.string()
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { TWidgetValue } from '@/lib/litegraph/src/types/widgets'
|
||||
import { parseNodePropertyArray } from './parseNodePropertyArray'
|
||||
import { serializedProxyWidgetTupleSchema } from './promotionSchema'
|
||||
|
||||
const proxyWidgetQuarantineReasonSchema = z.enum([
|
||||
export const proxyWidgetQuarantineReasonSchema = z.enum([
|
||||
'missingSourceNode',
|
||||
'missingSourceWidget',
|
||||
'missingSubgraphInput',
|
||||
@@ -18,7 +18,7 @@ export type ProxyWidgetQuarantineReason = z.infer<
|
||||
typeof proxyWidgetQuarantineReasonSchema
|
||||
>
|
||||
|
||||
const proxyWidgetErrorQuarantineEntrySchema = z.object({
|
||||
export const proxyWidgetErrorQuarantineEntrySchema = z.object({
|
||||
originalEntry: serializedProxyWidgetTupleSchema,
|
||||
reason: proxyWidgetQuarantineReasonSchema,
|
||||
hostValue: z.unknown().optional(),
|
||||
|
||||
@@ -6,8 +6,7 @@ import { nodeToLoad3dMap, useLoad3d } from '@/composables/useLoad3d'
|
||||
import { createExportMenuItems } from '@/extensions/core/load3d/exportMenuHelper'
|
||||
import type {
|
||||
CameraConfig,
|
||||
CameraState,
|
||||
ModelInfo
|
||||
CameraState
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
|
||||
import {
|
||||
@@ -403,9 +402,6 @@ useExtensionService().registerExtension({
|
||||
|
||||
currentLoad3d.handleResize()
|
||||
|
||||
const modelInfo = currentLoad3d.getModelInfo()
|
||||
const model_info: ModelInfo = modelInfo ? [modelInfo] : []
|
||||
|
||||
const returnVal = {
|
||||
image: `threed/${data.name} [temp]`,
|
||||
mask: `threed/${dataMask.name} [temp]`,
|
||||
@@ -413,8 +409,7 @@ useExtensionService().registerExtension({
|
||||
camera_info:
|
||||
(node.properties['Camera Config'] as CameraConfig | undefined)
|
||||
?.state || null,
|
||||
recording: '',
|
||||
model_info
|
||||
recording: ''
|
||||
}
|
||||
|
||||
const recordingData = currentLoad3d.getRecordingData()
|
||||
|
||||
@@ -162,57 +162,6 @@ describe('CameraManager', () => {
|
||||
const snapshot = manager.getCameraState()
|
||||
expect(snapshot.target.toArray()).toEqual([0, 0, 0])
|
||||
})
|
||||
|
||||
it('captures the active camera orientation as a serializable quaternion', () => {
|
||||
manager.perspectiveCamera.position.set(5, 0, 0)
|
||||
manager.perspectiveCamera.lookAt(0, 0, 0)
|
||||
|
||||
const { quaternion } = manager.getCameraState()
|
||||
|
||||
expect(quaternion).toEqual({
|
||||
x: manager.perspectiveCamera.quaternion.x,
|
||||
y: manager.perspectiveCamera.quaternion.y,
|
||||
z: manager.perspectiveCamera.quaternion.z,
|
||||
w: manager.perspectiveCamera.quaternion.w
|
||||
})
|
||||
expect(Object.keys(quaternion ?? {})).not.toContain('_x')
|
||||
})
|
||||
|
||||
it('captures the active camera orientation as a serializable euler rotation', () => {
|
||||
manager.perspectiveCamera.position.set(5, 0, 0)
|
||||
manager.perspectiveCamera.lookAt(0, 0, 0)
|
||||
|
||||
const { rotation } = manager.getCameraState()
|
||||
|
||||
expect(rotation).toEqual({
|
||||
x: manager.perspectiveCamera.rotation.x,
|
||||
y: manager.perspectiveCamera.rotation.y,
|
||||
z: manager.perspectiveCamera.rotation.z,
|
||||
order: manager.perspectiveCamera.rotation.order
|
||||
})
|
||||
expect(Object.keys(rotation ?? {})).not.toContain('_x')
|
||||
})
|
||||
|
||||
it('captures the configured perspective fov regardless of active camera', () => {
|
||||
manager.perspectiveCamera.fov = 42
|
||||
manager.toggleCamera('orthographic')
|
||||
|
||||
expect(manager.getCameraState().fov).toBe(42)
|
||||
})
|
||||
|
||||
it('reflects the perspective aspect after a resize', () => {
|
||||
manager.handleResize(800, 400)
|
||||
|
||||
expect(manager.getCameraState().aspect).toBe(2)
|
||||
})
|
||||
|
||||
it('reflects the orthographic frustum bounds after a resize', () => {
|
||||
manager.toggleCamera('orthographic')
|
||||
manager.handleResize(800, 400)
|
||||
|
||||
const { frustum } = manager.getCameraState()
|
||||
expect(frustum).toEqual({ left: -10, right: 10, top: 5, bottom: -5 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('setControls', () => {
|
||||
|
||||
@@ -144,11 +144,6 @@ export class CameraManager implements CameraManagerInterface {
|
||||
}
|
||||
|
||||
getCameraState(): CameraState {
|
||||
const { x, y, z, w } = this.activeCamera.quaternion
|
||||
const rotation = this.activeCamera.rotation
|
||||
const activeCamera = this.activeCamera as
|
||||
| THREE.PerspectiveCamera
|
||||
| THREE.OrthographicCamera
|
||||
return {
|
||||
position: this.activeCamera.position.clone(),
|
||||
target: this.controls?.target.clone() || new THREE.Vector3(),
|
||||
@@ -156,24 +151,7 @@ export class CameraManager implements CameraManagerInterface {
|
||||
this.activeCamera instanceof THREE.OrthographicCamera
|
||||
? this.activeCamera.zoom
|
||||
: (this.activeCamera as THREE.PerspectiveCamera).zoom,
|
||||
cameraType: this.getCurrentCameraType(),
|
||||
quaternion: { x, y, z, w },
|
||||
rotation: {
|
||||
x: rotation.x,
|
||||
y: rotation.y,
|
||||
z: rotation.z,
|
||||
order: rotation.order
|
||||
},
|
||||
fov: this.perspectiveCamera.fov,
|
||||
aspect: this.perspectiveCamera.aspect,
|
||||
near: activeCamera.near,
|
||||
far: activeCamera.far,
|
||||
frustum: {
|
||||
left: this.orthographicCamera.left,
|
||||
right: this.orthographicCamera.right,
|
||||
top: this.orthographicCamera.top,
|
||||
bottom: this.orthographicCamera.bottom
|
||||
}
|
||||
cameraType: this.getCurrentCameraType()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -314,38 +314,6 @@ describe('GizmoManager', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getModelInfo', () => {
|
||||
it('returns the full transform payload for the target object', () => {
|
||||
manager.init()
|
||||
const model = new THREE.Object3D()
|
||||
model.name = 'my-model'
|
||||
model.position.set(1, 2, 3)
|
||||
model.rotation.set(0.1, 0.2, 0.3)
|
||||
model.scale.set(4, 5, 6)
|
||||
manager.setupForModel(model)
|
||||
|
||||
const info = manager.getModelInfo()
|
||||
|
||||
expect(info).not.toBeNull()
|
||||
expect(info!.uuid).toBe(model.uuid)
|
||||
expect(info!.name).toBe('my-model')
|
||||
expect(info!.type).toBe('Object3D')
|
||||
expect(info!.position).toEqual({ x: 1, y: 2, z: 3 })
|
||||
expect(info!.rotation.x).toBeCloseTo(0.1)
|
||||
expect(info!.rotation.order).toBe(model.rotation.order)
|
||||
expect(info!.quaternion.w).toBeCloseTo(model.quaternion.w)
|
||||
expect(info!.scale).toEqual({ x: 4, y: 5, z: 6 })
|
||||
expect(info!.up).toEqual({ x: 0, y: 1, z: 0 })
|
||||
expect(info!.visible).toBe(true)
|
||||
expect(info!.matrix).toHaveLength(16)
|
||||
})
|
||||
|
||||
it('returns null when there is no target', () => {
|
||||
manager.init()
|
||||
expect(manager.getModelInfo()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeFromScene / ensureHelperInScene', () => {
|
||||
it('removes helper from scene', () => {
|
||||
manager.init()
|
||||
|
||||
@@ -3,7 +3,7 @@ import { TransformControls } from 'three/examples/jsm/controls/TransformControls
|
||||
|
||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
|
||||
|
||||
import type { GizmoMode, ModelTransform } from './interfaces'
|
||||
import type { GizmoMode } from './interfaces'
|
||||
|
||||
export class GizmoManager {
|
||||
private transformControls: TransformControls | null = null
|
||||
@@ -215,48 +215,6 @@ export class GizmoManager {
|
||||
}
|
||||
}
|
||||
|
||||
getModelInfo(): ModelTransform | null {
|
||||
const object = this.targetObject
|
||||
if (!object) return null
|
||||
|
||||
object.updateMatrix()
|
||||
|
||||
return {
|
||||
uuid: object.uuid,
|
||||
name: object.name,
|
||||
type: object.type,
|
||||
position: {
|
||||
x: object.position.x,
|
||||
y: object.position.y,
|
||||
z: object.position.z
|
||||
},
|
||||
rotation: {
|
||||
x: object.rotation.x,
|
||||
y: object.rotation.y,
|
||||
z: object.rotation.z,
|
||||
order: object.rotation.order
|
||||
},
|
||||
quaternion: {
|
||||
x: object.quaternion.x,
|
||||
y: object.quaternion.y,
|
||||
z: object.quaternion.z,
|
||||
w: object.quaternion.w
|
||||
},
|
||||
scale: {
|
||||
x: object.scale.x,
|
||||
y: object.scale.y,
|
||||
z: object.scale.z
|
||||
},
|
||||
up: {
|
||||
x: object.up.x,
|
||||
y: object.up.y,
|
||||
z: object.up.z
|
||||
},
|
||||
visible: object.visible,
|
||||
matrix: object.matrix.toArray()
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.transformControls) {
|
||||
const helper = this.transformControls.getHelper()
|
||||
|
||||
@@ -25,7 +25,6 @@ import type {
|
||||
Load3DOptions,
|
||||
LoadModelOptions,
|
||||
MaterialMode,
|
||||
ModelTransform,
|
||||
UpDirection
|
||||
} from './interfaces'
|
||||
import { attachContextMenuGuard } from './load3dContextMenuGuard'
|
||||
@@ -915,10 +914,6 @@ class Load3d {
|
||||
return this.gizmoManager.getTransform()
|
||||
}
|
||||
|
||||
public getModelInfo(): ModelTransform | null {
|
||||
return this.gizmoManager.getModelInfo()
|
||||
}
|
||||
|
||||
public fitToViewer(): void {
|
||||
this.modelManager.fitToViewer()
|
||||
this.forceRender()
|
||||
|
||||
@@ -15,7 +15,7 @@ export interface ModelLoadContext {
|
||||
readonly materialMode: MaterialMode
|
||||
}
|
||||
|
||||
type ModelAdapterKind = 'mesh' | 'pointCloud' | 'splat'
|
||||
export type ModelAdapterKind = 'mesh' | 'pointCloud' | 'splat'
|
||||
|
||||
export interface ModelAdapterCapabilities {
|
||||
/**
|
||||
|
||||
@@ -15,62 +15,18 @@ export type UpDirection = 'original' | '-x' | '+x' | '-y' | '+y' | '-z' | '+z'
|
||||
export type CameraType = 'perspective' | 'orthographic'
|
||||
export type BackgroundRenderModeType = 'tiled' | 'panorama'
|
||||
|
||||
interface CameraQuaternion {
|
||||
x: number
|
||||
y: number
|
||||
z: number
|
||||
w: number
|
||||
}
|
||||
|
||||
interface CameraRotation {
|
||||
x: number
|
||||
y: number
|
||||
z: number
|
||||
order: string
|
||||
}
|
||||
|
||||
interface CameraFrustum {
|
||||
left: number
|
||||
right: number
|
||||
top: number
|
||||
bottom: number
|
||||
}
|
||||
|
||||
export interface CameraState {
|
||||
position: THREE.Vector3
|
||||
target: THREE.Vector3
|
||||
zoom: number
|
||||
cameraType: CameraType
|
||||
quaternion?: CameraQuaternion
|
||||
rotation?: CameraRotation
|
||||
fov?: number
|
||||
aspect?: number
|
||||
near?: number
|
||||
far?: number
|
||||
frustum?: CameraFrustum
|
||||
}
|
||||
|
||||
export interface ModelTransform {
|
||||
uuid: string
|
||||
name: string
|
||||
type: string
|
||||
position: { x: number; y: number; z: number }
|
||||
rotation: { x: number; y: number; z: number; order: string }
|
||||
quaternion: { x: number; y: number; z: number; w: number }
|
||||
scale: { x: number; y: number; z: number }
|
||||
up: { x: number; y: number; z: number }
|
||||
visible: boolean
|
||||
matrix: number[]
|
||||
}
|
||||
|
||||
export type ModelInfo = ModelTransform[]
|
||||
|
||||
export interface SceneConfig {
|
||||
showGrid: boolean
|
||||
backgroundColor: string
|
||||
backgroundImage?: string
|
||||
backgroundRenderMode?: BackgroundRenderModeType
|
||||
models?: ModelInfo
|
||||
}
|
||||
|
||||
export type GizmoMode = 'translate' | 'rotate' | 'scale'
|
||||
|
||||
@@ -138,8 +138,6 @@
|
||||
"hideLeftPanel": "Hide left panel",
|
||||
"showRightPanel": "Show right panel",
|
||||
"hideRightPanel": "Hide right panel",
|
||||
"maximizeDialog": "Maximize dialog",
|
||||
"restoreDialog": "Restore dialog",
|
||||
"or": "or",
|
||||
"defaultBanner": "default banner",
|
||||
"enableOrDisablePack": "Enable or disable pack",
|
||||
|
||||
@@ -107,12 +107,6 @@ app.directive('tooltip', Tooltip)
|
||||
app
|
||||
.use(router)
|
||||
.use(PrimeVue, {
|
||||
zIndex: {
|
||||
modal: 1800,
|
||||
overlay: 1800,
|
||||
menu: 1800,
|
||||
tooltip: 1800
|
||||
},
|
||||
theme: {
|
||||
preset: ComfyUIPreset,
|
||||
options: {
|
||||
|
||||
@@ -236,8 +236,5 @@ export const MODEL_NODE_MAPPINGS: ReadonlyArray<
|
||||
['optical_flow', 'OpticalFlowLoader', 'model_name'],
|
||||
|
||||
// ---- WanVideo (ComfyUI-WanVideoWrapper) ----
|
||||
['loras', 'WanVideoLoraSelect', 'lora'],
|
||||
|
||||
// ---- LTX-Video IC-LoRA (ComfyUI-LTXVideo) ----
|
||||
['loras', 'LTXICLoRALoaderModelOnly', 'lora_name']
|
||||
['loras', 'WanVideoLoraSelect', 'lora']
|
||||
] as const satisfies ReadonlyArray<readonly [string, string, string]>
|
||||
|
||||
@@ -30,7 +30,7 @@ type FirebaseRuntimeConfig = {
|
||||
* be tweaked without a frontend release. Field types map 1:1 to a component
|
||||
* in our internal UI library — see `DynamicSurveyField.vue`.
|
||||
*/
|
||||
type OnboardingSurveyFieldType = 'single' | 'multi' | 'text'
|
||||
export type OnboardingSurveyFieldType = 'single' | 'multi' | 'text'
|
||||
|
||||
/**
|
||||
* A translatable string. Either:
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
/**
|
||||
* Settings dialog migration regression net: `useSettingsDialog().show()` must
|
||||
* open the Reka-renderer path with sizing that matches the previous
|
||||
* `BaseModalLayout size="sm"` (960px × 80vh). Catches accidental reverts of
|
||||
* the Phase 3 renderer flip.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const showDialog = vi.hoisted(() => vi.fn())
|
||||
const teamWorkspacesFlag = vi.hoisted(() => ({ value: false }))
|
||||
const isCloudRef = vi.hoisted(() => ({ value: false }))
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: () => ({ showDialog, closeDialog: vi.fn() })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({
|
||||
flags: {
|
||||
get teamWorkspacesEnabled() {
|
||||
return teamWorkspacesFlag.value
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return isCloudRef.value
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({ t: (k: string) => k }))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({ trackEvent: vi.fn() })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
isActiveSubscription: { value: true },
|
||||
isFreeTier: { value: false },
|
||||
type: { value: 'legacy' }
|
||||
})
|
||||
}))
|
||||
|
||||
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||
|
||||
describe('useSettingsDialog', () => {
|
||||
beforeEach(() => {
|
||||
showDialog.mockReset()
|
||||
teamWorkspacesFlag.value = false
|
||||
isCloudRef.value = false
|
||||
})
|
||||
|
||||
it("show() opens the Reka renderer with size 'full' and 960px content sizing", () => {
|
||||
useSettingsDialog().show()
|
||||
const [args] = showDialog.mock.calls[0]
|
||||
expect(args.key).toBe('global-settings')
|
||||
expect(args.dialogComponentProps.renderer).toBe('reka')
|
||||
expect(args.dialogComponentProps.size).toBe('full')
|
||||
expect(args.dialogComponentProps.contentClass).toContain('max-w-[960px]')
|
||||
expect(args.dialogComponentProps.contentClass).toContain('h-[80vh]')
|
||||
})
|
||||
|
||||
it('show() uses non-modal Reka so nested PrimeVue dialogs keep focus and pointer events', () => {
|
||||
useSettingsDialog().show()
|
||||
const [args] = showDialog.mock.calls[0]
|
||||
expect(args.dialogComponentProps.modal).toBe(false)
|
||||
})
|
||||
|
||||
it('show() omits overlayClass when not in workspace mode', () => {
|
||||
useSettingsDialog().show()
|
||||
const [args] = showDialog.mock.calls[0]
|
||||
expect(args.dialogComponentProps.overlayClass).toBeUndefined()
|
||||
})
|
||||
|
||||
it("show() sets overlayClass 'p-8' when isCloud && teamWorkspacesEnabled", () => {
|
||||
isCloudRef.value = true
|
||||
teamWorkspacesFlag.value = true
|
||||
|
||||
useSettingsDialog().show()
|
||||
const [args] = showDialog.mock.calls[0]
|
||||
expect(args.dialogComponentProps.overlayClass).toBe('p-8')
|
||||
})
|
||||
|
||||
it('show(panel) forwards defaultPanel to the dialog props', () => {
|
||||
useSettingsDialog().show('about')
|
||||
const [args] = showDialog.mock.calls[0]
|
||||
expect(args.props.defaultPanel).toBe('about')
|
||||
})
|
||||
|
||||
it('showAbout() opens the about panel', () => {
|
||||
useSettingsDialog().showAbout()
|
||||
const [args] = showDialog.mock.calls[0]
|
||||
expect(args.props.defaultPanel).toBe('about')
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,3 @@
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
@@ -8,20 +6,15 @@ import type { SettingPanelType } from '@/platform/settings/types'
|
||||
|
||||
const DIALOG_KEY = 'global-settings'
|
||||
|
||||
const SETTINGS_CONTENT_CLASS =
|
||||
'w-[90vw] max-w-[960px] sm:max-w-[960px] h-[80vh] max-h-none rounded-2xl overflow-hidden'
|
||||
|
||||
export function useSettingsDialog() {
|
||||
const dialogService = useDialogService()
|
||||
const dialogStore = useDialogStore()
|
||||
const { flags } = useFeatureFlags()
|
||||
|
||||
function hide() {
|
||||
dialogStore.closeDialog({ key: DIALOG_KEY })
|
||||
}
|
||||
|
||||
function show(panel?: SettingPanelType, settingId?: string) {
|
||||
const isWorkspaceMode = isCloud && flags.teamWorkspacesEnabled
|
||||
dialogService.showLayoutDialog({
|
||||
key: DIALOG_KEY,
|
||||
component: SettingDialog,
|
||||
@@ -29,18 +22,6 @@ export function useSettingsDialog() {
|
||||
onClose: hide,
|
||||
...(panel ? { defaultPanel: panel } : {}),
|
||||
...(settingId ? { scrollToSettingId: settingId } : {})
|
||||
},
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
// Settings hosts nested PrimeVue dialogs (Edit Keybinding, Overwrite
|
||||
// confirm, etc.) that teleport to body. Reka's modal mode traps focus
|
||||
// inside the Settings content and disables body pointer-events, which
|
||||
// breaks those nested dialogs' autofocus and click handling. Non-modal
|
||||
// keeps the visual overlay without those traps.
|
||||
modal: false,
|
||||
size: 'full',
|
||||
contentClass: SETTINGS_CONTENT_CLASS,
|
||||
overlayClass: isWorkspaceMode ? 'p-8' : undefined
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { createUuidv4 } from '@/lib/litegraph/src/utils/uuid'
|
||||
|
||||
import { isUuidShapedSubgraphId, zSubgraphId } from './subgraphIdSchema'
|
||||
|
||||
const CANONICAL_UUID = '550e8400-e29b-41d4-a716-446655440000'
|
||||
|
||||
const INVALID_STRING_CASES: Array<[label: string, value: string]> = [
|
||||
['empty string', ''],
|
||||
['arbitrary path', '/some/path'],
|
||||
['plain word', 'subgraph'],
|
||||
['hash leftover', '#abc'],
|
||||
['hex but not UUID-shaped', 'abcdef0123456789'],
|
||||
['UUID with leading hash', `#${CANONICAL_UUID}`],
|
||||
['UUID with whitespace', ` ${CANONICAL_UUID} `]
|
||||
]
|
||||
|
||||
const NON_STRING_CASES: Array<[label: string, value: unknown]> = [
|
||||
['number', 123],
|
||||
['undefined', undefined],
|
||||
['null', null],
|
||||
['object', { id: 'abc' }]
|
||||
]
|
||||
|
||||
describe('subgraphIdSchema', () => {
|
||||
describe('zSubgraphId', () => {
|
||||
it('accepts a freshly generated UUID v4', () => {
|
||||
expect(zSubgraphId.safeParse(createUuidv4()).success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts a canonical UUID string', () => {
|
||||
expect(zSubgraphId.safeParse(CANONICAL_UUID).success).toBe(true)
|
||||
})
|
||||
|
||||
it.for(INVALID_STRING_CASES)('rejects %s', ([_label, value]) => {
|
||||
expect(zSubgraphId.safeParse(value).success).toBe(false)
|
||||
})
|
||||
|
||||
it.for(NON_STRING_CASES)('rejects non-string %s', ([_label, value]) => {
|
||||
expect(zSubgraphId.safeParse(value).success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isUuidShapedSubgraphId', () => {
|
||||
it('returns true for a valid UUID', () => {
|
||||
expect(isUuidShapedSubgraphId(createUuidv4())).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for an invalid value', () => {
|
||||
expect(isUuidShapedSubgraphId('not-a-uuid')).toBe(false)
|
||||
expect(isUuidShapedSubgraphId(undefined)).toBe(false)
|
||||
expect(isUuidShapedSubgraphId(42)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,10 +0,0 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
/** Hash values from the URL bar are untrusted; validate before lookup. */
|
||||
export const zSubgraphId = z.string().uuid()
|
||||
|
||||
type SubgraphId = z.infer<typeof zSubgraphId>
|
||||
|
||||
export function isUuidShapedSubgraphId(value: unknown): value is SubgraphId {
|
||||
return zSubgraphId.safeParse(value).success
|
||||
}
|
||||
@@ -812,8 +812,8 @@ export class ComfyApi extends EventTarget {
|
||||
locale && locale !== 'en' ? `index.${locale}.json` : 'index.json'
|
||||
try {
|
||||
const res = await axios.get(this.fileURL(`/templates/${fileName}`))
|
||||
const contentType = String(res.headers['content-type'] ?? '')
|
||||
return contentType.includes('application/json') ? res.data : []
|
||||
const contentType = res.headers['content-type']
|
||||
return contentType?.includes('application/json') ? res.data : []
|
||||
} catch (error) {
|
||||
// Fallback to default English version if localized version doesn't exist
|
||||
if (locale && locale !== 'en') {
|
||||
@@ -1411,8 +1411,8 @@ export class ComfyApi extends EventTarget {
|
||||
}
|
||||
}
|
||||
)
|
||||
const contentType = String(res.headers['content-type'] ?? '')
|
||||
return contentType.includes('application/json') ? res.data : null
|
||||
const contentType = res.headers['content-type']
|
||||
return contentType?.includes('application/json') ? res.data : null
|
||||
} catch (error) {
|
||||
console.error('Error loading fuse options:', error)
|
||||
return null
|
||||
|
||||
@@ -4,8 +4,9 @@ import { merge } from 'es-toolkit/compat'
|
||||
import { defineStore } from 'pinia'
|
||||
import type { DialogPassThroughOptions } from 'primevue/dialog'
|
||||
import { markRaw, ref } from 'vue'
|
||||
import type { Component, HTMLAttributes, Ref } from 'vue'
|
||||
import type { Component, HTMLAttributes } from 'vue'
|
||||
|
||||
import type GlobalDialog from '@/components/dialog/GlobalDialog.vue'
|
||||
import type { DialogContentSize } from '@/components/ui/dialog/dialog.variants'
|
||||
import type { ComponentAttrs } from 'vue-component-type-helpers'
|
||||
|
||||
@@ -47,26 +48,25 @@ interface CustomDialogComponentProps {
|
||||
* PrimeVue path — use `pt` for that renderer.
|
||||
*/
|
||||
contentClass?: HTMLAttributes['class']
|
||||
/**
|
||||
* Class applied to the Reka-UI `DialogOverlay` element. Ignored on the
|
||||
* PrimeVue path — use `pt.mask` for that renderer.
|
||||
*/
|
||||
overlayClass?: HTMLAttributes['class']
|
||||
}
|
||||
|
||||
export type DialogComponentProps = Record<string, unknown> &
|
||||
export type DialogComponentProps = ComponentAttrs<typeof GlobalDialog> &
|
||||
CustomDialogComponentProps
|
||||
|
||||
export interface DialogInstance {
|
||||
export interface DialogInstance<
|
||||
H extends Component = Component,
|
||||
B extends Component = Component,
|
||||
F extends Component = Component
|
||||
> {
|
||||
key: string
|
||||
visible: boolean
|
||||
title?: string
|
||||
headerComponent?: Component
|
||||
headerProps?: Record<string, unknown>
|
||||
component: Component
|
||||
contentProps: Record<string, unknown>
|
||||
footerComponent?: Component
|
||||
footerProps?: Record<string, unknown>
|
||||
headerComponent?: H
|
||||
headerProps?: ComponentAttrs<H>
|
||||
component: B
|
||||
contentProps: ComponentAttrs<B>
|
||||
footerComponent?: F
|
||||
footerProps?: ComponentAttrs<F>
|
||||
dialogComponentProps: DialogComponentProps
|
||||
priority: number
|
||||
}
|
||||
@@ -100,7 +100,7 @@ interface UpdateDialogOptions {
|
||||
}
|
||||
|
||||
export const useDialogStore = defineStore('dialog', () => {
|
||||
const dialogStack: Ref<DialogInstance[]> = ref([])
|
||||
const dialogStack = ref<DialogInstance[]>([])
|
||||
|
||||
/**
|
||||
* The key of the currently active (top-most) dialog.
|
||||
@@ -118,6 +118,7 @@ export const useDialogStore = defineStore('dialog', () => {
|
||||
const insertIndex = dialogStack.value.findIndex(
|
||||
(d) => d.priority <= dialog.priority
|
||||
)
|
||||
|
||||
dialogStack.value.splice(
|
||||
insertIndex === -1 ? dialogStack.value.length : insertIndex,
|
||||
0,
|
||||
@@ -144,8 +145,8 @@ export const useDialogStore = defineStore('dialog', () => {
|
||||
if (!targetDialog) return
|
||||
|
||||
targetDialog.dialogComponentProps?.onClose?.()
|
||||
const index = dialogStack.value.findIndex((d) => d.key === targetDialog.key)
|
||||
if (index !== -1) dialogStack.value.splice(index, 1)
|
||||
const index = dialogStack.value.indexOf(targetDialog)
|
||||
dialogStack.value.splice(index, 1)
|
||||
|
||||
activeKey.value =
|
||||
dialogStack.value.length > 0
|
||||
|
||||
@@ -87,8 +87,7 @@ const MOCK_NODE_NAMES = [
|
||||
'IPAdapterModelLoader',
|
||||
'LS_LoadSegformerModel',
|
||||
'LoadNLFModel',
|
||||
'FlashVSRNode',
|
||||
'LTXICLoRALoaderModelOnly'
|
||||
'FlashVSRNode'
|
||||
] as const
|
||||
|
||||
const mockNodeDefsByName = Object.fromEntries(
|
||||
@@ -308,22 +307,7 @@ describe('useModelToNodeStore', () => {
|
||||
)
|
||||
|
||||
const loraProviders = modelToNodeStore.getAllNodeProviders('loras')
|
||||
expect(loraProviders).toHaveLength(3)
|
||||
expect(loraProviders).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
nodeDef: expect.objectContaining({ name: 'LoraLoader' })
|
||||
}),
|
||||
expect.objectContaining({
|
||||
nodeDef: expect.objectContaining({ name: 'LoraLoaderModelOnly' })
|
||||
}),
|
||||
expect.objectContaining({
|
||||
nodeDef: expect.objectContaining({
|
||||
name: 'LTXICLoRALoaderModelOnly'
|
||||
})
|
||||
})
|
||||
])
|
||||
)
|
||||
expect(loraProviders).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should return single provider for model type with one node', () => {
|
||||
@@ -577,18 +561,6 @@ describe('useModelToNodeStore', () => {
|
||||
expect(modelToNodeStore.getCategoryForNodeType('')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('maps the IC-LoRA Loader Model Only node to loras so its lora_name dropdown uses the cloud asset browser (FE-838)', () => {
|
||||
const modelToNodeStore = useModelToNodeStore()
|
||||
modelToNodeStore.registerDefaults()
|
||||
|
||||
expect(
|
||||
modelToNodeStore.getCategoryForNodeType('LTXICLoRALoaderModelOnly')
|
||||
).toBe('loras')
|
||||
expect(
|
||||
modelToNodeStore.getRegisteredNodeTypes()['LTXICLoRALoaderModelOnly']
|
||||
).toBe('lora_name')
|
||||
})
|
||||
|
||||
it('should return first category when node type exists in multiple categories', () => {
|
||||
const modelToNodeStore = useModelToNodeStore()
|
||||
|
||||
|
||||
@@ -1,307 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import type * as VueRouter from 'vue-router'
|
||||
|
||||
import type { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
||||
|
||||
const ids = vi.hoisted(() => ({
|
||||
root: '00000000-0000-4000-8000-000000000000',
|
||||
validSubgraph: '11111111-1111-4111-8111-111111111111',
|
||||
deletedSubgraph: '22222222-2222-4222-8222-222222222222'
|
||||
}))
|
||||
|
||||
const workflowStoreState = vi.hoisted(() => ({
|
||||
openWorkflows: [] as unknown[],
|
||||
activeSubgraph: undefined as unknown
|
||||
}))
|
||||
|
||||
const routerMocks = vi.hoisted(() => ({
|
||||
push: vi.fn().mockResolvedValue(undefined),
|
||||
replace: vi.fn().mockResolvedValue(undefined)
|
||||
}))
|
||||
|
||||
const routeHashRef = ref('')
|
||||
|
||||
vi.mock('vue-router', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof VueRouter>()
|
||||
return {
|
||||
...actual,
|
||||
useRouter: () => routerMocks
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@vueuse/router', () => ({
|
||||
useRouteHash: () => routeHashRef
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => {
|
||||
const mockCanvas = {
|
||||
subgraph: null,
|
||||
graph: null,
|
||||
setGraph: vi.fn(),
|
||||
setDirty: vi.fn(),
|
||||
ds: {
|
||||
scale: 1,
|
||||
offset: [0, 0],
|
||||
state: { scale: 1, offset: [0, 0] }
|
||||
}
|
||||
}
|
||||
|
||||
const mockRoot = {
|
||||
id: ids.root,
|
||||
_nodes: [],
|
||||
nodes: [],
|
||||
subgraphs: new Map(),
|
||||
getNodeById: vi.fn()
|
||||
}
|
||||
|
||||
return {
|
||||
app: {
|
||||
graph: mockRoot,
|
||||
rootGraph: mockRoot,
|
||||
canvas: mockCanvas
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
getCanvas: () => app.canvas,
|
||||
currentGraph: null
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ fitView: vi.fn() })
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/composables/useSlotElementTracking',
|
||||
() => ({ requestSlotLayoutSyncForAllNodes: vi.fn() })
|
||||
)
|
||||
|
||||
const workflowServiceMocks = vi.hoisted(() => ({
|
||||
openWorkflow: vi.fn().mockResolvedValue(undefined)
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
|
||||
useWorkflowService: () => workflowServiceMocks
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => workflowStoreState
|
||||
}))
|
||||
|
||||
function makeSubgraph(id: string): Subgraph {
|
||||
return fromPartial<Subgraph>({
|
||||
id,
|
||||
rootGraph: app.rootGraph,
|
||||
_nodes: [],
|
||||
nodes: []
|
||||
})
|
||||
}
|
||||
|
||||
async function flushHashWatcher() {
|
||||
await nextTick()
|
||||
await Promise.resolve()
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
describe('useSubgraphNavigationStore - navigateToHash validation', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.clearAllMocks()
|
||||
app.rootGraph.id = ids.root
|
||||
app.rootGraph.subgraphs.clear()
|
||||
app.canvas.subgraph = undefined
|
||||
app.canvas.graph = app.rootGraph
|
||||
workflowStoreState.openWorkflows = []
|
||||
workflowStoreState.activeSubgraph = undefined
|
||||
routeHashRef.value = ''
|
||||
})
|
||||
|
||||
it('navigates to a valid, existing subgraph hash', async () => {
|
||||
const subgraph = makeSubgraph(ids.validSubgraph)
|
||||
app.rootGraph.subgraphs.set(subgraph.id, subgraph)
|
||||
useSubgraphNavigationStore()
|
||||
|
||||
routeHashRef.value = `#${ids.validSubgraph}`
|
||||
await flushHashWatcher()
|
||||
|
||||
expect(app.canvas.setGraph).toHaveBeenCalledWith(subgraph)
|
||||
expect(routerMocks.replace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('redirects to root when hash references a deleted subgraph', async () => {
|
||||
useSubgraphNavigationStore()
|
||||
|
||||
routeHashRef.value = `#${ids.deletedSubgraph}`
|
||||
await vi.waitFor(() =>
|
||||
expect(routerMocks.replace).toHaveBeenCalledWith(`#${app.rootGraph.id}`)
|
||||
)
|
||||
})
|
||||
|
||||
it('redirects to root when hash is malformed (not a UUID)', async () => {
|
||||
useSubgraphNavigationStore()
|
||||
|
||||
routeHashRef.value = '#not-a-valid-uuid'
|
||||
await vi.waitFor(() =>
|
||||
expect(routerMocks.replace).toHaveBeenCalledWith(`#${app.rootGraph.id}`)
|
||||
)
|
||||
expect(app.canvas.setGraph).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not redirect when hash equals a non-UUID root graph id (loaded workflow slug)', async () => {
|
||||
const slugRootId = 'test-missing-models-in-subgraph'
|
||||
app.rootGraph.id = slugRootId
|
||||
app.canvas.graph = fromPartial<LGraph>({ id: slugRootId })
|
||||
useSubgraphNavigationStore()
|
||||
|
||||
routeHashRef.value = `#${slugRootId}`
|
||||
await flushHashWatcher()
|
||||
|
||||
expect(routerMocks.replace).not.toHaveBeenCalled()
|
||||
expect(app.canvas.setGraph).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('redirects when hash is a non-UUID slug that does not match root', async () => {
|
||||
useSubgraphNavigationStore()
|
||||
|
||||
routeHashRef.value = '#some-other-slug'
|
||||
await vi.waitFor(() =>
|
||||
expect(routerMocks.replace).toHaveBeenCalledWith(`#${app.rootGraph.id}`)
|
||||
)
|
||||
})
|
||||
|
||||
it('does not redirect or re-set graph when hash equals current root graph', async () => {
|
||||
app.canvas.graph = fromPartial<LGraph>({ id: ids.root })
|
||||
useSubgraphNavigationStore()
|
||||
|
||||
routeHashRef.value = `#${ids.root}`
|
||||
await flushHashWatcher()
|
||||
|
||||
expect(app.canvas.setGraph).not.toHaveBeenCalled()
|
||||
expect(routerMocks.replace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not redirect when transitioning to an empty hash on the root graph', async () => {
|
||||
routeHashRef.value = `#${ids.root}`
|
||||
app.canvas.graph = fromPartial<LGraph>({ id: ids.root })
|
||||
useSubgraphNavigationStore()
|
||||
await flushHashWatcher()
|
||||
routerMocks.replace.mockClear()
|
||||
vi.mocked(app.canvas.setGraph).mockClear()
|
||||
|
||||
routeHashRef.value = ''
|
||||
await flushHashWatcher()
|
||||
|
||||
expect(routerMocks.replace).not.toHaveBeenCalled()
|
||||
expect(app.canvas.setGraph).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('redirects when canvas still references a deleted subgraph (stale-graph guard)', async () => {
|
||||
app.canvas.graph = makeSubgraph(ids.deletedSubgraph)
|
||||
useSubgraphNavigationStore()
|
||||
|
||||
routeHashRef.value = `#${ids.deletedSubgraph}`
|
||||
await vi.waitFor(() => {
|
||||
expect(routerMocks.replace).toHaveBeenCalledWith(`#${app.rootGraph.id}`)
|
||||
expect(app.canvas.setGraph).toHaveBeenCalledWith(app.rootGraph)
|
||||
})
|
||||
})
|
||||
|
||||
it('recovers canvas to root even if router.replace rejects', async () => {
|
||||
routerMocks.replace.mockRejectedValueOnce(new Error('navigation aborted'))
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
app.canvas.graph = makeSubgraph(ids.deletedSubgraph)
|
||||
useSubgraphNavigationStore()
|
||||
|
||||
routeHashRef.value = `#${ids.deletedSubgraph}`
|
||||
await vi.waitFor(() =>
|
||||
expect(app.canvas.setGraph).toHaveBeenCalledWith(app.rootGraph)
|
||||
)
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('redirects when a workflow load resolves but the subgraph is still missing', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
workflowStoreState.openWorkflows = [
|
||||
fromPartial<ComfyWorkflow>({
|
||||
path: 'phantom-workflow.json',
|
||||
activeState: {
|
||||
id: ids.deletedSubgraph,
|
||||
definitions: { subgraphs: [] }
|
||||
}
|
||||
})
|
||||
]
|
||||
useSubgraphNavigationStore()
|
||||
|
||||
routeHashRef.value = `#${ids.deletedSubgraph}`
|
||||
await vi.waitFor(() => {
|
||||
expect(workflowServiceMocks.openWorkflow).toHaveBeenCalled()
|
||||
expect(routerMocks.replace).toHaveBeenCalledWith(`#${app.rootGraph.id}`)
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('subgraph not found after workflow load')
|
||||
)
|
||||
})
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('redirects when openWorkflow rejects during recovery', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
workflowServiceMocks.openWorkflow.mockRejectedValueOnce(
|
||||
new Error('load failed')
|
||||
)
|
||||
workflowStoreState.openWorkflows = [
|
||||
fromPartial<ComfyWorkflow>({
|
||||
path: 'broken-workflow.json',
|
||||
activeState: {
|
||||
id: ids.deletedSubgraph,
|
||||
definitions: { subgraphs: [] }
|
||||
}
|
||||
})
|
||||
]
|
||||
useSubgraphNavigationStore()
|
||||
|
||||
routeHashRef.value = `#${ids.deletedSubgraph}`
|
||||
await vi.waitFor(() => {
|
||||
expect(routerMocks.replace).toHaveBeenCalledWith(`#${app.rootGraph.id}`)
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('workflow load failed')
|
||||
)
|
||||
})
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('routeHash watcher does not re-enter navigateToHash during recovery redirect', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
// Simulate the real router replace: trigger the routeHash watcher
|
||||
// exactly the way vue-router does when the URL is replaced.
|
||||
routerMocks.replace.mockImplementation((target) => {
|
||||
const hash = typeof target === 'string' ? target : ''
|
||||
routeHashRef.value = hash
|
||||
return Promise.resolve(undefined)
|
||||
})
|
||||
app.canvas.graph = makeSubgraph(ids.deletedSubgraph)
|
||||
useSubgraphNavigationStore()
|
||||
|
||||
routeHashRef.value = `#${ids.deletedSubgraph}`
|
||||
await vi.waitFor(() => {
|
||||
expect(routerMocks.replace).toHaveBeenCalledWith(`#${app.rootGraph.id}`)
|
||||
})
|
||||
|
||||
// navigateToHash for the deleted id ran once and produced exactly one
|
||||
// redirect. The watcher must NOT have fired again for the rewritten
|
||||
// (root) hash and produced a second redirect.
|
||||
expect(routerMocks.replace).toHaveBeenCalledTimes(1)
|
||||
expect(app.canvas.setGraph).toHaveBeenCalledWith(app.rootGraph)
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -2,11 +2,7 @@ import QuickLRU from '@alloc/quick-lru'
|
||||
import { useRouteHash } from '@vueuse/router'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref, shallowRef, watch } from 'vue'
|
||||
import {
|
||||
NavigationFailureType,
|
||||
isNavigationFailure,
|
||||
useRouter
|
||||
} from 'vue-router'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import type { DragAndScaleState } from '@/lib/litegraph/src/DragAndScale'
|
||||
import type { Subgraph } from '@/lib/litegraph/src/litegraph'
|
||||
@@ -14,7 +10,6 @@ import { useWorkflowStore } from '@/platform/workflow/management/stores/workflow
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { requestSlotLayoutSyncForAllNodes } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
|
||||
import { isUuidShapedSubgraphId } from '@/schemas/subgraphIdSchema'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { findSubgraphPathById } from '@/utils/graphTraversalUtil'
|
||||
@@ -205,64 +200,20 @@ export const useSubgraphNavigationStore = defineStore(
|
||||
{ flush: 'sync' }
|
||||
)
|
||||
|
||||
// Counter so nested/overlapping async navigations don't release
|
||||
// suppression early; gates both the canvasStore.currentGraph watcher
|
||||
// (updateHash) and the routeHash watcher to prevent re-entrant
|
||||
// navigateToHash calls during router.replace().
|
||||
let blockNavDepth = 0
|
||||
//Allow navigation with forward/back buttons
|
||||
let blockHashUpdate = false
|
||||
let initialLoad = true
|
||||
|
||||
async function withNavBlocked<T>(op: () => Promise<T>): Promise<T> {
|
||||
blockNavDepth++
|
||||
try {
|
||||
return await op()
|
||||
} finally {
|
||||
blockNavDepth--
|
||||
}
|
||||
}
|
||||
|
||||
function ensureCanvasOnRoot() {
|
||||
const root = app.rootGraph
|
||||
const canvas = canvasStore.getCanvas()
|
||||
if (!root || !canvas) return
|
||||
if (canvas.graph?.id !== root.id) canvas.setGraph(root)
|
||||
}
|
||||
|
||||
async function redirectToRoot(reason: string) {
|
||||
const root = app.rootGraph
|
||||
console.warn(`[subgraphNavigation] ${reason}; redirecting to root graph`)
|
||||
try {
|
||||
await withNavBlocked(() => router.replace('#' + root.id))
|
||||
} catch (err) {
|
||||
if (
|
||||
!isNavigationFailure(err, NavigationFailureType.duplicated) &&
|
||||
!isNavigationFailure(err, NavigationFailureType.cancelled)
|
||||
) {
|
||||
console.warn(
|
||||
'[subgraphNavigation] router.replace rejected during recovery',
|
||||
err
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
ensureCanvasOnRoot()
|
||||
}
|
||||
}
|
||||
|
||||
async function navigateToHash(newHash: string) {
|
||||
const root = app.rootGraph
|
||||
const locatorId = newHash?.slice(1) || root.id
|
||||
const canvas = canvasStore.getCanvas()
|
||||
|
||||
const isRoot = locatorId === root.id
|
||||
const targetGraph = isRoot
|
||||
? root
|
||||
: isUuidShapedSubgraphId(locatorId)
|
||||
if (canvas.graph?.id === locatorId) return
|
||||
const targetGraph =
|
||||
(locatorId || root.id) !== root.id
|
||||
? root.subgraphs.get(locatorId)
|
||||
: undefined
|
||||
if (targetGraph) {
|
||||
if (canvas.graph?.id === targetGraph.id) return
|
||||
return canvas.setGraph(targetGraph)
|
||||
}
|
||||
: root
|
||||
if (targetGraph) return canvas.setGraph(targetGraph)
|
||||
|
||||
//Search all open workflows
|
||||
for (const workflow of workflowStore.openWorkflows) {
|
||||
@@ -271,48 +222,29 @@ export const useSubgraphNavigationStore = defineStore(
|
||||
const subgraphs = activeState.definitions?.subgraphs ?? []
|
||||
for (const graph of [activeState, ...subgraphs]) {
|
||||
if (graph.id !== locatorId) continue
|
||||
// This will trigger a navigation, which can break forward history.
|
||||
// After openWorkflow resolves, app.rootGraph has been swapped, so we
|
||||
// intentionally re-read app.rootGraph below instead of using the
|
||||
// `root` captured at function entry.
|
||||
//This will trigger a navigation, which can break forward history
|
||||
try {
|
||||
await withNavBlocked(() =>
|
||||
useWorkflowService().openWorkflow(workflow)
|
||||
)
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'[subgraphNavigation] openWorkflow rejected during recovery',
|
||||
err
|
||||
)
|
||||
return redirectToRoot('workflow load failed')
|
||||
blockHashUpdate = true
|
||||
await useWorkflowService().openWorkflow(workflow)
|
||||
} finally {
|
||||
blockHashUpdate = false
|
||||
}
|
||||
const loadedGraph =
|
||||
const targetGraph =
|
||||
app.rootGraph.id === locatorId
|
||||
? app.rootGraph
|
||||
: app.rootGraph.subgraphs.get(locatorId)
|
||||
if (!loadedGraph) {
|
||||
return redirectToRoot('subgraph not found after workflow load')
|
||||
if (!targetGraph) {
|
||||
console.error('subgraph poofed after load?')
|
||||
return
|
||||
}
|
||||
if (canvas.graph?.id === loadedGraph.id) return
|
||||
return canvas.setGraph(loadedGraph)
|
||||
}
|
||||
}
|
||||
|
||||
await redirectToRoot(`subgraph not found: ${locatorId}`)
|
||||
}
|
||||
|
||||
async function safeRouterCall(op: () => Promise<unknown>, label: string) {
|
||||
try {
|
||||
await op()
|
||||
} catch (err) {
|
||||
if (!isNavigationFailure(err, NavigationFailureType.duplicated)) {
|
||||
console.warn(`[subgraphNavigation] ${label} rejected`, err)
|
||||
return canvas.setGraph(targetGraph)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function updateHash() {
|
||||
if (blockNavDepth > 0) return
|
||||
if (blockHashUpdate) return
|
||||
if (initialLoad) {
|
||||
initialLoad = false
|
||||
if (!routeHash.value) return
|
||||
@@ -323,22 +255,16 @@ export const useSubgraphNavigationStore = defineStore(
|
||||
}
|
||||
|
||||
const newId = canvasStore.getCanvas().graph?.id ?? ''
|
||||
if (!routeHash.value) {
|
||||
await safeRouterCall(
|
||||
() => router.replace('#' + app.rootGraph.id),
|
||||
'router.replace'
|
||||
)
|
||||
}
|
||||
if (!routeHash.value) await router.replace('#' + app.rootGraph.id)
|
||||
const currentId = routeHash.value?.slice(1)
|
||||
if (!newId || newId === currentId) return
|
||||
|
||||
await safeRouterCall(() => router.push('#' + newId), 'router.push')
|
||||
await router.push('#' + newId)
|
||||
}
|
||||
//update navigation hash
|
||||
//NOTE: Doesn't apply on workflow load
|
||||
watch(() => canvasStore.currentGraph, updateHash)
|
||||
watch(routeHash, () => {
|
||||
if (blockNavDepth > 0) return
|
||||
void navigateToHash(String(routeHash.value))
|
||||
})
|
||||
watch(routeHash, () => navigateToHash(String(routeHash.value)))
|
||||
|
||||
/** Save the current viewport for the active graph/workflow. Called by
|
||||
* workflowService.beforeLoadNewGraph() before the canvas is overwritten. */
|
||||
|
||||
Reference in New Issue
Block a user