Floating Menus - UI rework (#5980)

## Summary

Enhancing and further modernizing the UI, giving users more usable area
whilst keeping farmiliar positioning and feel of elements.

## Changes

- **What**: Significant restructure of the UI elements, changing
elements from large blocks to floating elements, updating:
- Side toolbar menu (floating style, supports small/normal mode,
combines to scroll on height overflow)
- Bottom tabs panel (floating style, tabs redesigned)
- Action bar (support for docking/undocking menu)
    - Added login/user menu button to top right
- Restyled breadcrumbs (still collapse when overflows)
- Add litegraph support for fps info position (so it isn't covered by
the sidebar)

- **Breaking**: 
- Removed various elements and added new ones, I have tested custom
sidebars, custom actions, etc but if scripts are inserting elements into
"other" elements they may have been (re)moved.
- Remove support for bottom menu
- Remove support for 2nd-row tabs

## Screenshots 
<img width="1116" height="907" alt="ui"
src="https://github.com/user-attachments/assets/b040a215-67d3-4c88-8c4d-f402a16a34f6"
/>


https://github.com/user-attachments/assets/571dbda5-01ec-47e8-b235-ee1b88c93dd0

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5980-Floating-Menus-UI-rework-2866d73d3650810aac60cc1afe979b60)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
pythongosssss
2025-10-17 02:12:09 +01:00
committed by GitHub
parent 8cc5b52c64
commit 984ebef416
59 changed files with 793 additions and 533 deletions

View File

@@ -0,0 +1,311 @@
<template>
<div
class="comfy-menu-button-wrapper flex shrink-0 cursor-pointer flex-col items-center justify-center rounded-t-md p-2 transition-colors"
:class="{
'comfy-menu-button-active': menuRef?.visible
}"
@click="menuRef?.toggle($event)"
>
<ComfyLogoTransparent
alt="ComfyUI Logo"
class="comfyui-logo h-[18px] w-[18px]"
/>
<span
v-if="!isSmall"
class="side-bar-button-label mt-1 text-center text-[10px]"
>{{ t('sideToolbar.labels.menu') }}</span
>
</div>
<TieredMenu
ref="menuRef"
:model="translatedItems"
:popup="true"
class="comfy-command-menu"
@show="onMenuShow"
>
<template #item="{ item, props }">
<a
class="p-menubar-item-link px-4 py-2"
v-bind="props.action"
:href="item.url"
target="_blank"
:class="typeof item.class === 'function' ? item.class() : item.class"
@mousedown="
isZoomCommand(item) ? handleZoomMouseDown(item, $event) : undefined
"
@click="handleItemClick(item, $event)"
>
<i
v-if="hasActiveStateSiblings(item)"
class="p-menubar-item-icon pi pi-check text-sm"
:class="{ invisible: !item.comfyCommand?.active?.() }"
/>
<span
v-else-if="
item.icon && item.comfyCommand?.id !== 'Comfy.NewBlankWorkflow'
"
class="p-menubar-item-icon text-sm"
:class="item.icon"
/>
<span class="p-menubar-item-label text-nowrap">{{ item.label }}</span>
<i
v-if="item.comfyCommand?.id === 'Comfy.NewBlankWorkflow'"
class="ml-auto"
:class="item.icon"
/>
<span
v-if="item?.comfyCommand?.keybinding"
class="keybinding-tag ml-auto rounded border border-surface p-1 text-xs text-nowrap text-muted"
>
{{ item.comfyCommand.keybinding.combo.toString() }}
</span>
<i v-if="item.items" class="pi pi-angle-right ml-auto" />
</a>
</template>
</TieredMenu>
</template>
<script setup lang="ts">
import type { MenuItem } from 'primevue/menuitem'
import TieredMenu from 'primevue/tieredmenu'
import type { TieredMenuMethods, TieredMenuState } from 'primevue/tieredmenu'
import { computed, nextTick, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import SettingDialogHeader from '@/components/dialog/header/SettingDialogHeader.vue'
import ComfyLogoTransparent from '@/components/icons/ComfyLogoTransparent.vue'
import SettingDialogContent from '@/platform/settings/components/SettingDialogContent.vue'
import { useColorPaletteService } from '@/services/colorPaletteService'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import { useMenuItemStore } from '@/stores/menuItemStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { whileMouseDown } from '@/utils/mouseDownUtil'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
const { t } = useI18n()
const commandStore = useCommandStore()
const menuItemStore = useMenuItemStore()
const colorPaletteStore = useColorPaletteStore()
const colorPaletteService = useColorPaletteService()
const dialogStore = useDialogStore()
const managerState = useManagerState()
const { isSmall = false } = defineProps<{
isSmall?: boolean
}>()
const menuRef = ref<
({ dirty: boolean } & TieredMenuMethods & TieredMenuState) | null
>(null)
const translateMenuItem = (item: MenuItem): MenuItem => {
const label = typeof item.label === 'function' ? item.label() : item.label
const translatedLabel = label
? t(`menuLabels.${normalizeI18nKey(label)}`, label)
: undefined
return {
...item,
label: translatedLabel,
items: item.items?.map(translateMenuItem)
}
}
const showSettings = (defaultPanel?: string) => {
dialogStore.showDialog({
key: 'global-settings',
headerComponent: SettingDialogHeader,
component: SettingDialogContent,
props: {
defaultPanel
}
})
}
const showManageExtensions = async () => {
await managerState.openManager({
initialTab: ManagerTab.All,
showToastOnLegacyError: false
})
}
const themeMenuItems = computed(() => {
return colorPaletteStore.palettes.map((palette) => ({
key: `theme-${palette.id}`,
label: palette.name,
parentPath: 'theme',
comfyCommand: {
active: () => colorPaletteStore.activePaletteId === palette.id
},
command: async () => {
await colorPaletteService.loadColorPalette(palette.id)
}
}))
})
const extraMenuItems = computed(() => [
{ separator: true },
{
key: 'theme',
label: t('menu.theme'),
items: themeMenuItems.value
},
{ separator: true },
{
key: 'browse-templates',
label: t('menuLabels.Browse Templates'),
icon: 'icon-[comfy--template]',
command: () => commandStore.execute('Comfy.BrowseTemplates')
},
{
key: 'settings',
label: t('g.settings'),
icon: 'mdi mdi-cog-outline',
command: () => showSettings()
},
{
key: 'manage-extensions',
label: t('menu.manageExtensions'),
icon: 'mdi mdi-puzzle-outline',
command: showManageExtensions
}
])
const translatedItems = computed(() => {
const items = menuItemStore.menuItems.map(translateMenuItem)
let helpIndex = items.findIndex((item) => item.key === 'Help')
let helpItem: MenuItem | undefined
if (helpIndex !== -1) {
items[helpIndex].icon = 'mdi mdi-help-circle-outline'
// If help is not the last item (i.e. we have extension commands), separate them
const isLastItem = helpIndex !== items.length - 1
helpItem = items.splice(
helpIndex,
1,
...(isLastItem
? [
{
separator: true
}
]
: [])
)[0]
}
helpIndex = items.length
items.splice(
helpIndex,
0,
...extraMenuItems.value,
...(helpItem
? [
{
separator: true
},
helpItem
]
: [])
)
return items
})
const onMenuShow = () => {
void nextTick(() => {
// Force the menu to show submenus on hover
if (menuRef.value) {
menuRef.value.dirty = true
}
})
}
const isZoomCommand = (item: MenuItem) => {
return (
item.comfyCommand?.id === 'Comfy.Canvas.ZoomIn' ||
item.comfyCommand?.id === 'Comfy.Canvas.ZoomOut'
)
}
const handleZoomMouseDown = (item: MenuItem, event: MouseEvent) => {
if (item.comfyCommand) {
whileMouseDown(
event,
async () => {
await commandStore.execute(item.comfyCommand!.id)
},
50
)
}
}
const handleItemClick = (item: MenuItem, event: MouseEvent) => {
// Prevent the menu from closing for zoom commands or commands that have active state
if (isZoomCommand(item) || item.comfyCommand?.active) {
event.preventDefault()
event.stopPropagation()
if (item.comfyCommand?.active) {
item.command?.({
item,
originalEvent: event
})
}
return false
}
}
const hasActiveStateSiblings = (item: MenuItem): boolean => {
// Check if this item has siblings with active state (either from store or theme items)
return (
item.parentPath &&
(item.parentPath === 'theme' ||
menuItemStore.menuItemHasActiveStateChildren[item.parentPath])
)
}
</script>
<style scoped>
.comfy-menu-button-wrapper {
width: var(--sidebar-width);
height: var(--sidebar-item-height);
}
.comfy-menu-button-wrapper:hover {
background: var(--p-button-text-secondary-hover-background);
}
.comfy-menu-button-active,
.comfy-menu-button-active:hover {
background-color: var(--content-hover-bg);
}
.keybinding-tag {
background: var(--p-content-hover-background);
border-color: var(--p-content-border-color);
border-style: solid;
}
</style>
<style>
.comfy-command-menu {
--p-tieredmenu-item-focus-background: color-mix(
in srgb,
var(--fg-color) 15%,
transparent
);
--p-tieredmenu-item-active-background: color-mix(
in srgb,
var(--fg-color) 10%,
transparent
);
}
.comfy-command-menu ul {
background-color: var(--comfy-menu-bg) !important;
}
</style>

View File

@@ -1,48 +1,69 @@
<template>
<teleport :to="teleportTarget">
<nav class="side-tool-bar-container" :class="{ 'small-sidebar': isSmall }">
<SidebarIcon
v-for="tab in tabs"
:key="tab.id"
:icon="tab.icon"
:icon-badge="tab.iconBadge"
:tooltip="tab.tooltip"
:tooltip-suffix="getTabTooltipSuffix(tab)"
:label="tab.label || tab.title"
:is-small="isSmall"
:selected="tab.id === selectedTab?.id"
:class="tab.id + '-tab-button'"
@click="onTabClick(tab)"
/>
<SidebarTemplatesButton />
<div class="side-tool-bar-end">
<SidebarLogoutIcon v-if="userStore.isMultiUserServer" />
<SidebarHelpCenterIcon />
<SidebarBottomPanelToggleButton />
<SidebarShortcutsToggleButton />
</div>
</nav>
</teleport>
<div
v-if="selectedTab"
class="sidebar-content-container h-full overflow-x-hidden overflow-y-auto"
<nav
ref="sideToolbarRef"
class="side-tool-bar-container flex h-full flex-col items-center bg-transparent [.floating-sidebar]:-mr-2"
:class="{
'small-sidebar': isSmall,
'connected-sidebar': isConnected,
'floating-sidebar': !isConnected,
'overflowing-sidebar': isOverflowing
}"
>
<ExtensionSlot :extension="selectedTab" />
</div>
<div
ref="contentMeasureRef"
:class="
isOverflowing
? 'side-tool-bar-container overflow-y-auto'
: 'flex flex-col h-full'
"
>
<div ref="topToolbarRef" :class="groupClasses">
<ComfyMenuButton :is-small="isSmall" />
<SidebarIcon
v-for="tab in tabs"
:key="tab.id"
:icon="tab.icon"
:icon-badge="tab.iconBadge"
:tooltip="tab.tooltip"
:tooltip-suffix="getTabTooltipSuffix(tab)"
:label="tab.label || tab.title"
:is-small="isSmall"
:selected="tab.id === selectedTab?.id"
:class="tab.id + '-tab-button'"
@click="onTabClick(tab)"
/>
<SidebarTemplatesButton />
</div>
<div ref="bottomToolbarRef" class="mt-auto" :class="groupClasses">
<SidebarLogoutIcon
v-if="userStore.isMultiUserServer"
:is-small="isSmall"
/>
<SidebarHelpCenterIcon :is-small="isSmall" />
<SidebarBottomPanelToggleButton :is-small="isSmall" />
<SidebarShortcutsToggleButton :is-small="isSmall" />
</div>
</div>
</nav>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useResizeObserver } from '@vueuse/core'
import { debounce } from 'es-toolkit/compat'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
import ComfyMenuButton from '@/components/sidebar/ComfyMenuButton.vue'
import SidebarBottomPanelToggleButton from '@/components/sidebar/SidebarBottomPanelToggleButton.vue'
import SidebarShortcutsToggleButton from '@/components/sidebar/SidebarShortcutsToggleButton.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCommandStore } from '@/stores/commandStore'
import { useKeybindingStore } from '@/stores/keybindingStore'
import { useUserStore } from '@/stores/userStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import type { SidebarTabExtension } from '@/types/extensionTypes'
import { cn } from '@/utils/tailwindUtil'
import SidebarHelpCenterIcon from './SidebarHelpCenterIcon.vue'
import SidebarIcon from './SidebarIcon.vue'
@@ -53,16 +74,25 @@ const workspaceStore = useWorkspaceStore()
const settingStore = useSettingStore()
const userStore = useUserStore()
const commandStore = useCommandStore()
const teleportTarget = computed(() =>
settingStore.get('Comfy.Sidebar.Location') === 'left'
? '.comfyui-body-left'
: '.comfyui-body-right'
)
const canvasStore = useCanvasStore()
const sideToolbarRef = ref<HTMLElement>()
const contentMeasureRef = ref<HTMLElement>()
const topToolbarRef = ref<HTMLElement>()
const bottomToolbarRef = ref<HTMLElement>()
const isSmall = computed(
() => settingStore.get('Comfy.Sidebar.Size') === 'small'
)
const sidebarLocation = computed<'left' | 'right'>(() =>
settingStore.get('Comfy.Sidebar.Location')
)
const sidebarStyle = computed(() => settingStore.get('Comfy.Sidebar.Style'))
const isConnected = computed(
() =>
selectedTab.value ||
isOverflowing.value ||
sidebarStyle.value === 'connected'
)
const tabs = computed(() => workspaceStore.getSidebarTabs())
const selectedTab = computed(() => workspaceStore.sidebarTab.activeSidebarTab)
@@ -79,6 +109,68 @@ const getTabTooltipSuffix = (tab: SidebarTabExtension) => {
)
return keybinding ? ` (${keybinding.combo.toString()})` : ''
}
const isOverflowing = ref(false)
const groupClasses = computed(() =>
cn(
'sidebar-item-group flex flex-col items-center overflow-hidden flex-shrink-0' +
(isConnected.value ? '' : ' rounded-lg shadow-md')
)
)
const ENTER_OVERFLOW_MARGIN = 20
const EXIT_OVERFLOW_MARGIN = 50
const checkOverflow = debounce(() => {
if (!sideToolbarRef.value || !topToolbarRef.value || !bottomToolbarRef.value)
return
const containerHeight = sideToolbarRef.value.clientHeight
const topHeight = topToolbarRef.value.scrollHeight
const bottomHeight = bottomToolbarRef.value.scrollHeight
const contentHeight = topHeight + bottomHeight
if (isOverflowing.value) {
isOverflowing.value = containerHeight < contentHeight + EXIT_OVERFLOW_MARGIN
} else {
isOverflowing.value =
containerHeight < contentHeight + ENTER_OVERFLOW_MARGIN
}
}, 16)
onMounted(() => {
if (!sideToolbarRef.value) return
const overflowObserver = useResizeObserver(
sideToolbarRef.value,
checkOverflow
)
checkOverflow()
onBeforeUnmount(() => {
overflowObserver.stop()
})
watch(
[isSmall, sidebarLocation],
async () => {
if (canvasStore.canvas) {
if (sidebarLocation.value === 'left') {
await nextTick()
canvasStore.canvas.fpsInfoLocation = [
sideToolbarRef.value?.getBoundingClientRect()?.right,
null
]
} else {
canvasStore.canvas.fpsInfoLocation = null
}
canvasStore.canvas.setDirty(false, true)
}
},
{ immediate: true }
)
})
</script>
<style>
@@ -88,36 +180,64 @@ const getTabTooltipSuffix = (tab: SidebarTabExtension) => {
* but need to reference sidebar dimensions for proper positioning.
*/
:root {
--sidebar-width: 4rem;
--sidebar-padding: 8px;
--sidebar-icon-size: 1rem;
--sidebar-default-floating-width: 56px;
--sidebar-default-connected-width: calc(
var(--sidebar-default-floating-width) + var(--sidebar-padding) * 2
);
--sidebar-default-item-height: 56px;
--sidebar-small-floating-width: 48px;
--sidebar-small-connected-width: calc(
var(--sidebar-small-floating-width) + var(--sidebar-padding) * 2
);
--sidebar-small-item-height: 48px;
--sidebar-width: var(--sidebar-default-floating-width);
--sidebar-item-height: var(--sidebar-default-item-height);
}
:root:has(.side-tool-bar-container.small-sidebar) {
--sidebar-width: 2.5rem;
--sidebar-width: var(--sidebar-small-floating-width);
--sidebar-item-height: var(--sidebar-small-item-height);
}
:root:has(.side-tool-bar-container.connected-sidebar) {
--sidebar-width: var(--sidebar-default-connected-width);
}
:root:has(.side-tool-bar-container.small-sidebar.connected-sidebar) {
--sidebar-width: var(--sidebar-small-connected-width);
}
</style>
<style scoped>
.side-tool-bar-container {
display: flex;
flex-direction: column;
align-items: center;
@reference "tailwindcss";
width: var(--sidebar-width);
height: 100%;
background-color: var(--comfy-menu-secondary-bg);
color: var(--fg-color);
box-shadow: var(--bar-shadow);
.floating-sidebar {
padding: var(--sidebar-padding);
}
.side-tool-bar-container.small-sidebar {
--sidebar-width: 2.5rem;
--sidebar-icon-size: 1rem;
.floating-sidebar .sidebar-item-group {
border-color: var(--p-panel-border-color);
}
.side-tool-bar-end {
align-self: flex-end;
margin-top: auto;
.connected-sidebar {
padding: var(--sidebar-padding) 0;
background-color: var(--comfy-menu-bg);
}
.sidebar-item-group {
background-color: var(--comfy-menu-bg);
border: 1px solid transparent;
}
.overflowing-sidebar :deep(.comfy-menu-button-wrapper) {
position: sticky;
top: 0;
z-index: 1;
background-color: var(--comfy-menu-bg);
}
</style>

View File

@@ -1,5 +1,6 @@
<template>
<SidebarIcon
label="sideToolbar.labels.console"
:tooltip="$t('menu.toggleBottomPanel')"
:selected="bottomPanelStore.activePanel == 'terminal'"
@click="bottomPanelStore.toggleBottomPanel"

View File

@@ -3,8 +3,10 @@
<SidebarIcon
icon="pi pi-question-circle"
class="comfy-help-center-btn"
label="menu.help"
:tooltip="$t('sideToolbar.helpCenter')"
:icon-badge="shouldShowRedDot ? '' : ''"
:is-small="isSmall"
@click="toggleHelpCenter"
/>
@@ -16,7 +18,7 @@
:class="{
'sidebar-left': sidebarLocation === 'left',
'sidebar-right': sidebarLocation === 'right',
'small-sidebar': sidebarSize === 'small'
'small-sidebar': isSmall
}"
>
<HelpCenterMenuContent @close="closeHelpCenter" />
@@ -29,7 +31,7 @@
:class="{
'sidebar-left': sidebarLocation === 'left',
'sidebar-right': sidebarLocation === 'right',
'small-sidebar': sidebarSize === 'small'
'small-sidebar': isSmall
}"
/>
</Teleport>
@@ -40,7 +42,7 @@
:class="{
'sidebar-left': sidebarLocation === 'left',
'sidebar-right': sidebarLocation === 'right',
'small-sidebar': sidebarSize === 'small'
'small-sidebar': isSmall
}"
@whats-new-dismissed="handleWhatsNewDismissed"
/>
@@ -59,7 +61,7 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, onMounted } from 'vue'
import { computed, onMounted, toRefs } from 'vue'
import HelpCenterMenuContent from '@/components/helpcenter/HelpCenterMenuContent.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
@@ -87,6 +89,11 @@ const { showNodeConflictDialog } = useDialogService()
const { shouldShowRedDot: shouldShowConflictRedDot, markConflictsAsSeen } =
useConflictAcknowledgment()
const props = defineProps<{
isSmall: boolean
}>()
const { isSmall } = toRefs(props)
// Use either release red dot or conflict red dot
const shouldShowRedDot = computed((): boolean => {
const releaseRedDot = showReleaseRedDot.value
@@ -97,8 +104,6 @@ const sidebarLocation = computed(() =>
settingStore.get('Comfy.Sidebar.Location')
)
const sidebarSize = computed(() => settingStore.get('Comfy.Sidebar.Size'))
const toggleHelpCenter = () => {
helpCenterStore.toggle()
}

View File

@@ -8,10 +8,8 @@
text
:pt="{
root: {
class: `side-bar-button ${
selected
? 'p-button-primary side-bar-button-selected'
: 'p-button-secondary'
class: `side-bar-button p-button-secondary ${
selected ? 'side-bar-button-selected' : ''
}`,
'aria-label': computedTooltip
}
@@ -87,9 +85,13 @@ const computedTooltip = computed(() => t(tooltip) + tooltipSuffix)
font-size: var(--sidebar-icon-size) !important;
}
.side-bar-button-selected {
background-color: var(--content-hover-bg);
color: var(--content-hover-fg);
}
.side-bar-button-selected .side-bar-button-icon {
font-size: var(--sidebar-icon-size) !important;
font-weight: 700;
}
</style>
@@ -98,8 +100,9 @@ const computedTooltip = computed(() => t(tooltip) + tooltipSuffix)
.side-bar-button {
width: var(--sidebar-width);
height: calc(var(--sidebar-width) + 0.5rem);
height: var(--sidebar-item-height);
border-radius: 0;
flex-shrink: 0;
}
.side-tool-bar-end .side-bar-button {

View File

@@ -1,5 +1,10 @@
<template>
<SidebarIcon icon="pi pi-sign-out" :tooltip="tooltip" @click="logout" />
<SidebarIcon
icon="pi pi-sign-out"
:tooltip="tooltip"
label="sideToolbar.logout"
@click="logout"
/>
</template>
<script setup lang="ts">

View File

@@ -1,5 +1,6 @@
<template>
<SidebarIcon
label="shortcuts.shortcuts"
:tooltip="tooltipText"
:selected="isShortcutsPanelVisible"
@click="toggleShortcutsPanel"