mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-28 00:45:03 +00:00
Compare commits
3 Commits
feat/load3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8206022982 | ||
|
|
5f2b2f2e87 | ||
|
|
a931acadd3 |
Binary file not shown.
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 26 KiB |
87
browser_tests/tests/subgraph/subgraphHashValidation.spec.ts
Normal file
87
browser_tests/tests/subgraph/subgraphHashValidation.spec.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
async function waitForRootCanvasReady(page: Page) {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const state = await page.evaluate(() => ({
|
||||
rootId: window.app?.rootGraph?.id ?? '',
|
||||
canvasGraphId: window.app?.canvas?.graph?.id ?? ''
|
||||
}))
|
||||
return state.rootId !== '' && state.canvasGraphId === state.rootId
|
||||
})
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
async function expectCanvasOnRootGraph(page: Page) {
|
||||
await expect
|
||||
.poll(async () =>
|
||||
page.evaluate(() => ({
|
||||
rootId: window.app!.rootGraph.id,
|
||||
canvasGraphId: window.app!.canvas.graph?.id,
|
||||
hash: window.location.hash
|
||||
}))
|
||||
)
|
||||
.toEqual({
|
||||
rootId: expect.any(String),
|
||||
canvasGraphId: expect.stringMatching(/.+/),
|
||||
hash: expect.stringMatching(/^#.+/)
|
||||
})
|
||||
const state = await page.evaluate(() => ({
|
||||
rootId: window.app!.rootGraph.id,
|
||||
canvasGraphId: window.app!.canvas.graph?.id,
|
||||
hash: window.location.hash
|
||||
}))
|
||||
expect(state.canvasGraphId).toBe(state.rootId)
|
||||
expect(state.hash).toBe(`#${state.rootId}`)
|
||||
}
|
||||
|
||||
test.describe(
|
||||
'Subgraph hash validation (FE-559)',
|
||||
{ tag: ['@subgraph'] },
|
||||
() => {
|
||||
test('redirects URL and canvas to root for a non-existent subgraph hash', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await waitForRootCanvasReady(comfyPage.page)
|
||||
const rootId = await comfyPage.page.evaluate(
|
||||
() => window.app!.rootGraph.id
|
||||
)
|
||||
const phantomId = '11111111-1111-4111-8111-111111111111'
|
||||
expect(phantomId).not.toBe(rootId)
|
||||
|
||||
await comfyPage.page.evaluate((hash) => {
|
||||
window.location.hash = hash
|
||||
}, `#${phantomId}`)
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.page.evaluate(() => window.location.hash), {
|
||||
timeout: 5000
|
||||
})
|
||||
.toBe(`#${rootId}`)
|
||||
await expectCanvasOnRootGraph(comfyPage.page)
|
||||
})
|
||||
|
||||
test('redirects URL and canvas to root when hash is malformed', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await waitForRootCanvasReady(comfyPage.page)
|
||||
const rootId = await comfyPage.page.evaluate(
|
||||
() => window.app!.rootGraph.id
|
||||
)
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.location.hash = '#not-a-valid-uuid'
|
||||
})
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.page.evaluate(() => window.location.hash), {
|
||||
timeout: 5000
|
||||
})
|
||||
.toBe(`#${rootId}`)
|
||||
await expectCanvasOnRootGraph(comfyPage.page)
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -43,7 +43,6 @@ const config: KnipConfig = {
|
||||
'@iconify/json',
|
||||
'@primeuix/forms',
|
||||
'@primeuix/styled',
|
||||
'@primeuix/utils',
|
||||
'@primevue/icons'
|
||||
],
|
||||
ignore: [
|
||||
|
||||
@@ -1892,3 +1892,17 @@ audio.comfy-audio.empty-audio-widget {
|
||||
300% 14px;
|
||||
background-attachment: local, local, scroll, scroll;
|
||||
}
|
||||
|
||||
/*
|
||||
PrimeVue overlays teleport to body. When a Reka modal dialog is open it sets
|
||||
body { pointer-events: none } via DismissableLayer, which propagates to the
|
||||
body-portaled overlays and makes them unclickable. PrimeVue's own Dialog
|
||||
sets pointer-events inline, but Select / ColorPicker / Popover / Autocomplete
|
||||
overlays do not, so they need to opt in here.
|
||||
*/
|
||||
.p-select-overlay,
|
||||
.p-colorpicker-panel,
|
||||
.p-popover,
|
||||
.p-autocomplete-overlay {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,10 @@ import { defineComponent, h } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
|
||||
import {
|
||||
onRekaFocusOutside,
|
||||
onRekaPointerDownOutside
|
||||
} from '@/components/dialog/rekaPrimeVueBridge'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const i18n = createI18n({
|
||||
@@ -190,3 +194,88 @@ describe('GlobalDialog Reka parity with PrimeVue', () => {
|
||||
expect(store.isDialogOpen('reka-esc-blocked')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldPreventRekaDismiss', () => {
|
||||
function makeEvent(target: Element | null) {
|
||||
let prevented = false
|
||||
return {
|
||||
detail: { originalEvent: { target } },
|
||||
preventDefault: () => {
|
||||
prevented = true
|
||||
},
|
||||
get defaultPrevented() {
|
||||
return prevented
|
||||
}
|
||||
} as unknown as CustomEvent<{ originalEvent: PointerEvent }> & {
|
||||
defaultPrevented: boolean
|
||||
}
|
||||
}
|
||||
|
||||
it.for([
|
||||
'p-select-overlay',
|
||||
'p-colorpicker-panel',
|
||||
'p-popover',
|
||||
'p-autocomplete-overlay',
|
||||
'p-overlay-mask',
|
||||
'p-dialog'
|
||||
])('prevents dismiss when target is inside %s', (className) => {
|
||||
const overlay = document.createElement('div')
|
||||
overlay.className = className
|
||||
const inner = document.createElement('button')
|
||||
overlay.appendChild(inner)
|
||||
document.body.appendChild(overlay)
|
||||
|
||||
const event = makeEvent(inner)
|
||||
onRekaPointerDownOutside({ dismissableMask: undefined }, event)
|
||||
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
overlay.remove()
|
||||
})
|
||||
|
||||
it('allows dismiss when target is outside any PrimeVue overlay', () => {
|
||||
const event = makeEvent(document.body)
|
||||
onRekaPointerDownOutside({ dismissableMask: undefined }, event)
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
})
|
||||
|
||||
it('prevents dismiss when dismissableMask is false even outside an overlay', () => {
|
||||
const event = makeEvent(document.body)
|
||||
onRekaPointerDownOutside({ dismissableMask: false }, event)
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
})
|
||||
|
||||
it.for(['p-dialog', 'p-select-overlay'])(
|
||||
'focus-outside on a sibling %s portal does not dismiss the parent',
|
||||
(className) => {
|
||||
const overlay = document.createElement('div')
|
||||
overlay.className = className
|
||||
const inner = document.createElement('button')
|
||||
overlay.appendChild(inner)
|
||||
document.body.appendChild(overlay)
|
||||
|
||||
const event = makeEvent(inner)
|
||||
onRekaFocusOutside(event)
|
||||
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
overlay.remove()
|
||||
}
|
||||
)
|
||||
|
||||
it('focus-outside still dismisses when focus moves to a non-portal element', () => {
|
||||
const event = makeEvent(document.body)
|
||||
onRekaFocusOutside(event)
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
})
|
||||
|
||||
it('focus-outside on a sibling Reka portal does not dismiss the parent', () => {
|
||||
const portal = document.createElement('div')
|
||||
portal.setAttribute('role', 'dialog')
|
||||
document.body.appendChild(portal)
|
||||
|
||||
const event = makeEvent(portal)
|
||||
onRekaFocusOutside(event)
|
||||
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
portal.remove()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,9 +8,14 @@
|
||||
@update:open="(open) => onRekaOpenChange(item.key, open)"
|
||||
>
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogOverlay
|
||||
v-reka-z-index
|
||||
:class="item.dialogComponentProps.overlayClass"
|
||||
/>
|
||||
<DialogContent
|
||||
v-reka-z-index
|
||||
:size="item.dialogComponentProps.size ?? 'md'"
|
||||
:maximized="!!item.dialogComponentProps.maximized"
|
||||
:class="item.dialogComponentProps.contentClass"
|
||||
:aria-labelledby="item.key"
|
||||
@escape-key-down="
|
||||
@@ -19,34 +24,51 @@
|
||||
e.preventDefault()
|
||||
"
|
||||
@pointer-down-outside="
|
||||
(e) =>
|
||||
item.dialogComponentProps.dismissableMask === false &&
|
||||
e.preventDefault()
|
||||
(e) => onRekaPointerDownOutside(item.dialogComponentProps, e)
|
||||
"
|
||||
@focus-outside="onRekaFocusOutside"
|
||||
@mousedown="() => dialogStore.riseDialog({ key: item.key })"
|
||||
>
|
||||
<DialogHeader v-if="!item.dialogComponentProps.headless">
|
||||
<component
|
||||
:is="item.headerComponent"
|
||||
v-if="item.headerComponent"
|
||||
v-bind="item.headerProps"
|
||||
:id="item.key"
|
||||
/>
|
||||
<DialogTitle v-else :id="item.key">
|
||||
{{ item.title || ' ' }}
|
||||
</DialogTitle>
|
||||
<DialogClose v-if="item.dialogComponentProps.closable !== false" />
|
||||
</DialogHeader>
|
||||
<div class="flex-1 overflow-auto px-4 py-2">
|
||||
<template v-if="item.dialogComponentProps.headless">
|
||||
<component
|
||||
:is="item.component"
|
||||
v-bind="item.contentProps"
|
||||
:maximized="item.dialogComponentProps.maximized"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter v-if="item.footerComponent">
|
||||
<component :is="item.footerComponent" v-bind="item.footerProps" />
|
||||
</DialogFooter>
|
||||
</template>
|
||||
<template v-else>
|
||||
<DialogHeader>
|
||||
<component
|
||||
:is="item.headerComponent"
|
||||
v-if="item.headerComponent"
|
||||
v-bind="item.headerProps"
|
||||
:id="item.key"
|
||||
/>
|
||||
<DialogTitle v-else :id="item.key">
|
||||
{{ item.title || ' ' }}
|
||||
</DialogTitle>
|
||||
<div class="flex items-center gap-1">
|
||||
<DialogMaximize
|
||||
v-if="item.dialogComponentProps.maximizable"
|
||||
:maximized="!!item.dialogComponentProps.maximized"
|
||||
@toggle="toggleMaximize(item)"
|
||||
/>
|
||||
<DialogClose
|
||||
v-if="item.dialogComponentProps.closable !== false"
|
||||
/>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<div class="flex-1 overflow-auto px-4 py-2">
|
||||
<component
|
||||
:is="item.component"
|
||||
v-bind="item.contentProps"
|
||||
:maximized="item.dialogComponentProps.maximized"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter v-if="item.footerComponent">
|
||||
<component :is="item.footerComponent" v-bind="item.footerProps" />
|
||||
</DialogFooter>
|
||||
</template>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
@@ -55,7 +77,6 @@
|
||||
v-model:visible="item.visible"
|
||||
class="global-dialog"
|
||||
v-bind="item.dialogComponentProps"
|
||||
:pt="getDialogPt(item)"
|
||||
:aria-labelledby="item.key"
|
||||
>
|
||||
<template #header>
|
||||
@@ -86,29 +107,25 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { merge } from 'es-toolkit/compat'
|
||||
import PrimeDialog from 'primevue/dialog'
|
||||
import type { DialogPassThroughOptions } from 'primevue/dialog'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import Dialog from '@/components/ui/dialog/Dialog.vue'
|
||||
import DialogClose from '@/components/ui/dialog/DialogClose.vue'
|
||||
import DialogContent from '@/components/ui/dialog/DialogContent.vue'
|
||||
import DialogFooter from '@/components/ui/dialog/DialogFooter.vue'
|
||||
import DialogHeader from '@/components/ui/dialog/DialogHeader.vue'
|
||||
import DialogMaximize from '@/components/ui/dialog/DialogMaximize.vue'
|
||||
import DialogOverlay from '@/components/ui/dialog/DialogOverlay.vue'
|
||||
import DialogPortal from '@/components/ui/dialog/DialogPortal.vue'
|
||||
import DialogTitle from '@/components/ui/dialog/DialogTitle.vue'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import type { DialogComponentProps, DialogInstance } from '@/stores/dialogStore'
|
||||
import {
|
||||
onRekaFocusOutside,
|
||||
onRekaPointerDownOutside
|
||||
} from '@/components/dialog/rekaPrimeVueBridge'
|
||||
import { vRekaZIndex } from '@/components/dialog/vRekaZIndex'
|
||||
import type { DialogInstance } from '@/stores/dialogStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
const teamWorkspacesEnabled = computed(
|
||||
() => isCloud && flags.teamWorkspacesEnabled
|
||||
)
|
||||
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
function isRekaItem(item: DialogInstance) {
|
||||
@@ -119,20 +136,8 @@ function onRekaOpenChange(key: string, open: boolean) {
|
||||
if (!open) dialogStore.closeDialog({ key })
|
||||
}
|
||||
|
||||
function getDialogPt(item: {
|
||||
key: string
|
||||
dialogComponentProps: DialogComponentProps
|
||||
}): DialogPassThroughOptions {
|
||||
const isWorkspaceSettingsDialog =
|
||||
item.key === 'global-settings' && teamWorkspacesEnabled.value
|
||||
const basePt = item.dialogComponentProps.pt || {}
|
||||
|
||||
if (isWorkspaceSettingsDialog) {
|
||||
return merge(basePt, {
|
||||
mask: { class: 'p-8' }
|
||||
})
|
||||
}
|
||||
return basePt
|
||||
function toggleMaximize(item: DialogInstance) {
|
||||
item.dialogComponentProps.maximized = !item.dialogComponentProps.maximized
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -163,19 +168,6 @@ function getDialogPt(item: {
|
||||
}
|
||||
}
|
||||
|
||||
/* Workspace mode: wider settings dialog */
|
||||
.settings-dialog-workspace {
|
||||
width: 100%;
|
||||
max-width: 1440px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.settings-dialog-workspace .p-dialog-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.manager-dialog {
|
||||
height: 80vh;
|
||||
max-width: 1724px;
|
||||
|
||||
@@ -244,7 +244,7 @@
|
||||
<ContextMenuPortal>
|
||||
<ContextMenuContent
|
||||
:style="keybindingOverlayContentStyle"
|
||||
class="z-1200 min-w-56 rounded-lg border border-border-subtle bg-base-background px-2 py-3 shadow-interface"
|
||||
class="z-1800 min-w-56 rounded-lg border border-border-subtle bg-base-background px-2 py-3 shadow-interface"
|
||||
>
|
||||
<ContextMenuItem
|
||||
class="flex cursor-pointer items-center gap-2 rounded-sm px-3 py-2 text-sm text-text-primary outline-none select-none hover:bg-node-component-surface-hovered focus:bg-node-component-surface-hovered data-disabled:cursor-default data-disabled:opacity-50"
|
||||
|
||||
49
src/components/dialog/rekaPrimeVueBridge.ts
Normal file
49
src/components/dialog/rekaPrimeVueBridge.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
// PrimeVue overlays (Select, ColorPicker, Popover, Autocomplete, stacked
|
||||
// PrimeVue Dialogs) teleport to body. Reka treats clicks on body-portaled
|
||||
// elements as outside its dialog and would auto-dismiss on the first
|
||||
// interaction, tearing the overlay down mid-interaction. Treat any
|
||||
// PrimeVue overlay click as inside.
|
||||
const PRIMEVUE_OVERLAY_SELECTORS =
|
||||
'.p-select-overlay, .p-colorpicker-panel, .p-popover, .p-autocomplete-overlay, .p-overlay, .p-overlay-mask, .p-dialog'
|
||||
|
||||
// Reka portals its own dialogs / popovers / menus into the body too. When a
|
||||
// nested Reka layer opens on top of a non-modal parent, the parent's
|
||||
// DismissableLayer sees the focus shift / pointer-down as "outside" and would
|
||||
// dismiss itself. These selectors cover the portaled roots so we can treat
|
||||
// interactions on them as inside.
|
||||
const REKA_PORTAL_SELECTORS =
|
||||
'[data-reka-popper-content-wrapper], [data-reka-dialog-content], [data-reka-menu-content], [data-reka-context-menu-content], [role="dialog"], [role="menu"], [role="listbox"], [role="tooltip"]'
|
||||
|
||||
const OUTSIDE_LAYER_SELECTORS = `${PRIMEVUE_OVERLAY_SELECTORS}, ${REKA_PORTAL_SELECTORS}`
|
||||
|
||||
type OutsideEvent = CustomEvent<{ originalEvent: Event }>
|
||||
|
||||
function isInsideOverlay(target: EventTarget | null): boolean {
|
||||
return (
|
||||
target instanceof Element &&
|
||||
target.closest(OUTSIDE_LAYER_SELECTORS) !== null
|
||||
)
|
||||
}
|
||||
|
||||
export function onRekaPointerDownOutside(
|
||||
options: { dismissableMask?: boolean },
|
||||
event: OutsideEvent
|
||||
) {
|
||||
if (isInsideOverlay(event.detail.originalEvent.target)) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
if (options.dismissableMask === false) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
// Focus / interact-outside fires when focus moves to a sibling portal (a
|
||||
// nested Reka or PrimeVue dialog teleported to body). Without this guard a
|
||||
// non-modal Reka dialog would dismiss itself the moment a nested dialog
|
||||
// receives focus.
|
||||
export function onRekaFocusOutside(event: OutsideEvent) {
|
||||
if (isInsideOverlay(event.detail.originalEvent.target)) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
17
src/components/dialog/vRekaZIndex.ts
Normal file
17
src/components/dialog/vRekaZIndex.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { ZIndex } from '@primeuix/utils/zindex'
|
||||
import type { Directive } from 'vue'
|
||||
|
||||
// Both Reka and PrimeVue dialogs can appear at any depth in dialogStack, in
|
||||
// any order. PrimeVue auto-increments a per-key z-index counter so later
|
||||
// dialogs always cover earlier ones; Reka uses a static z-1700 class which
|
||||
// can lose to an already-open PrimeVue dialog. Registering Reka's content
|
||||
// element with the same ZIndex counter (key 'modal', base 1700) makes both
|
||||
// renderers share one stacking sequence: whichever dialog opens last wins.
|
||||
export const vRekaZIndex: Directive<HTMLElement> = {
|
||||
mounted(el) {
|
||||
ZIndex.set('modal', el, 1700)
|
||||
},
|
||||
beforeUnmount(el) {
|
||||
ZIndex.clear(el)
|
||||
}
|
||||
}
|
||||
@@ -10,11 +10,13 @@ import { dialogContentVariants } from './dialog.variants'
|
||||
|
||||
const {
|
||||
size,
|
||||
maximized = false,
|
||||
class: customClass = '',
|
||||
...restProps
|
||||
} = defineProps<
|
||||
DialogContentProps & {
|
||||
size?: DialogContentSize
|
||||
maximized?: boolean
|
||||
class?: HTMLAttributes['class']
|
||||
}
|
||||
>()
|
||||
@@ -26,7 +28,7 @@ const forwarded = useForwardPropsEmits(restProps, emits)
|
||||
<template>
|
||||
<DialogContent
|
||||
v-bind="forwarded"
|
||||
:class="cn(dialogContentVariants({ size }), customClass)"
|
||||
:class="cn(dialogContentVariants({ size, maximized }), customClass)"
|
||||
>
|
||||
<slot />
|
||||
</DialogContent>
|
||||
|
||||
25
src/components/ui/dialog/DialogMaximize.vue
Normal file
25
src/components/ui/dialog/DialogMaximize.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const { maximized = false } = defineProps<{ maximized?: boolean }>()
|
||||
const emit = defineEmits<{ toggle: [] }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Button
|
||||
:aria-label="maximized ? t('g.restoreDialog') : t('g.maximizeDialog')"
|
||||
size="icon"
|
||||
variant="muted-textonly"
|
||||
@click="emit('toggle')"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
maximized ? 'icon-[lucide--minimize-2]' : 'icon-[lucide--maximize-2]'
|
||||
"
|
||||
/>
|
||||
</Button>
|
||||
</template>
|
||||
@@ -2,7 +2,7 @@ import type { VariantProps } from 'cva'
|
||||
import { cva } from 'cva'
|
||||
|
||||
export const dialogContentVariants = cva({
|
||||
base: 'fixed top-1/2 left-1/2 z-1700 flex max-h-[85vh] w-[calc(100vw-1rem)] -translate-x-1/2 -translate-y-1/2 flex-col rounded-lg border border-border-subtle bg-base-background shadow-lg outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
base: 'fixed z-1700 flex flex-col rounded-lg border border-border-subtle bg-base-background shadow-lg outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
variants: {
|
||||
size: {
|
||||
sm: 'sm:max-w-sm',
|
||||
@@ -10,10 +10,15 @@ export const dialogContentVariants = cva({
|
||||
lg: 'sm:max-w-3xl',
|
||||
xl: 'sm:max-w-5xl',
|
||||
full: 'sm:max-w-[calc(100vw-1rem)]'
|
||||
},
|
||||
maximized: {
|
||||
true: 'inset-2 top-2 left-2 size-auto max-h-none max-w-none sm:max-w-none',
|
||||
false: 'top-1/2 left-1/2 max-h-[85vh] w-[calc(100vw-1rem)] -translate-1/2'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'md'
|
||||
size: 'md',
|
||||
maximized: false
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -22,21 +22,6 @@ export interface CameraState {
|
||||
cameraType: CameraType
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -138,6 +138,8 @@
|
||||
"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,6 +107,12 @@ app.directive('tooltip', Tooltip)
|
||||
app
|
||||
.use(router)
|
||||
.use(PrimeVue, {
|
||||
zIndex: {
|
||||
modal: 1800,
|
||||
overlay: 1800,
|
||||
menu: 1800,
|
||||
tooltip: 1800
|
||||
},
|
||||
theme: {
|
||||
preset: ComfyUIPreset,
|
||||
options: {
|
||||
|
||||
@@ -236,5 +236,8 @@ export const MODEL_NODE_MAPPINGS: ReadonlyArray<
|
||||
['optical_flow', 'OpticalFlowLoader', 'model_name'],
|
||||
|
||||
// ---- WanVideo (ComfyUI-WanVideoWrapper) ----
|
||||
['loras', 'WanVideoLoraSelect', 'lora']
|
||||
['loras', 'WanVideoLoraSelect', 'lora'],
|
||||
|
||||
// ---- LTX-Video IC-LoRA (ComfyUI-LTXVideo) ----
|
||||
['loras', 'LTXICLoRALoaderModelOnly', 'lora_name']
|
||||
] as const satisfies ReadonlyArray<readonly [string, string, string]>
|
||||
|
||||
98
src/platform/settings/composables/useSettingsDialog.test.ts
Normal file
98
src/platform/settings/composables/useSettingsDialog.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* 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,3 +1,5 @@
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
@@ -6,15 +8,20 @@ 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,
|
||||
@@ -22,6 +29,18 @@ 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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
56
src/schemas/subgraphIdSchema.test.ts
Normal file
56
src/schemas/subgraphIdSchema.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
10
src/schemas/subgraphIdSchema.ts
Normal file
10
src/schemas/subgraphIdSchema.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
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
|
||||
}
|
||||
@@ -47,6 +47,11 @@ 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> &
|
||||
|
||||
@@ -87,7 +87,8 @@ const MOCK_NODE_NAMES = [
|
||||
'IPAdapterModelLoader',
|
||||
'LS_LoadSegformerModel',
|
||||
'LoadNLFModel',
|
||||
'FlashVSRNode'
|
||||
'FlashVSRNode',
|
||||
'LTXICLoRALoaderModelOnly'
|
||||
] as const
|
||||
|
||||
const mockNodeDefsByName = Object.fromEntries(
|
||||
@@ -307,7 +308,22 @@ describe('useModelToNodeStore', () => {
|
||||
)
|
||||
|
||||
const loraProviders = modelToNodeStore.getAllNodeProviders('loras')
|
||||
expect(loraProviders).toHaveLength(2)
|
||||
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'
|
||||
})
|
||||
})
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('should return single provider for model type with one node', () => {
|
||||
@@ -561,6 +577,18 @@ 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()
|
||||
|
||||
|
||||
307
src/stores/subgraphNavigationStore.navigateToHash.test.ts
Normal file
307
src/stores/subgraphNavigationStore.navigateToHash.test.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
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,7 +2,11 @@ import QuickLRU from '@alloc/quick-lru'
|
||||
import { useRouteHash } from '@vueuse/router'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref, shallowRef, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
NavigationFailureType,
|
||||
isNavigationFailure,
|
||||
useRouter
|
||||
} from 'vue-router'
|
||||
|
||||
import type { DragAndScaleState } from '@/lib/litegraph/src/DragAndScale'
|
||||
import type { Subgraph } from '@/lib/litegraph/src/litegraph'
|
||||
@@ -10,6 +14,7 @@ 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'
|
||||
@@ -200,20 +205,64 @@ export const useSubgraphNavigationStore = defineStore(
|
||||
{ flush: 'sync' }
|
||||
)
|
||||
|
||||
//Allow navigation with forward/back buttons
|
||||
let blockHashUpdate = false
|
||||
// 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
|
||||
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()
|
||||
if (canvas.graph?.id === locatorId) return
|
||||
const targetGraph =
|
||||
(locatorId || root.id) !== root.id
|
||||
|
||||
const isRoot = locatorId === root.id
|
||||
const targetGraph = isRoot
|
||||
? root
|
||||
: isUuidShapedSubgraphId(locatorId)
|
||||
? root.subgraphs.get(locatorId)
|
||||
: root
|
||||
if (targetGraph) return canvas.setGraph(targetGraph)
|
||||
: undefined
|
||||
if (targetGraph) {
|
||||
if (canvas.graph?.id === targetGraph.id) return
|
||||
return canvas.setGraph(targetGraph)
|
||||
}
|
||||
|
||||
//Search all open workflows
|
||||
for (const workflow of workflowStore.openWorkflows) {
|
||||
@@ -222,29 +271,48 @@ 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
|
||||
// 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.
|
||||
try {
|
||||
blockHashUpdate = true
|
||||
await useWorkflowService().openWorkflow(workflow)
|
||||
} finally {
|
||||
blockHashUpdate = false
|
||||
await withNavBlocked(() =>
|
||||
useWorkflowService().openWorkflow(workflow)
|
||||
)
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'[subgraphNavigation] openWorkflow rejected during recovery',
|
||||
err
|
||||
)
|
||||
return redirectToRoot('workflow load failed')
|
||||
}
|
||||
const targetGraph =
|
||||
const loadedGraph =
|
||||
app.rootGraph.id === locatorId
|
||||
? app.rootGraph
|
||||
: app.rootGraph.subgraphs.get(locatorId)
|
||||
if (!targetGraph) {
|
||||
console.error('subgraph poofed after load?')
|
||||
return
|
||||
if (!loadedGraph) {
|
||||
return redirectToRoot('subgraph not found after workflow load')
|
||||
}
|
||||
if (canvas.graph?.id === loadedGraph.id) return
|
||||
return canvas.setGraph(loadedGraph)
|
||||
}
|
||||
}
|
||||
|
||||
return canvas.setGraph(targetGraph)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function updateHash() {
|
||||
if (blockHashUpdate) return
|
||||
if (blockNavDepth > 0) return
|
||||
if (initialLoad) {
|
||||
initialLoad = false
|
||||
if (!routeHash.value) return
|
||||
@@ -255,16 +323,22 @@ export const useSubgraphNavigationStore = defineStore(
|
||||
}
|
||||
|
||||
const newId = canvasStore.getCanvas().graph?.id ?? ''
|
||||
if (!routeHash.value) await router.replace('#' + app.rootGraph.id)
|
||||
if (!routeHash.value) {
|
||||
await safeRouterCall(
|
||||
() => router.replace('#' + app.rootGraph.id),
|
||||
'router.replace'
|
||||
)
|
||||
}
|
||||
const currentId = routeHash.value?.slice(1)
|
||||
if (!newId || newId === currentId) return
|
||||
|
||||
await router.push('#' + newId)
|
||||
await safeRouterCall(() => router.push('#' + newId), 'router.push')
|
||||
}
|
||||
//update navigation hash
|
||||
//NOTE: Doesn't apply on workflow load
|
||||
watch(() => canvasStore.currentGraph, updateHash)
|
||||
watch(routeHash, () => navigateToHash(String(routeHash.value)))
|
||||
watch(routeHash, () => {
|
||||
if (blockNavDepth > 0) return
|
||||
void 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