mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-12 16:40:05 +00:00
feat: replace loading indicator with C logo fill loader and pre-Vue splash screen (#9516)
This commit is contained in:
56
index.html
56
index.html
File diff suppressed because one or more lines are too long
51
public/splash.css
Normal file
51
public/splash.css
Normal file
@@ -0,0 +1,51 @@
|
||||
/* Pre-Vue splash loader — colors set by inline script */
|
||||
#splash-loader {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
contain: strict;
|
||||
}
|
||||
#splash-loader svg {
|
||||
width: min(200px, 50vw);
|
||||
height: auto;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
#splash-loader .wave-group {
|
||||
animation: splash-rise 4s ease-in-out infinite alternate;
|
||||
will-change: transform;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
#splash-loader .wave-path {
|
||||
animation: splash-wave 1.2s linear infinite;
|
||||
will-change: transform;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
@keyframes splash-rise {
|
||||
from {
|
||||
transform: translateY(280px);
|
||||
}
|
||||
to {
|
||||
transform: translateY(-80px);
|
||||
}
|
||||
}
|
||||
@keyframes splash-wave {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
transform: translateX(-880px);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#splash-loader .wave-group,
|
||||
#splash-loader .wave-path {
|
||||
animation: none;
|
||||
}
|
||||
#splash-loader .wave-group {
|
||||
transform: translateY(-80px);
|
||||
}
|
||||
}
|
||||
19
src/App.vue
19
src/App.vue
@@ -2,20 +2,13 @@
|
||||
<router-view />
|
||||
<GlobalDialog />
|
||||
<BlockUI full-screen :blocked="isLoading" />
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="pointer-events-none fixed inset-0 z-1200 flex items-center justify-center"
|
||||
>
|
||||
<LogoComfyWaveLoader size="xl" color="yellow" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { captureException } from '@sentry/vue'
|
||||
import BlockUI from 'primevue/blockui'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { computed, onMounted, watch } from 'vue'
|
||||
|
||||
import LogoComfyWaveLoader from '@/components/loader/LogoComfyWaveLoader.vue'
|
||||
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
|
||||
import config from '@/config'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
@@ -31,6 +24,16 @@ app.extensionManager = useWorkspaceStore()
|
||||
const conflictDetection = useConflictDetection()
|
||||
const isLoading = computed<boolean>(() => workspaceStore.spinner)
|
||||
|
||||
watch(
|
||||
isLoading,
|
||||
(loading, prevLoading) => {
|
||||
if (prevLoading && !loading) {
|
||||
document.getElementById('splash-loader')?.remove()
|
||||
}
|
||||
},
|
||||
{ flush: 'post' }
|
||||
)
|
||||
|
||||
const showContextMenu = (event: MouseEvent) => {
|
||||
const { target } = event
|
||||
switch (true) {
|
||||
|
||||
@@ -479,50 +479,53 @@ useEventListener(
|
||||
onMounted(async () => {
|
||||
comfyApp.vueAppReady = true
|
||||
workspaceStore.spinner = true
|
||||
// ChangeTracker needs to be initialized before setup, as it will overwrite
|
||||
// some listeners of litegraph canvas.
|
||||
ChangeTracker.init()
|
||||
try {
|
||||
// ChangeTracker needs to be initialized before setup, as it will overwrite
|
||||
// some listeners of litegraph canvas.
|
||||
ChangeTracker.init()
|
||||
|
||||
await until(() => isSettingsReady.value || !!settingsError.value).toBe(true)
|
||||
await until(() => isSettingsReady.value || !!settingsError.value).toBe(true)
|
||||
|
||||
if (settingsError.value) {
|
||||
if (settingsError.value instanceof UnauthorizedError) {
|
||||
localStorage.removeItem('Comfy.userId')
|
||||
localStorage.removeItem('Comfy.userName')
|
||||
window.location.reload()
|
||||
return
|
||||
if (settingsError.value) {
|
||||
if (settingsError.value instanceof UnauthorizedError) {
|
||||
localStorage.removeItem('Comfy.userId')
|
||||
localStorage.removeItem('Comfy.userName')
|
||||
window.location.reload()
|
||||
return
|
||||
}
|
||||
throw settingsError.value
|
||||
}
|
||||
throw settingsError.value
|
||||
|
||||
// Register core settings immediately after settings are ready
|
||||
CORE_SETTINGS.forEach(settingStore.addSetting)
|
||||
|
||||
await Promise.all([
|
||||
until(() => isI18nReady.value || !!i18nError.value).toBe(true),
|
||||
useNewUserService().initializeIfNewUser()
|
||||
])
|
||||
if (i18nError.value) {
|
||||
console.warn(
|
||||
'[GraphCanvas] Failed to load custom nodes i18n:',
|
||||
i18nError.value
|
||||
)
|
||||
}
|
||||
|
||||
// @ts-expect-error fixme ts strict error
|
||||
await comfyApp.setup(canvasRef.value)
|
||||
canvasStore.canvas = comfyApp.canvas
|
||||
canvasStore.canvas.render_canvas_border = false
|
||||
useSearchBoxStore().setPopoverRef(nodeSearchboxPopoverRef.value)
|
||||
|
||||
window.app = comfyApp
|
||||
window.graph = comfyApp.graph
|
||||
|
||||
comfyAppReady.value = true
|
||||
|
||||
vueNodeLifecycle.setupEmptyGraphListener()
|
||||
} finally {
|
||||
workspaceStore.spinner = false
|
||||
}
|
||||
|
||||
// Register core settings immediately after settings are ready
|
||||
CORE_SETTINGS.forEach(settingStore.addSetting)
|
||||
|
||||
await Promise.all([
|
||||
until(() => isI18nReady.value || !!i18nError.value).toBe(true),
|
||||
useNewUserService().initializeIfNewUser()
|
||||
])
|
||||
if (i18nError.value) {
|
||||
console.warn(
|
||||
'[GraphCanvas] Failed to load custom nodes i18n:',
|
||||
i18nError.value
|
||||
)
|
||||
}
|
||||
|
||||
// @ts-expect-error fixme ts strict error
|
||||
await comfyApp.setup(canvasRef.value)
|
||||
canvasStore.canvas = comfyApp.canvas
|
||||
canvasStore.canvas.render_canvas_border = false
|
||||
workspaceStore.spinner = false
|
||||
useSearchBoxStore().setPopoverRef(nodeSearchboxPopoverRef.value)
|
||||
|
||||
window.app = comfyApp
|
||||
window.graph = comfyApp.graph
|
||||
|
||||
comfyAppReady.value = true
|
||||
|
||||
vueNodeLifecycle.setupEmptyGraphListener()
|
||||
|
||||
comfyApp.canvas.onSelectionChange = useChainCallback(
|
||||
comfyApp.canvas.onSelectionChange,
|
||||
() => canvasStore.updateSelectedItems()
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import LogoCFillLoader from './LogoCFillLoader.vue'
|
||||
|
||||
const meta: Meta<typeof LogoCFillLoader> = {
|
||||
title: 'Components/Loader/LogoCFillLoader',
|
||||
component: LogoCFillLoader,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
backgrounds: { default: 'dark' }
|
||||
},
|
||||
argTypes: {
|
||||
size: {
|
||||
control: 'select',
|
||||
options: ['sm', 'md', 'lg', 'xl']
|
||||
},
|
||||
color: {
|
||||
control: 'select',
|
||||
options: ['yellow', 'blue', 'white', 'black']
|
||||
},
|
||||
bordered: {
|
||||
control: 'boolean'
|
||||
},
|
||||
disableAnimation: {
|
||||
control: 'boolean'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {}
|
||||
|
||||
export const Small: Story = {
|
||||
args: { size: 'sm' }
|
||||
}
|
||||
|
||||
export const Large: Story = {
|
||||
args: { size: 'lg' }
|
||||
}
|
||||
|
||||
export const ExtraLarge: Story = {
|
||||
args: { size: 'xl' }
|
||||
}
|
||||
|
||||
export const NoBorder: Story = {
|
||||
args: { bordered: false }
|
||||
}
|
||||
|
||||
export const Static: Story = {
|
||||
args: { disableAnimation: true }
|
||||
}
|
||||
|
||||
export const BrandColors: Story = {
|
||||
render: () => ({
|
||||
components: { LogoCFillLoader },
|
||||
template: `
|
||||
<div class="flex items-end gap-12">
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<span class="text-xs text-neutral-400">Yellow</span>
|
||||
<LogoCFillLoader size="lg" color="yellow" />
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<span class="text-xs text-neutral-400">Blue</span>
|
||||
<LogoCFillLoader size="lg" color="blue" />
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<span class="text-xs text-neutral-400">White</span>
|
||||
<LogoCFillLoader size="lg" color="white" />
|
||||
</div>
|
||||
<div class="p-4 bg-white rounded" style="background: white">
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<span class="text-xs text-neutral-600">Black</span>
|
||||
<LogoCFillLoader size="lg" color="black" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const AllSizes: Story = {
|
||||
render: () => ({
|
||||
components: { LogoCFillLoader },
|
||||
template: `
|
||||
<div class="flex items-end gap-8">
|
||||
<LogoCFillLoader size="sm" color="yellow" />
|
||||
<LogoCFillLoader size="md" color="yellow" />
|
||||
<LogoCFillLoader size="lg" color="yellow" />
|
||||
<LogoCFillLoader size="xl" color="yellow" />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
<template>
|
||||
<span role="status" :class="cn('inline-flex', colorClass)">
|
||||
<svg
|
||||
:width="Math.round(heightMap[size] * (VB_W / VB_H))"
|
||||
:height="heightMap[size]"
|
||||
:viewBox="`0 0 ${VB_W} ${VB_H}`"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<defs>
|
||||
<mask :id="maskId">
|
||||
<path :d="C_PATH" fill="white" />
|
||||
</mask>
|
||||
</defs>
|
||||
<path
|
||||
v-if="bordered"
|
||||
:d="C_PATH"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
fill="none"
|
||||
opacity="0.4"
|
||||
/>
|
||||
<g :mask="`url(#${maskId})`">
|
||||
<rect
|
||||
:class="disableAnimation ? undefined : 'c-fill-rect'"
|
||||
:x="-BLEED"
|
||||
:y="-BLEED"
|
||||
:width="VB_W + BLEED * 2"
|
||||
:height="VB_H + BLEED * 2"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
<span class="sr-only">{{ t('g.loading') }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useId, computed } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const {
|
||||
size = 'md',
|
||||
color = 'black',
|
||||
bordered = true,
|
||||
disableAnimation = false
|
||||
} = defineProps<{
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl'
|
||||
color?: 'yellow' | 'blue' | 'white' | 'black'
|
||||
bordered?: boolean
|
||||
disableAnimation?: boolean
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const maskId = `c-mask-${useId()}`
|
||||
|
||||
const VB_W = 185
|
||||
const VB_H = 201
|
||||
const BLEED = 1
|
||||
|
||||
// Larger than LogoComfyWaveLoader because the C logo is near-square (185×201)
|
||||
// while the COMFY wordmark is wide (879×284), so larger heights are needed
|
||||
// for visually comparable perceived size.
|
||||
const heightMap = { sm: 48, md: 80, lg: 120, xl: 200 } as const
|
||||
const colorMap = {
|
||||
yellow: 'text-brand-yellow',
|
||||
blue: 'text-brand-blue',
|
||||
white: 'text-white',
|
||||
black: 'text-black'
|
||||
} as const
|
||||
|
||||
const colorClass = computed(() => colorMap[color])
|
||||
|
||||
const C_PATH =
|
||||
'M42.1217 200.812C37.367 200.812 33.5304 199.045 31.0285 195.703C28.4569 192.27 27.7864 187.477 29.1882 182.557L34.8172 162.791C35.2661 161.217 34.9537 159.523 33.9747 158.214C32.9958 156.908 31.464 156.139 29.8371 156.139L13.6525 156.139C8.89521 156.139 5.05862 154.374 2.55797 151.032C-0.0136533 147.597-0.684085 142.804 0.71869 137.883L20.0565 70.289L22.1916 62.8625C25.0617 52.7847 35.5288 44.5943 45.528 44.5943L64.8938 44.5943C67.2048 44.5943 69.2376 43.0535 69.8738 40.8175L76.2782 18.3344C79.1454 8.26681 89.6127 0.0763962 99.6117 0.0763945L141.029 0.00258328L171.349-2.99253e-05C176.104-3.0756e-05 179.941 1.765 182.442 5.10626C185.013 8.53932 185.684 13.3324 184.282 18.2528L175.612 48.6947C172.746 58.7597 162.279 66.9475 152.28 66.9475L110.771 67.0265L91.4113 67.0265C89.1029 67.0265 87.0727 68.5647 86.4326 70.7983L70.2909 127.179C69.8394 128.756 70.1518 130.454 71.1334 131.763C72.1123 133.07 73.6441 133.839 75.2697 133.839C75.2736 133.839 102.699 133.785 102.699 133.785L132.929 133.785C137.685 133.785 141.522 135.55 144.023 138.892C146.594 142.327 147.265 147.12 145.862 152.041L137.192 182.478C134.326 192.545 123.859 200.733 113.86 200.733L72.3517 200.812L42.1217 200.812Z'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.c-fill-rect {
|
||||
animation: c-fill-up 2.5s cubic-bezier(0.25, 0, 0.3, 1) forwards;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
@keyframes c-fill-up {
|
||||
0% {
|
||||
transform: translateY(calc(v-bind(VB_H) * 1px + v-bind(BLEED) * 1px));
|
||||
}
|
||||
100% {
|
||||
transform: translateY(calc(v-bind(BLEED) * -1px));
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.c-fill-rect {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -81,18 +81,16 @@ describe('WorkspaceAuthGate', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('[data-testid="slot-content"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[role="status"]').exists()).toBe(false)
|
||||
expect(mockRefreshRemoteConfig).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('cloud builds - unauthenticated user', () => {
|
||||
it('shows spinner while waiting for Firebase auth', () => {
|
||||
it('hides slot while waiting for Firebase auth', () => {
|
||||
mockIsInitialized.value = false
|
||||
|
||||
const wrapper = mountComponent()
|
||||
|
||||
expect(wrapper.find('[role="status"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-testid="slot-content"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
@@ -100,7 +98,7 @@ describe('WorkspaceAuthGate', () => {
|
||||
mockIsInitialized.value = false
|
||||
|
||||
const wrapper = mountComponent()
|
||||
expect(wrapper.find('[role="status"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-testid="slot-content"]').exists()).toBe(false)
|
||||
|
||||
mockIsInitialized.value = true
|
||||
mockCurrentUser.value = null
|
||||
@@ -179,8 +177,8 @@ describe('WorkspaceAuthGate', () => {
|
||||
const wrapper = mountComponent()
|
||||
await flushPromises()
|
||||
|
||||
// Still showing spinner before timeout
|
||||
expect(wrapper.find('[role="status"]').exists()).toBe(true)
|
||||
// Slot not yet rendered before timeout
|
||||
expect(wrapper.find('[data-testid="slot-content"]').exists()).toBe(false)
|
||||
|
||||
// Advance past the 10 second timeout
|
||||
await vi.advanceTimersByTimeAsync(10_001)
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
<template>
|
||||
<slot v-if="isReady" />
|
||||
<div
|
||||
v-else
|
||||
class="fixed inset-0 z-1100 flex items-center justify-center bg-(--p-mask-background)"
|
||||
>
|
||||
<LogoComfyWaveLoader size="xl" color="yellow" disable-animation />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -20,6 +14,9 @@
|
||||
*
|
||||
* This prevents race conditions where API calls use Firebase tokens
|
||||
* instead of workspace tokens when the workspace feature is enabled.
|
||||
*
|
||||
* The splash loader in index.html (z-9999) covers the screen during this
|
||||
* phase, so no separate loading indicator is needed here.
|
||||
*/
|
||||
import { promiseTimeout, until } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
@@ -30,7 +27,6 @@ import { isCloud } from '@/platform/distribution/types'
|
||||
import { refreshRemoteConfig } from '@/platform/remoteConfig/refreshRemoteConfig'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
import LogoComfyWaveLoader from '@/components/loader/LogoComfyWaveLoader.vue'
|
||||
|
||||
const FIREBASE_INIT_TIMEOUT_MS = 16_000
|
||||
const CONFIG_REFRESH_TIMEOUT_MS = 10_000
|
||||
|
||||
@@ -101,7 +101,7 @@ export const useColorPaletteService = () => {
|
||||
linkColorPalette: Colors['node_slot']
|
||||
) {
|
||||
if (!linkColorPalette) return
|
||||
const rootStyle = document.body?.style
|
||||
const rootStyle = document.documentElement?.style
|
||||
if (!rootStyle) return
|
||||
|
||||
for (const dataType of nodeDefStore.nodeDataTypes) {
|
||||
@@ -121,7 +121,7 @@ export const useColorPaletteService = () => {
|
||||
colorPaletteId: string
|
||||
) {
|
||||
if (!palette) return
|
||||
const rootStyle = document.body?.style
|
||||
const rootStyle = document.documentElement?.style
|
||||
if (!rootStyle) return
|
||||
|
||||
for (const themeVar of Object.keys(THEME_PROPERTY_MAP)) {
|
||||
@@ -206,7 +206,10 @@ export const useColorPaletteService = () => {
|
||||
*
|
||||
* @param comfyColorPalette - The palette to set.
|
||||
*/
|
||||
const loadComfyColorPalette = (comfyColorPalette: Colors['comfy_base']) => {
|
||||
const loadComfyColorPalette = (
|
||||
comfyColorPalette: Colors['comfy_base'],
|
||||
isLightTheme: boolean
|
||||
) => {
|
||||
if (!comfyColorPalette) return
|
||||
const rootStyle = document.documentElement.style
|
||||
for (const [key, value] of Object.entries(comfyColorPalette)) {
|
||||
@@ -228,6 +231,14 @@ export const useColorPaletteService = () => {
|
||||
} else {
|
||||
rootStyle.removeProperty('--bg-img')
|
||||
}
|
||||
|
||||
try {
|
||||
const splashBg = isLightTheme ? '#FFFFFF' : comfyColorPalette['bg-color']
|
||||
localStorage.setItem('comfy-splash-bg', splashBg)
|
||||
localStorage.setItem('comfy-splash-fg', comfyColorPalette['fg-color'])
|
||||
} catch (_) {
|
||||
/* empty */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -249,7 +260,10 @@ export const useColorPaletteService = () => {
|
||||
colorPaletteId
|
||||
)
|
||||
loadLinkColorPaletteForVueNodes(completedPalette.colors.node_slot)
|
||||
loadComfyColorPalette(completedPalette.colors.comfy_base)
|
||||
loadComfyColorPalette(
|
||||
completedPalette.colors.comfy_base,
|
||||
completedPalette.light_theme === true
|
||||
)
|
||||
app.canvas.setDirty(true, true)
|
||||
|
||||
colorPaletteStore.activePaletteId = colorPaletteId
|
||||
|
||||
@@ -132,7 +132,6 @@ watch(
|
||||
} else {
|
||||
document.body.classList.add(DARK_THEME_CLASS)
|
||||
}
|
||||
|
||||
if (isDesktop) {
|
||||
electronAPI().changeTheme({
|
||||
color: 'rgba(0, 0, 0, 0)',
|
||||
|
||||
@@ -87,6 +87,8 @@ const login = async () => {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
document.getElementById('splash-loader')?.remove()
|
||||
|
||||
if (!userStore.initialized) {
|
||||
await userStore.initialize()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user