V3 UI - Tabs & Menu rework (#4374)

Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
pythongosssss
2025-07-24 08:09:12 +01:00
committed by GitHub
parent 2338cbd4c9
commit 62f3ba0689
33 changed files with 1057 additions and 231 deletions

View File

@@ -1,34 +1,70 @@
<template>
<div v-if="workflowStore.isSubgraphActive" class="p-2 subgraph-breadcrumb">
<div
class="subgraph-breadcrumb w-auto"
:class="{
'subgraph-breadcrumb-collapse': collapseTabs,
'subgraph-breadcrumb-overflow': overflowingTabs
}"
:style="{
'--p-breadcrumb-gap': `${ITEM_GAP}px`,
'--p-breadcrumb-item-min-width': `${MIN_WIDTH}px`,
'--p-breadcrumb-item-padding': `${ITEM_PADDING}px`,
'--p-breadcrumb-icon-width': `${ICON_WIDTH}px`
}"
>
<Breadcrumb
class="bg-transparent"
:home="home"
ref="breadcrumbRef"
class="bg-transparent p-0"
:model="items"
aria-label="Graph navigation"
@item-click="handleItemClick"
/>
>
<template #item="{ item }">
<SubgraphBreadcrumbItem
:item="item"
:is-active="item === items.at(-1)"
/>
</template>
<template #separator
><span style="transform: scale(1.5)"> / </span></template
>
</Breadcrumb>
</div>
</template>
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import Breadcrumb from 'primevue/breadcrumb'
import type { MenuItem, MenuItemCommandEvent } from 'primevue/menuitem'
import { computed } from 'vue'
import type { MenuItem } from 'primevue/menuitem'
import { computed, onUpdated, ref, watch } from 'vue'
import SubgraphBreadcrumbItem from '@/components/breadcrumb/SubgraphBreadcrumbItem.vue'
import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
import { useCanvasStore } from '@/stores/graphStore'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import { useWorkflowStore } from '@/stores/workflowStore'
const MIN_WIDTH = 28
const ITEM_GAP = 8
const ITEM_PADDING = 8
const ICON_WIDTH = 20
const workflowStore = useWorkflowStore()
const navigationStore = useSubgraphNavigationStore()
const breadcrumbRef = ref<InstanceType<typeof Breadcrumb>>()
const workflowName = computed(() => workflowStore.activeWorkflow?.filename)
const collapseTabs = ref(false)
const overflowingTabs = ref(false)
const breadcrumbElement = computed(() => {
if (!breadcrumbRef.value) return null
const el = (breadcrumbRef.value as unknown as { $el: HTMLElement }).$el
const list = el?.querySelector('.p-breadcrumb-list') as HTMLElement
return list
})
const items = computed(() => {
if (!navigationStore.navigationStack.length) return []
return navigationStore.navigationStack.map<MenuItem>((subgraph) => ({
const items = navigationStore.navigationStack.map<MenuItem>((subgraph) => ({
label: subgraph.name,
command: () => {
const canvas = useCanvasStore().getCanvas()
@@ -37,11 +73,14 @@ const items = computed(() => {
canvas.setGraph(subgraph)
}
}))
return [home.value, ...items]
})
const home = computed(() => ({
label: workflowName.value,
icon: 'pi pi-home',
key: 'root',
command: () => {
const canvas = useCanvasStore().getCanvas()
if (!canvas.graph) throw new TypeError('Canvas has no graph')
@@ -50,10 +89,6 @@ const home = computed(() => ({
}
}))
const handleItemClick = (event: MenuItemCommandEvent) => {
event.item.command?.(event)
}
// Escape exits from the current subgraph.
useEventListener(document, 'keydown', (event) => {
if (event.key === 'Escape') {
@@ -65,21 +100,116 @@ useEventListener(document, 'keydown', (event) => {
)
}
})
// Check for overflow on breadcrumb items and collapse/expand the breadcrumb to fit
let overflowObserver: ReturnType<typeof useOverflowObserver> | undefined
watch(breadcrumbElement, (el) => {
overflowObserver?.dispose()
overflowObserver = undefined
if (!el) return
overflowObserver = useOverflowObserver(el, {
onCheck: (isOverflowing) => {
overflowingTabs.value = isOverflowing
if (collapseTabs.value) {
// Items are currently hidden, check if we can show them
if (!isOverflowing) {
const items = [
...el.querySelectorAll('.p-breadcrumb-item')
] as HTMLElement[]
if (items.length < 3) return
const itemsWithIcon = items.filter((item) =>
item.querySelector('.p-breadcrumb-item-link-icon-visible')
).length
const separators = el.querySelectorAll(
'.p-breadcrumb-separator'
) as NodeListOf<HTMLElement>
const separator = separators[separators.length - 1] as HTMLElement
const separatorWidth = separator.offsetWidth
// items + separators + gaps + icons
const itemsWidth =
(MIN_WIDTH + ITEM_PADDING + ITEM_PADDING) * items.length +
itemsWithIcon * ICON_WIDTH
const separatorsWidth = (items.length - 1) * separatorWidth
const gapsWidth = (items.length - 1) * (ITEM_GAP * 2)
const totalWidth = itemsWidth + separatorsWidth + gapsWidth
const containerWidth = el.clientWidth
if (totalWidth <= containerWidth) {
collapseTabs.value = false
}
}
} else if (isOverflowing) {
collapseTabs.value = true
}
}
})
})
// If e.g. the workflow name changes, we need to check the overflow again
onUpdated(() => {
if (!overflowObserver?.disposed.value) {
overflowObserver?.checkOverflow()
}
})
</script>
<style>
.subgraph-breadcrumb {
.p-breadcrumb-item-link,
.p-breadcrumb-item-icon {
@apply select-none;
<style scoped>
.subgraph-breadcrumb:not(:empty) {
flex: auto;
flex-shrink: 10000;
min-width: 120px;
}
color: #d26565;
text-shadow:
1px 1px 0 #000,
-1px -1px 0 #000,
1px -1px 0 #000,
-1px 1px 0 #000,
0 0 0.375rem #000;
.subgraph-breadcrumb,
:deep(.p-breadcrumb) {
@apply overflow-hidden;
}
:deep(.p-breadcrumb-item) {
@apply flex items-center rounded-lg overflow-hidden;
min-width: calc(var(--p-breadcrumb-item-min-width) + 1rem);
/* Collapse middle items first */
flex-shrink: 10000;
}
:deep(.p-breadcrumb-item:has(.p-breadcrumb-item-link-icon-visible)) {
min-width: calc(var(--p-breadcrumb-item-min-width) + 1rem + 20px);
}
:deep(.p-breadcrumb-item:first-child) {
/* Then collapse the root workflow */
flex-shrink: 5000;
}
:deep(.p-breadcrumb-item:last-child) {
/* Then collapse the active item */
flex-shrink: 1;
}
:deep(.p-breadcrumb-item:hover),
:deep(.p-breadcrumb-item:has(.p-breadcrumb-item-link-menu-visible)) {
background-color: color-mix(in srgb, var(--fg-color) 10%, transparent);
color: var(--fg-color);
}
</style>
<style>
.subgraph-breadcrumb-collapse .p-breadcrumb-list {
.p-breadcrumb-item,
.p-breadcrumb-separator {
@apply hidden;
}
.p-breadcrumb-item:nth-last-child(3),
.p-breadcrumb-separator:nth-last-child(2),
.p-breadcrumb-item:nth-last-child(1) {
@apply block;
}
}
</style>

View File

@@ -0,0 +1,209 @@
<template>
<a
ref="wrapperRef"
v-tooltip.bottom="item.label"
href="#"
class="cursor-pointer p-breadcrumb-item-link"
:class="{
'flex items-center gap-1': isActive,
'p-breadcrumb-item-link-menu-visible': menu?.overlayVisible,
'p-breadcrumb-item-link-icon-visible': isActive
}"
@click="handleClick"
>
<span class="p-breadcrumb-item-label">{{ item.label }}</span>
<i v-if="isActive" class="pi pi-angle-down text-[10px]"></i>
</a>
<Menu
v-if="isActive"
ref="menu"
:model="menuItems"
:popup="true"
:pt="{
root: {
style: 'background-color: var(--comfy-menu-secondary-bg)'
},
itemLink: {
class: 'py-2'
}
}"
/>
<InputText
v-if="isEditing"
ref="itemInputRef"
v-model="itemLabel"
class="fixed z-[10000] text-[.8rem] px-2 py-2"
@blur="inputBlur(true)"
@click.stop
@keydown.enter="inputBlur(true)"
@keydown.esc="inputBlur(false)"
/>
</template>
<script setup lang="ts">
import InputText from 'primevue/inputtext'
import Menu, { MenuState } from 'primevue/menu'
import type { MenuItem } from 'primevue/menuitem'
import { computed, nextTick, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useDialogService } from '@/services/dialogService'
import { useWorkflowService } from '@/services/workflowService'
import { useCommandStore } from '@/stores/commandStore'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import { ComfyWorkflow, useWorkflowStore } from '@/stores/workflowStore'
import { appendJsonExt } from '@/utils/formatUtil'
interface Props {
item: MenuItem
isActive?: boolean
}
const props = withDefaults(defineProps<Props>(), {
isActive: false
})
const { t } = useI18n()
const menu = ref<InstanceType<typeof Menu> & MenuState>()
const dialogService = useDialogService()
const workflowStore = useWorkflowStore()
const workflowService = useWorkflowService()
const isEditing = ref(false)
const itemLabel = ref<string>()
const itemInputRef = ref<{ $el?: HTMLInputElement }>()
const wrapperRef = ref<HTMLAnchorElement>()
const rename = async (
newName: string | null | undefined,
initialName: string
) => {
if (newName && newName !== initialName) {
if (workflowStore.activeSubgraph) {
workflowStore.activeSubgraph.name = newName
} else if (workflowStore.activeWorkflow) {
try {
await workflowService.renameWorkflow(
workflowStore.activeWorkflow,
ComfyWorkflow.basePath + appendJsonExt(newName)
)
} catch (error) {
console.error(error)
dialogService.showErrorDialog(error)
return
}
}
// Force the navigation stack to recompute the labels
// TODO: investigate if there is a better way to do this
const navigationStore = useSubgraphNavigationStore()
navigationStore.restoreState(navigationStore.exportState())
}
}
const menuItems = computed<MenuItem[]>(() => {
return [
{
label: t('g.rename'),
icon: 'pi pi-pencil',
command: async () => {
let initialName =
workflowStore.activeSubgraph?.name ??
workflowStore.activeWorkflow?.filename
if (!initialName) return
const newName = await dialogService.prompt({
title: t('g.rename'),
message: t('breadcrumbsMenu.enterNewName'),
defaultValue: initialName
})
await rename(newName, initialName)
}
},
{
label: t('breadcrumbsMenu.duplicate'),
icon: 'pi pi-copy',
command: async () => {
await workflowService.duplicateWorkflow(workflowStore.activeWorkflow!)
},
visible: props.item.key === 'root'
},
{
separator: true
},
{
label: t('breadcrumbsMenu.clearWorkflow'),
icon: 'pi pi-trash',
command: async () => {
await useCommandStore().execute('Comfy.ClearWorkflow')
}
},
{
separator: true,
visible: props.item.key === 'root'
},
{
label: t('breadcrumbsMenu.deleteWorkflow'),
icon: 'pi pi-times',
command: async () => {
await workflowService.deleteWorkflow(workflowStore.activeWorkflow!)
},
visible: props.item.key === 'root'
}
]
})
const handleClick = (event: MouseEvent) => {
if (isEditing.value) {
return
}
if (event.detail === 1) {
if (props.isActive) {
menu.value?.toggle(event)
} else {
props.item.command?.({ item: props.item, originalEvent: event })
}
} else if (props.isActive && event.detail === 2) {
menu.value?.hide()
event.stopPropagation()
event.preventDefault()
isEditing.value = true
itemLabel.value = props.item.label as string
void nextTick(() => {
if (itemInputRef.value?.$el) {
itemInputRef.value.$el.focus()
itemInputRef.value.$el.select()
if (wrapperRef.value) {
itemInputRef.value.$el.style.width = `${Math.max(200, wrapperRef.value.offsetWidth)}px`
}
}
})
}
}
const inputBlur = async (doRename: boolean) => {
if (doRename) {
await rename(itemLabel.value, props.item.label as string)
}
isEditing.value = false
}
</script>
<style scoped>
.p-breadcrumb-item-link,
.p-breadcrumb-item-icon {
@apply select-none;
}
.p-breadcrumb-item-link {
@apply overflow-hidden;
padding: var(--p-breadcrumb-item-padding);
}
.p-breadcrumb-item-label {
@apply whitespace-nowrap text-ellipsis overflow-hidden;
}
</style>