mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-26 01:09:46 +00:00
V3 UI - Tabs & Menu rework (#4374)
Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
@@ -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>
|
||||
|
||||
209
src/components/breadcrumb/SubgraphBreadcrumbItem.vue
Normal file
209
src/components/breadcrumb/SubgraphBreadcrumbItem.vue
Normal 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>
|
||||
Reference in New Issue
Block a user