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

@@ -168,7 +168,7 @@ export class ComfyPage {
this.menu = new ComfyMenu(page)
this.actionbar = new ComfyActionbar(page)
this.templates = new ComfyTemplates(page)
this.settingDialog = new SettingDialog(page)
this.settingDialog = new SettingDialog(page, this)
this.confirmDialog = new ConfirmDialog(page)
}

View File

@@ -1,15 +1,19 @@
import { Page } from '@playwright/test'
import { ComfyPage } from '../ComfyPage'
export class SettingDialog {
constructor(public readonly page: Page) {}
constructor(
public readonly page: Page,
public readonly comfyPage: ComfyPage
) {}
get root() {
return this.page.locator('div.settings-container')
}
async open() {
const button = this.page.locator('button.comfy-settings-btn:visible')
await button.click()
await this.comfyPage.executeCommand('Comfy.ShowSettingsDialog')
await this.page.waitForSelector('div.settings-container')
}

View File

@@ -15,10 +15,6 @@ export class Topbar {
.innerText()
}
async openSubmenuMobile() {
await this.page.locator('.p-menubar-mobile .p-menubar-button').click()
}
getMenuItem(itemLabel: string): Locator {
return this.page.locator(`.p-menubar-item-label:text-is("${itemLabel}")`)
}
@@ -68,31 +64,41 @@ export class Topbar {
await this.getSaveDialog().waitFor({ state: 'hidden', timeout: 500 })
}
async openTopbarMenu() {
await this.page.locator('.comfyui-logo-wrapper').click()
const menu = this.page.locator('.comfy-command-menu')
await menu.waitFor({ state: 'visible' })
return menu
}
async triggerTopbarCommand(path: string[]) {
if (path.length < 2) {
throw new Error('Path is too short')
}
const menu = await this.openTopbarMenu()
const tabName = path[0]
const topLevelMenu = this.page.locator(
`.top-menubar .p-menubar-item-label:text-is("${tabName}")`
const topLevelMenuItem = this.page.locator(
`.p-menubar-item-label:text-is("${tabName}")`
)
const topLevelMenu = menu
.locator('.p-tieredmenu-item')
.filter({ has: topLevelMenuItem })
await topLevelMenu.waitFor({ state: 'visible' })
await topLevelMenu.click()
await topLevelMenu.hover()
let currentMenu = topLevelMenu
for (let i = 1; i < path.length; i++) {
const commandName = path[i]
const menuItem = this.page
const menuItem = currentMenu
.locator(
`.top-menubar .p-menubar-submenu .p-menubar-item:has-text("${commandName}")`
`.p-tieredmenu-submenu .p-tieredmenu-item:has-text("${commandName}")`
)
.first()
await menuItem.waitFor({ state: 'visible' })
await menuItem.hover()
if (i === path.length - 1) {
await menuItem.click()
}
currentMenu = menuItem
}
await currentMenu.click()
}
}

View File

@@ -769,7 +769,8 @@ test.describe('Viewport settings', () => {
}) => {
// Screenshot the canvas element
await comfyPage.menu.topbar.saveWorkflow('Workflow A')
await expect(comfyPage.canvas).toHaveScreenshot('viewport-workflow-a.png')
await comfyPage.nextFrame()
const screenshotA = (await comfyPage.canvas.screenshot()).toString('base64')
// Save workflow as a new file, then zoom out before screen shot
await comfyPage.menu.topbar.saveWorkflowAs('Workflow B')
@@ -777,7 +778,12 @@ test.describe('Viewport settings', () => {
for (let i = 0; i < 4; i++) {
await comfyMouse.wheel(0, 60)
}
await expect(comfyPage.canvas).toHaveScreenshot('viewport-workflow-b.png')
await comfyPage.nextFrame()
const screenshotB = (await comfyPage.canvas.screenshot()).toString('base64')
// Ensure that the screenshots are different due to zoom level
expect(screenshotB).not.toBe(screenshotA)
const tabA = comfyPage.menu.topbar.getWorkflowTab('Workflow A')
const tabB = comfyPage.menu.topbar.getWorkflowTab('Workflow B')
@@ -785,11 +791,15 @@ test.describe('Viewport settings', () => {
// Go back to Workflow A
await tabA.click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('viewport-workflow-a.png')
expect((await comfyPage.canvas.screenshot()).toString('base64')).toBe(
screenshotA
)
// And back to Workflow B
await tabB.click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('viewport-workflow-b.png')
expect((await comfyPage.canvas.screenshot()).toString('base64')).toBe(
screenshotB
)
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

View File

@@ -63,7 +63,7 @@ test.describe('Menu', () => {
test('@mobile Items fully visible on mobile screen width', async ({
comfyPage
}) => {
await comfyPage.menu.topbar.openSubmenuMobile()
await comfyPage.menu.topbar.openTopbarMenu()
const topLevelMenuItem = comfyPage.page
.locator('a.p-menubar-item-link')
.first()
@@ -74,8 +74,9 @@ test.describe('Menu', () => {
})
test('Displays keybinding next to item', async ({ comfyPage }) => {
await comfyPage.menu.topbar.openTopbarMenu()
const workflowMenuItem = comfyPage.menu.topbar.getMenuItem('Workflow')
await workflowMenuItem.click()
await workflowMenuItem.hover()
const exportTag = comfyPage.page.locator('.keybinding-tag', {
hasText: 'Ctrl + s'
})

View File

@@ -30,10 +30,11 @@ import ComfyQueueButton from './ComfyQueueButton.vue'
const settingsStore = useSettingStore()
const visible = computed(
() => settingsStore.get('Comfy.UseNewMenu') !== 'Disabled'
)
const position = computed(() => settingsStore.get('Comfy.UseNewMenu'))
const visible = computed(() => position.value !== 'Disabled')
const topMenuRef = inject<Ref<HTMLDivElement | null>>('topMenuRef')
const panelRef = ref<HTMLElement | null>(null)
const dragHandleRef = ref<HTMLElement | null>(null)
const isDocked = useLocalStorage('Comfy.MenuPosition.Docked', false)
@@ -49,7 +50,16 @@ const {
} = useDraggable(panelRef, {
initialValue: { x: 0, y: 0 },
handle: dragHandleRef,
containerElement: document.body
containerElement: document.body,
onMove: (event) => {
// Prevent dragging the menu over the top of the tabs
if (position.value === 'Top') {
const minY = topMenuRef?.value?.getBoundingClientRect().top ?? 40
if (event.y < minY) {
event.y = minY
}
}
}
})
// Update storedPosition when x or y changes
@@ -182,7 +192,6 @@ const adjustMenuPosition = () => {
useEventListener(window, 'resize', adjustMenuPosition)
const topMenuRef = inject<Ref<HTMLDivElement | null>>('topMenuRef')
const topMenuBounds = useElementBounding(topMenuRef)
const overlapThreshold = 20 // pixels
const isOverlappingWithTopMenu = computed(() => {

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>

View File

@@ -16,7 +16,6 @@
<SecondRowWorkflowTabs
v-if="workflowTabsPosition === 'Topbar (2nd-row)'"
/>
<SubgraphBreadcrumb />
</div>
<GraphCanvasMenu v-if="canvasMenuEnabled" class="pointer-events-auto" />
</template>
@@ -50,7 +49,6 @@ import { computed, onMounted, ref, watch, watchEffect } from 'vue'
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
import BottomPanel from '@/components/bottomPanel/BottomPanel.vue'
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
import DomWidgets from '@/components/graph/DomWidgets.vue'
import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue'
import NodeTooltip from '@/components/graph/NodeTooltip.vue'

View File

@@ -14,9 +14,8 @@
/>
<div class="side-tool-bar-end">
<SidebarLogoutIcon v-if="userStore.isMultiUserServer" />
<SidebarThemeToggleIcon />
<SidebarHelpCenterIcon />
<SidebarSettingsToggleIcon />
<SidebarBottomPanelToggleButton />
</div>
</nav>
</teleport>
@@ -32,6 +31,7 @@
import { computed } from 'vue'
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
import SidebarBottomPanelToggleButton from '@/components/sidebar/SidebarBottomPanelToggleButton.vue'
import { useKeybindingStore } from '@/stores/keybindingStore'
import { useSettingStore } from '@/stores/settingStore'
import { useUserStore } from '@/stores/userStore'
@@ -41,8 +41,6 @@ import type { SidebarTabExtension } from '@/types/extensionTypes'
import SidebarHelpCenterIcon from './SidebarHelpCenterIcon.vue'
import SidebarIcon from './SidebarIcon.vue'
import SidebarLogoutIcon from './SidebarLogoutIcon.vue'
import SidebarSettingsToggleIcon from './SidebarSettingsToggleIcon.vue'
import SidebarThemeToggleIcon from './SidebarThemeToggleIcon.vue'
const workspaceStore = useWorkspaceStore()
const settingStore = useSettingStore()

View File

@@ -0,0 +1,19 @@
<template>
<SidebarIcon
:tooltip="$t('menu.toggleBottomPanel')"
:selected="bottomPanelStore.bottomPanelVisible"
@click="bottomPanelStore.toggleBottomPanel"
>
<template #icon>
<i-ph:terminal-bold />
</template>
</SidebarIcon>
</template>
<script setup lang="ts">
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
import SidebarIcon from './SidebarIcon.vue'
const bottomPanelStore = useBottomPanelStore()
</script>

View File

@@ -19,10 +19,12 @@
@click="emit('click', $event)"
>
<template #icon>
<OverlayBadge v-if="shouldShowBadge" :value="overlayValue">
<i :class="icon + ' side-bar-button-icon'" />
</OverlayBadge>
<i v-else :class="icon + ' side-bar-button-icon'" />
<slot name="icon">
<OverlayBadge v-if="shouldShowBadge" :value="overlayValue">
<i :class="icon + ' side-bar-button-icon'" />
</OverlayBadge>
<i v-else :class="icon + ' side-bar-button-icon'" />
</slot>
</template>
</Button>
</template>

View File

@@ -1,25 +0,0 @@
<template>
<SidebarIcon
icon="pi pi-cog"
class="comfy-settings-btn"
:tooltip="$t('g.settings')"
@click="showSetting"
/>
</template>
<script setup lang="ts">
import SettingDialogContent from '@/components/dialog/content/SettingDialogContent.vue'
import SettingDialogHeader from '@/components/dialog/header/SettingDialogHeader.vue'
import { useDialogStore } from '@/stores/dialogStore'
import SidebarIcon from './SidebarIcon.vue'
const dialogStore = useDialogStore()
const showSetting = () => {
dialogStore.showDialog({
key: 'global-settings',
headerComponent: SettingDialogHeader,
component: SettingDialogContent
})
}
</script>

View File

@@ -1,29 +0,0 @@
<template>
<SidebarIcon
:icon="icon"
:tooltip="$t('sideToolbar.themeToggle')"
class="comfy-vue-theme-toggle"
@click="toggleTheme"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useCommandStore } from '@/stores/commandStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import SidebarIcon from './SidebarIcon.vue'
const colorPaletteStore = useColorPaletteStore()
const icon = computed(() =>
colorPaletteStore.completedActivePalette.light_theme
? 'pi pi-sun'
: 'pi pi-moon'
)
const commandStore = useCommandStore()
const toggleTheme = async () => {
await commandStore.execute('Comfy.ToggleTheme')
}
</script>

View File

@@ -1,25 +0,0 @@
<template>
<Button
v-show="bottomPanelStore.bottomPanelTabs.length > 0"
v-tooltip="{ value: $t('menu.toggleBottomPanel'), showDelay: 300 }"
severity="secondary"
text
:aria-label="$t('menu.toggleBottomPanel')"
@click="bottomPanelStore.toggleBottomPanel"
>
<template #icon>
<i-material-symbols:dock-to-bottom
v-if="bottomPanelStore.bottomPanelVisible"
/>
<i-material-symbols:dock-to-bottom-outline v-else />
</template>
</Button>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
const bottomPanelStore = useBottomPanelStore()
</script>

View File

@@ -1,51 +1,116 @@
<template>
<Menubar
:model="translatedItems"
class="top-menubar border-none p-0 bg-transparent"
:pt="{
rootList: 'gap-0 flex-nowrap w-auto',
submenu: `dropdown-direction-${dropdownDirection}`,
item: 'relative'
<div
class="comfyui-logo-wrapper p-1 flex justify-center items-center cursor-pointer rounded-md mr-2"
:class="{
'comfyui-logo-menu-visible': menuRef?.visible
}"
:style="{
minWidth: isLargeSidebar ? '4rem' : 'auto'
}"
@click="menuRef?.toggle($event)"
>
<template #item="{ item, props, root }">
<img
src="/assets/images/comfy-logo-mono.svg"
alt="ComfyUI Logo"
class="comfyui-logo h-7"
@contextmenu="showNativeSystemMenu"
/>
<i class="pi pi-angle-down ml-1 text-[10px]" />
</div>
<TieredMenu
ref="menuRef"
:model="translatedItems"
:popup="true"
class="comfy-command-menu"
:class="{
'comfy-command-menu-top': isTopMenu
}"
@show="onMenuShow"
>
<template #item="{ item, props }">
<div
v-if="item.key === 'theme'"
class="flex items-center gap-4 px-4 py-5"
@click.stop.prevent
>
{{ item.label }}
<SelectButton
:options="[darkLabel, lightLabel]"
:model-value="activeTheme"
@click.stop.prevent
@update:model-value="onThemeChange"
>
<template #option="{ option }">
<div class="flex items-center gap-2">
<i v-if="option === lightLabel" class="pi pi-sun" />
<i v-if="option === darkLabel" class="pi pi-moon" />
<span>{{ option }}</span>
</div>
</template>
</SelectButton>
</div>
<a
class="p-menubar-item-link"
v-else
class="p-menubar-item-link px-4 py-2"
v-bind="props.action"
:href="item.url"
target="_blank"
>
<span v-if="item.icon" class="p-menubar-item-icon" :class="item.icon" />
<span class="p-menubar-item-label">{{ item.label }}</span>
<span class="p-menubar-item-label text-nowrap">{{ item.label }}</span>
<span
v-if="item?.comfyCommand?.keybinding"
class="ml-auto border border-surface rounded text-muted text-xs text-nowrap p-1 keybinding-tag"
>
{{ item.comfyCommand.keybinding.combo.toString() }}
</span>
<i v-if="!root && item.items" class="ml-auto pi pi-angle-right" />
<i v-if="item.items" class="ml-auto pi pi-angle-right" />
</a>
</template>
</Menubar>
</TieredMenu>
<SubgraphBreadcrumb />
</template>
<script setup lang="ts">
import Menubar from 'primevue/menubar'
import type { MenuItem } from 'primevue/menuitem'
import { computed } from 'vue'
import SelectButton from 'primevue/selectbutton'
import TieredMenu, {
type TieredMenuMethods,
type TieredMenuState
} from 'primevue/tieredmenu'
import { computed, nextTick, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
import SettingDialogContent from '@/components/dialog/content/SettingDialogContent.vue'
import SettingDialogHeader from '@/components/dialog/header/SettingDialogHeader.vue'
import { useDialogService } from '@/services/dialogService'
import { useAboutPanelStore } from '@/stores/aboutPanelStore'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import { useMenuItemStore } from '@/stores/menuItemStore'
import { useSettingStore } from '@/stores/settingStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { showNativeSystemMenu } from '@/utils/envUtil'
import { normalizeI18nKey } from '@/utils/formatUtil'
const settingStore = useSettingStore()
const dropdownDirection = computed(() =>
settingStore.get('Comfy.UseNewMenu') === 'Top' ? 'down' : 'up'
)
const colorPaletteStore = useColorPaletteStore()
const menuItemsStore = useMenuItemStore()
const commandStore = useCommandStore()
const dialogStore = useDialogStore()
const aboutPanelStore = useAboutPanelStore()
const settingStore = useSettingStore()
const { t } = useI18n()
const menuRef = ref<
({ dirty: boolean } & TieredMenuMethods & TieredMenuState) | null
>(null)
const isLargeSidebar = computed(
() => settingStore.get('Comfy.Sidebar.Size') !== 'small'
)
const isTopMenu = computed(() => settingStore.get('Comfy.UseNewMenu') === 'Top')
const translateMenuItem = (item: MenuItem): MenuItem => {
const label = typeof item.label === 'function' ? item.label() : item.label
const translatedLabel = label
@@ -59,9 +124,119 @@ const translateMenuItem = (item: MenuItem): MenuItem => {
}
}
const translatedItems = computed(() =>
menuItemsStore.menuItems.map(translateMenuItem)
)
const showSettings = (defaultPanel?: string) => {
dialogStore.showDialog({
key: 'global-settings',
headerComponent: SettingDialogHeader,
component: SettingDialogContent,
props: {
defaultPanel
}
})
}
// Temporary duplicated from LoadWorkflowWarning.vue
// Determines if ComfyUI-Manager is installed by checking for its badge in the about panel
// This allows us to conditionally show the Manager button only when the extension is available
// TODO: Remove this check when Manager functionality is fully migrated into core
const isManagerInstalled = computed(() => {
return aboutPanelStore.badges.some(
(badge) =>
badge.label.includes('ComfyUI-Manager') ||
badge.url.includes('ComfyUI-Manager')
)
})
const showManageExtensions = () => {
if (isManagerInstalled.value) {
useDialogService().showManagerDialog()
} else {
showSettings('extension')
}
}
const extraMenuItems: MenuItem[] = [
{ separator: true },
{
key: 'theme',
label: t('menu.theme')
},
{ separator: true },
{
key: 'manage-extensions',
label: t('menu.manageExtensions'),
icon: 'mdi mdi-puzzle-outline',
command: showManageExtensions
},
{
key: 'settings',
label: t('g.settings'),
icon: 'mdi mdi-cog-outline',
command: () => showSettings()
}
]
const lightLabel = t('menu.light')
const darkLabel = t('menu.dark')
const activeTheme = computed(() => {
return colorPaletteStore.completedActivePalette.light_theme
? lightLabel
: darkLabel
})
const onThemeChange = async () => {
await commandStore.execute('Comfy.ToggleTheme')
}
const translatedItems = computed(() => {
const items = menuItemsStore.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,
...(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
}
})
}
</script>
<style scoped>
@@ -74,4 +249,20 @@ const translatedItems = computed(() =>
border-color: var(--p-content-border-color);
border-style: solid;
}
.comfyui-logo-menu-visible,
.comfyui-logo-wrapper:hover {
background-color: color-mix(in srgb, var(--fg-color) 10%, transparent);
}
</style>
<style>
.comfy-command-menu ul {
background-color: var(--comfy-menu-secondary-bg) !important;
}
.comfy-command-menu-top .p-tieredmenu-submenu {
left: calc(100% + 15px) !important;
top: -4px !important;
}
</style>

View File

@@ -1,82 +1,66 @@
<template>
<div
v-show="showTopMenu"
ref="topMenuRef"
class="comfyui-menu flex items-center"
:class="{ dropzone: isDropZone, 'dropzone-active': isDroppable }"
>
<img
src="/assets/images/comfy-logo-mono.svg"
alt="ComfyUI Logo"
class="comfyui-logo ml-2 app-drag h-6"
/>
<CommandMenubar />
<div class="flex-grow min-w-0 app-drag h-full">
<WorkflowTabs v-if="workflowTabsPosition === 'Topbar'" />
</div>
<div ref="menuRight" class="comfyui-menu-right flex-shrink-0" />
<Actionbar />
<CurrentUserButton class="flex-shrink-0" />
<BottomPanelToggleButton class="flex-shrink-0" />
<Button
v-tooltip="{ value: $t('menu.hideMenu'), showDelay: 300 }"
class="flex-shrink-0"
icon="pi pi-bars"
severity="secondary"
text
:aria-label="$t('menu.hideMenu')"
@click="workspaceState.focusMode = true"
@contextmenu="showNativeSystemMenu"
/>
<div>
<div
v-show="menuSetting !== 'Bottom'"
class="window-actions-spacer flex-shrink-0"
v-show="showTopMenu && workflowTabsPosition === 'Topbar'"
class="w-full flex content-end z-[1001] h-[38px]"
style="background: var(--border-color)"
>
<WorkflowTabs />
</div>
<div
v-show="showTopMenu"
ref="topMenuRef"
class="comfyui-menu flex items-center"
:class="{ dropzone: isDropZone, 'dropzone-active': isDroppable }"
>
<CommandMenubar />
<div class="flex-grow min-w-0 app-drag h-full"></div>
<div
ref="menuRight"
class="comfyui-menu-right flex-shrink-1 overflow-auto"
/>
<Actionbar />
<CurrentUserButton class="flex-shrink-0" />
</div>
<!-- Virtual top menu for native window (drag handle) -->
<div
v-show="isNativeWindow() && !showTopMenu"
class="fixed top-0 left-0 app-drag w-full h-[var(--comfy-topbar-height)]"
/>
</div>
<!-- Virtual top menu for native window (drag handle) -->
<div
v-show="isNativeWindow() && !showTopMenu"
class="fixed top-0 left-0 app-drag w-full h-[var(--comfy-topbar-height)]"
/>
</template>
<script setup lang="ts">
import { useEventBus } from '@vueuse/core'
import Button from 'primevue/button'
import { computed, onMounted, provide, ref } from 'vue'
import Actionbar from '@/components/actionbar/ComfyActionbar.vue'
import BottomPanelToggleButton from '@/components/topbar/BottomPanelToggleButton.vue'
import CommandMenubar from '@/components/topbar/CommandMenubar.vue'
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
import { app } from '@/scripts/app'
import { useSettingStore } from '@/stores/settingStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import {
electronAPI,
isElectron,
isNativeWindow,
showNativeSystemMenu
} from '@/utils/envUtil'
import { electronAPI, isElectron, isNativeWindow } from '@/utils/envUtil'
const workspaceState = useWorkspaceStore()
const settingStore = useSettingStore()
const workflowTabsPosition = computed(() =>
settingStore.get('Comfy.Workflow.WorkflowTabsPosition')
)
const menuSetting = computed(() => settingStore.get('Comfy.UseNewMenu'))
const betaMenuEnabled = computed(() => menuSetting.value !== 'Disabled')
const showTopMenu = computed(
() => betaMenuEnabled.value && !workspaceState.focusMode
)
const workflowTabsPosition = computed(() =>
settingStore.get('Comfy.Workflow.WorkflowTabsPosition')
)
const menuRight = ref<HTMLDivElement | null>(null)
// Menu-right holds legacy topbar elements attached by custom scripts
onMounted(() => {
if (menuRight.value) {
app.menu.element.style.width = 'fit-content'
menuRight.value.appendChild(app.menu.element)
}
})
@@ -138,4 +122,35 @@ onMounted(() => {
.dark-theme .comfyui-logo {
filter: invert(1);
}
.comfyui-menu-button-hide {
background-color: var(--comfy-menu-secondary-bg);
border-left: 1px solid var(--border-color);
}
</style>
<style>
.comfyui-menu-right::-webkit-scrollbar {
max-height: 5px;
}
.comfyui-menu-right:hover::-webkit-scrollbar {
cursor: grab;
}
.comfyui-menu-right::-webkit-scrollbar-track {
background: color-mix(in srgb, var(--border-color) 60%, transparent);
}
.comfyui-menu-right:hover::-webkit-scrollbar-track {
background: color-mix(in srgb, var(--border-color) 80%, transparent);
}
.comfyui-menu-right::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--fg-color) 30%, transparent);
}
.comfyui-menu-right::-webkit-scrollbar-thumb:hover {
background: color-mix(in srgb, var(--fg-color) 80%, transparent);
}
</style>

View File

@@ -1,8 +1,11 @@
<template>
<div ref="workflowTabRef" class="flex p-2 gap-2 workflow-tab" v-bind="$attrs">
<span
v-tooltip.bottom="workflowOption.workflow.key"
class="workflow-label text-sm max-w-[150px] truncate inline-block"
v-tooltip.bottom="{
value: workflowOption.workflow.key,
class: 'workflow-tab-tooltip'
}"
class="workflow-label text-sm max-w-[150px] min-w-[30px] truncate inline-block"
>
{{ workflowOption.workflow.filename }}
</span>
@@ -142,3 +145,9 @@ usePragmaticDroppable(tabGetter, {
transform: translate(-50%, -50%);
}
</style>
<style>
.p-tooltip.workflow-tab-tooltip {
z-index: 1200 !important;
}
</style>

View File

@@ -1,5 +1,17 @@
<template>
<div class="workflow-tabs-container flex flex-row max-w-full h-full">
<div
class="workflow-tabs-container flex flex-row max-w-full h-full flex-auto overflow-hidden"
:class="{ 'workflow-tabs-container-desktop': isDesktop }"
>
<Button
v-if="showOverflowArrows"
icon="pi pi-chevron-left"
text
severity="secondary"
class="overflow-arrow overflow-arrow-left"
:disabled="!leftArrowEnabled"
@mousedown="whileMouseDown($event, () => scroll(-1))"
/>
<ScrollPanel
ref="scrollPanelRef"
class="overflow-hidden no-drag"
@@ -27,9 +39,18 @@
</template>
</SelectButton>
</ScrollPanel>
<Button
v-if="showOverflowArrows"
icon="pi pi-chevron-right"
text
severity="secondary"
class="overflow-arrow overflow-arrow-right"
:disabled="!rightArrowEnabled"
@mousedown="whileMouseDown($event, () => scroll(1))"
/>
<Button
v-tooltip="{ value: $t('sideToolbar.newBlankWorkflow'), showDelay: 300 }"
class="new-blank-workflow-button flex-shrink-0 no-drag"
class="new-blank-workflow-button flex-shrink-0 no-drag rounded-none"
icon="pi pi-plus"
text
severity="secondary"
@@ -37,23 +58,32 @@
@click="() => commandStore.execute('Comfy.NewBlankWorkflow')"
/>
<ContextMenu ref="menu" :model="contextMenuItems" />
<div
v-if="menuSetting !== 'Bottom' && isDesktop"
class="window-actions-spacer flex-shrink-0 app-drag"
/>
</div>
</template>
<script setup lang="ts">
import { useScroll } from '@vueuse/core'
import Button from 'primevue/button'
import ContextMenu from 'primevue/contextmenu'
import ScrollPanel from 'primevue/scrollpanel'
import SelectButton from 'primevue/selectbutton'
import { computed, nextTick, ref, watch } from 'vue'
import { computed, nextTick, onUpdated, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import WorkflowTab from '@/components/topbar/WorkflowTab.vue'
import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
import { useWorkflowService } from '@/services/workflowService'
import { useCommandStore } from '@/stores/commandStore'
import { useSettingStore } from '@/stores/settingStore'
import { ComfyWorkflow, useWorkflowBookmarkStore } from '@/stores/workflowStore'
import { useWorkflowStore } from '@/stores/workflowStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isElectron } from '@/utils/envUtil'
import { whileMouseDown } from '@/utils/mouseDownUtil'
interface WorkflowOption {
value: string
@@ -67,11 +97,19 @@ const props = defineProps<{
const { t } = useI18n()
const workspaceStore = useWorkspaceStore()
const workflowStore = useWorkflowStore()
const workflowService = useWorkflowService()
const workflowBookmarkStore = useWorkflowBookmarkStore()
const settingStore = useSettingStore()
const workflowService = useWorkflowService()
const rightClickedTab = ref<WorkflowOption | undefined>()
const menu = ref()
const scrollPanelRef = ref()
const showOverflowArrows = ref(false)
const leftArrowEnabled = ref(false)
const rightArrowEnabled = ref(false)
const isDesktop = isElectron()
const menuSetting = computed(() => settingStore.get('Comfy.UseNewMenu'))
const workflowToOption = (workflow: ComfyWorkflow): WorkflowOption => ({
value: workflow.path,
@@ -86,6 +124,7 @@ const selectedWorkflow = computed<WorkflowOption | null>(() =>
? workflowToOption(workflowStore.activeWorkflow as ComfyWorkflow)
: null
)
const onWorkflowChange = async (option: WorkflowOption) => {
// Prevent unselecting the current workflow
if (!option) {
@@ -178,6 +217,13 @@ const handleWheel = (event: WheelEvent) => {
})
}
const scroll = (direction: number) => {
const scrollElement = scrollPanelRef.value.$el.querySelector(
'.p-scrollpanel-content'
) as HTMLElement
scrollElement.scrollBy({ left: direction * 20 })
}
// Scroll to active offscreen tab when opened
watch(
() => workflowStore.activeWorkflow,
@@ -208,12 +254,71 @@ watch(
},
{ immediate: true }
)
const scrollContent = computed(
() =>
scrollPanelRef.value?.$el.querySelector(
'.p-scrollpanel-content'
) as HTMLElement
)
let overflowObserver: ReturnType<typeof useOverflowObserver> | null = null
let overflowWatch: ReturnType<typeof watch> | null = null
watch(scrollContent, (value) => {
const scrollState = useScroll(value)
watch(scrollState.arrivedState, () => {
leftArrowEnabled.value = !scrollState.arrivedState.left
rightArrowEnabled.value = !scrollState.arrivedState.right
})
overflowObserver?.dispose()
overflowWatch?.stop()
overflowObserver = useOverflowObserver(value)
overflowWatch = watch(
overflowObserver.isOverflowing,
(value) => {
showOverflowArrows.value = value
void nextTick(() => {
// Force a new check after arrows are updated
scrollState.measure()
})
},
{ immediate: true }
)
})
onUpdated(() => {
if (!overflowObserver?.disposed.value) {
overflowObserver?.checkOverflow()
}
})
</script>
<style scoped>
.workflow-tabs-container {
background-color: var(--comfy-menu-secondary-bg);
}
:deep(.p-togglebutton) {
@apply p-0 bg-transparent rounded-none flex-shrink-0 relative border-0 border-r border-solid;
@apply p-0 bg-transparent rounded-none flex-shrink relative border-0 border-r border-solid;
border-right-color: var(--border-color);
min-width: 90px;
}
.overflow-arrow {
@apply px-2 rounded-none;
}
.overflow-arrow[disabled] {
@apply opacity-25;
}
:deep(.p-togglebutton > .p-togglebutton-content) {
@apply max-w-full;
}
:deep(.workflow-tab) {
@apply max-w-full;
}
:deep(.p-togglebutton::before) {
@@ -255,6 +360,10 @@ watch(
@apply h-full;
}
:deep(.workflow-tabs) {
display: flex;
}
/* Scrollbar half opacity to avoid blocking the active tab bottom border */
:deep(.p-scrollpanel:hover .p-scrollpanel-bar),
:deep(.p-scrollpanel:active .p-scrollpanel-bar) {
@@ -264,4 +373,15 @@ watch(
:deep(.p-selectbutton) {
@apply rounded-none h-full;
}
.workflow-tabs-container-desktop {
max-width: env(titlebar-area-width, 100vw);
}
.window-actions-spacer {
@apply flex-auto;
/* If we are using custom titlebar, then we need to add a gap for the user to drag the window */
--window-actions-spacer-width: min(75px, env(titlebar-area-width, 0) * 9999);
min-width: var(--window-actions-spacer-width);
}
</style>

View File

@@ -0,0 +1,64 @@
import { useMutationObserver, useResizeObserver } from '@vueuse/core'
import { debounce } from 'lodash'
import { readonly, ref } from 'vue'
/**
* Observes an element for overflow changes and optionally debounces the check
* @param element - The element to observe
* @param options - The options for the observer
* @param options.debounceTime - The time to debounce the check in milliseconds
* @param options.useMutationObserver - Whether to use a mutation observer to check for overflow
* @param options.useResizeObserver - Whether to use a resize observer to check for overflow
* @returns An object containing the isOverflowing state and the checkOverflow function to manually trigger
*/
export const useOverflowObserver = (
element: HTMLElement,
options?: {
debounceTime?: number
useMutationObserver?: boolean
useResizeObserver?: boolean
onCheck?: (isOverflowing: boolean) => void
}
) => {
options = {
debounceTime: 25,
useMutationObserver: true,
useResizeObserver: true,
...options
}
const isOverflowing = ref(false)
const disposeFns: (() => void)[] = []
const disposed = ref(false)
const checkOverflowFn = () => {
isOverflowing.value = element.scrollWidth > element.clientWidth
options.onCheck?.(isOverflowing.value)
}
const checkOverflow = options.debounceTime
? debounce(checkOverflowFn, options.debounceTime)
: checkOverflowFn
if (options.useMutationObserver) {
disposeFns.push(
useMutationObserver(element, checkOverflow, {
subtree: true,
childList: true
}).stop
)
}
if (options.useResizeObserver) {
disposeFns.push(useResizeObserver(element, checkOverflow).stop)
}
return {
isOverflowing: readonly(isOverflowing),
disposed: readonly(disposed),
checkOverflow,
dispose: () => {
disposed.value = true
disposeFns.forEach((fn) => fn())
}
}
}

View File

@@ -171,7 +171,11 @@ export function useCoreCommands(): ComfyCommand[] {
confirm('Clear workflow?')
) {
app.clean()
app.graph.clear()
if (app.canvas.subgraph) {
app.canvas.subgraph.clear()
} else {
app.graph.clear()
}
api.dispatchCustomEvent('graphCleared')
}
}

View File

@@ -420,7 +420,6 @@
"restart": "Restart"
},
"sideToolbar": {
"themeToggle": "Toggle Theme",
"helpCenter": "Help Center",
"logout": "Logout",
"queue": "Queue",
@@ -521,7 +520,13 @@
"clipspace": "Open Clipspace",
"resetView": "Reset canvas view",
"clear": "Clear workflow",
"toggleBottomPanel": "Toggle Bottom Panel"
"toggleBottomPanel": "Toggle Bottom Panel",
"theme": "Theme",
"dark": "Dark",
"light": "Light",
"manageExtensions": "Manage Extensions",
"settings": "Settings",
"help": "Help"
},
"tabMenu": {
"duplicateTab": "Duplicate Tab",
@@ -1595,5 +1600,11 @@
"whatsNewPopup": {
"learnMore": "Learn more",
"noReleaseNotes": "No release notes available."
},
"breadcrumbsMenu": {
"duplicate": "Duplicate",
"clearWorkflow": "Clear Workflow",
"deleteWorkflow": "Delete Workflow",
"enterNewName": "Enter new name"
}
}

View File

@@ -82,6 +82,12 @@
"title": "Crea una cuenta"
}
},
"breadcrumbsMenu": {
"clearWorkflow": "Limpiar flujo de trabajo",
"deleteWorkflow": "Eliminar flujo de trabajo",
"duplicate": "Duplicar",
"enterNewName": "Ingrese un nuevo nombre"
},
"chatHistory": {
"cancelEdit": "Cancelar",
"cancelEditTooltip": "Cancelar edición",
@@ -698,13 +704,17 @@
"batchCountTooltip": "El número de veces que la generación del flujo de trabajo debe ser encolada",
"clear": "Limpiar flujo de trabajo",
"clipspace": "Abrir Clipspace",
"dark": "Oscuro",
"disabled": "Deshabilitado",
"disabledTooltip": "El flujo de trabajo no se encolará automáticamente",
"execute": "Ejecutar",
"help": "Ayuda",
"hideMenu": "Ocultar menú",
"instant": "Instantáneo",
"instantTooltip": "El flujo de trabajo se encolará instantáneamente después de que finalice una generación",
"interrupt": "Cancelar ejecución actual",
"light": "Claro",
"manageExtensions": "Gestionar extensiones",
"onChange": "Al cambiar",
"onChangeTooltip": "El flujo de trabajo se encolará una vez que se haga un cambio",
"refresh": "Actualizar definiciones de nodos",
@@ -712,7 +722,9 @@
"run": "Ejecutar",
"runWorkflow": "Ejecutar flujo de trabajo (Shift para encolar al frente)",
"runWorkflowFront": "Ejecutar flujo de trabajo (Encolar al frente)",
"settings": "Configuración",
"showMenu": "Mostrar menú",
"theme": "Tema",
"toggleBottomPanel": "Alternar panel inferior"
},
"menuLabels": {
@@ -1159,7 +1171,6 @@
},
"showFlatList": "Mostrar lista plana"
},
"themeToggle": "Cambiar tema",
"workflowTab": {
"confirmDelete": "¿Estás seguro de que quieres eliminar este flujo de trabajo?",
"confirmDeleteTitle": "¿Eliminar flujo de trabajo?",

View File

@@ -82,6 +82,12 @@
"title": "Créer un compte"
}
},
"breadcrumbsMenu": {
"clearWorkflow": "Effacer le workflow",
"deleteWorkflow": "Supprimer le workflow",
"duplicate": "Dupliquer",
"enterNewName": "Entrez un nouveau nom"
},
"chatHistory": {
"cancelEdit": "Annuler",
"cancelEditTooltip": "Annuler la modification",
@@ -698,13 +704,17 @@
"batchCountTooltip": "Le nombre de fois que la génération du flux de travail doit être mise en file d'attente",
"clear": "Effacer le flux de travail",
"clipspace": "Ouvrir Clipspace",
"dark": "Sombre",
"disabled": "Désactivé",
"disabledTooltip": "Le flux de travail ne sera pas mis en file d'attente automatiquement",
"execute": "Exécuter",
"help": "Aide",
"hideMenu": "Masquer le menu",
"instant": "Instantané",
"instantTooltip": "Le flux de travail sera mis en file d'attente immédiatement après la fin d'une génération",
"interrupt": "Annuler l'exécution en cours",
"light": "Clair",
"manageExtensions": "Gérer les extensions",
"onChange": "Sur modification",
"onChangeTooltip": "Le flux de travail sera mis en file d'attente une fois une modification effectuée",
"refresh": "Actualiser les définitions des nœuds",
@@ -712,7 +722,9 @@
"run": "Exécuter",
"runWorkflow": "Exécuter le workflow (Maj pour mettre en file d'attente en premier)",
"runWorkflowFront": "Exécuter le workflow (Mettre en file d'attente en premier)",
"settings": "Paramètres",
"showMenu": "Afficher le menu",
"theme": "Thème",
"toggleBottomPanel": "Basculer le panneau inférieur"
},
"menuLabels": {
@@ -1159,7 +1171,6 @@
},
"showFlatList": "Afficher la liste plate"
},
"themeToggle": "Changer de thème",
"workflowTab": {
"confirmDelete": "Êtes-vous sûr de vouloir supprimer ce flux de travail ?",
"confirmDeleteTitle": "Supprimer le flux de travail ?",

View File

@@ -82,6 +82,12 @@
"title": "アカウントを作成する"
}
},
"breadcrumbsMenu": {
"clearWorkflow": "ワークフローをクリア",
"deleteWorkflow": "ワークフローを削除",
"duplicate": "複製",
"enterNewName": "新しい名前を入力"
},
"chatHistory": {
"cancelEdit": "キャンセル",
"cancelEditTooltip": "編集をキャンセル",
@@ -698,13 +704,17 @@
"batchCountTooltip": "ワークフロー生成回数",
"clear": "ワークフローをクリア",
"clipspace": "クリップスペースを開く",
"dark": "ダーク",
"disabled": "無効",
"disabledTooltip": "ワークフローは自動的にキューに追加されません",
"execute": "実行",
"help": "ヘルプ",
"hideMenu": "メニューを隠す",
"instant": "即時",
"instantTooltip": "生成完了後すぐにキューに追加",
"interrupt": "現在の実行を中止",
"light": "ライト",
"manageExtensions": "拡張機能の管理",
"onChange": "変更時",
"onChangeTooltip": "変更が行われるとワークフローがキューに追加されます",
"refresh": "ノードを更新",
@@ -712,7 +722,9 @@
"run": "実行する",
"runWorkflow": "ワークフローを実行する (Shiftで先頭にキュー)",
"runWorkflowFront": "ワークフローを実行する (先頭にキュー)",
"settings": "設定",
"showMenu": "メニューを表示",
"theme": "テーマ",
"toggleBottomPanel": "下部パネルを切り替え"
},
"menuLabels": {
@@ -1159,7 +1171,6 @@
},
"showFlatList": "フラットリストを表示"
},
"themeToggle": "テーマの切り替え",
"workflowTab": {
"confirmDelete": "このワークフローを削除してもよろしいですか?",
"confirmDeleteTitle": "ワークフローを削除しますか?",

View File

@@ -82,6 +82,12 @@
"title": "계정 생성"
}
},
"breadcrumbsMenu": {
"clearWorkflow": "워크플로우 지우기",
"deleteWorkflow": "워크플로우 삭제",
"duplicate": "복제",
"enterNewName": "새 이름 입력"
},
"chatHistory": {
"cancelEdit": "취소",
"cancelEditTooltip": "편집 취소",
@@ -698,13 +704,17 @@
"batchCountTooltip": "워크플로 작업을 실행 대기열에 반복 추가할 횟수",
"clear": "워크플로 비우기",
"clipspace": "클립스페이스 열기",
"dark": "다크",
"disabled": "비활성화됨",
"disabledTooltip": "워크플로 작업을 자동으로 실행 대기열에 추가하지 않습니다.",
"execute": "실행",
"help": "도움말",
"hideMenu": "메뉴 숨기기",
"instant": "즉시",
"instantTooltip": "워크플로 실행이 완료되면 즉시 실행 대기열에 추가합니다.",
"interrupt": "현재 실행 취소",
"light": "라이트",
"manageExtensions": "확장 프로그램 관리",
"onChange": "변경 시",
"onChangeTooltip": "변경이 있는 경우에만 워크플로를 실행 대기열에 추가합니다.",
"refresh": "노드 정의 새로 고침",
@@ -712,7 +722,9 @@
"run": "실행",
"runWorkflow": "워크플로우 실행 (시프트 키와 함께 클릭시 가장 먼저 실행)",
"runWorkflowFront": "워크플로우 실행 (가장 먼저 실행)",
"settings": "설정",
"showMenu": "메뉴 표시",
"theme": "테마",
"toggleBottomPanel": "하단 패널 전환"
},
"menuLabels": {
@@ -1159,7 +1171,6 @@
},
"showFlatList": "평면 목록 표시"
},
"themeToggle": "테마 전환",
"workflowTab": {
"confirmDelete": "정말로 이 워크플로를 삭제하시겠습니까?",
"confirmDeleteTitle": "워크플로 삭제",

View File

@@ -82,6 +82,12 @@
"title": "Создать аккаунт"
}
},
"breadcrumbsMenu": {
"clearWorkflow": "Очистить рабочий процесс",
"deleteWorkflow": "Удалить рабочий процесс",
"duplicate": "Дублировать",
"enterNewName": "Введите новое имя"
},
"chatHistory": {
"cancelEdit": "Отмена",
"cancelEditTooltip": "Отменить редактирование",
@@ -698,13 +704,17 @@
"batchCountTooltip": "Количество раз, когда генерация рабочего процесса должна быть помещена в очередь",
"clear": "Очистить рабочий процесс",
"clipspace": "Открыть Clipspace",
"dark": "Тёмная",
"disabled": "Отключено",
"disabledTooltip": "Рабочий процесс не будет автоматически помещён в очередь",
"execute": "Выполнить",
"help": "Справка",
"hideMenu": "Скрыть меню",
"instant": "Мгновенно",
"instantTooltip": "Рабочий процесс будет помещён в очередь сразу же после завершения генерации",
"interrupt": "Отменить текущее выполнение",
"light": "Светлая",
"manageExtensions": "Управление расширениями",
"onChange": "При изменении",
"onChangeTooltip": "Рабочий процесс будет поставлен в очередь после внесения изменений",
"refresh": "Обновить определения нод",
@@ -712,7 +722,9 @@
"run": "Запустить",
"runWorkflow": "Запустить рабочий процесс (Shift для очереди в начале)",
"runWorkflowFront": "Запустить рабочий процесс (Очередь в начале)",
"settings": "Настройки",
"showMenu": "Показать меню",
"theme": "Тема",
"toggleBottomPanel": "Переключить нижнюю панель"
},
"menuLabels": {
@@ -1159,7 +1171,6 @@
},
"showFlatList": "Показать плоский список"
},
"themeToggle": "Переключить тему",
"workflowTab": {
"confirmDelete": "Вы уверены, что хотите удалить этот рабочий процесс?",
"confirmDeleteTitle": "Удалить рабочий процесс?",

View File

@@ -82,6 +82,12 @@
"title": "建立帳戶"
}
},
"breadcrumbsMenu": {
"clearWorkflow": "清除工作流程",
"deleteWorkflow": "刪除工作流程",
"duplicate": "複製",
"enterNewName": "輸入新名稱"
},
"chatHistory": {
"cancelEdit": "取消",
"cancelEditTooltip": "取消編輯",
@@ -698,13 +704,17 @@
"batchCountTooltip": "工作流程產生應排入佇列的次數",
"clear": "清除工作流程",
"clipspace": "開啟 Clipspace",
"dark": "深色",
"disabled": "已停用",
"disabledTooltip": "工作流程將不會自動排入佇列",
"execute": "執行",
"help": "說明",
"hideMenu": "隱藏選單",
"instant": "立即",
"instantTooltip": "每次產生完成後,工作流程會立即排入佇列",
"interrupt": "取消目前執行",
"light": "淺色",
"manageExtensions": "管理擴充功能",
"onChange": "變更時",
"onChangeTooltip": "每當有變更時,工作流程會排入佇列",
"refresh": "重新整理節點定義",
@@ -712,7 +722,9 @@
"run": "執行",
"runWorkflow": "執行工作流程Shift 於前方排隊)",
"runWorkflowFront": "執行工作流程(前方排隊)",
"settings": "設定",
"showMenu": "顯示選單",
"theme": "主題",
"toggleBottomPanel": "切換下方面板"
},
"menuLabels": {
@@ -1159,7 +1171,6 @@
},
"showFlatList": "顯示平面清單"
},
"themeToggle": "切換主題",
"workflowTab": {
"confirmDelete": "您確定要刪除這個工作流程嗎?",
"confirmDeleteTitle": "刪除工作流程?",

View File

@@ -82,6 +82,12 @@
"title": "创建一个账户"
}
},
"breadcrumbsMenu": {
"clearWorkflow": "清除工作流程",
"deleteWorkflow": "刪除工作流程",
"duplicate": "複製",
"enterNewName": "輸入新名稱"
},
"chatHistory": {
"cancelEdit": "取消",
"cancelEditTooltip": "取消编辑",
@@ -698,13 +704,17 @@
"batchCountTooltip": "工作流生成次数",
"clear": "清空工作流",
"clipspace": "打开剪贴板",
"dark": "深色",
"disabled": "禁用",
"disabledTooltip": "工作流将不会自动执行",
"execute": "执行",
"help": "說明",
"hideMenu": "隐藏菜单",
"instant": "实时",
"instantTooltip": "工作流将会在生成完成后立即执行",
"interrupt": "取消当前任务",
"light": "淺色",
"manageExtensions": "管理擴充功能",
"onChange": "更改时",
"onChangeTooltip": "一旦进行更改,工作流将添加到执行队列",
"refresh": "刷新节点",
@@ -712,7 +722,9 @@
"run": "运行",
"runWorkflow": "运行工作流程Shift排在前面",
"runWorkflowFront": "运行工作流程(排在前面)",
"settings": "設定",
"showMenu": "显示菜单",
"theme": "主題",
"toggleBottomPanel": "底部面板"
},
"menuLabels": {
@@ -1159,7 +1171,6 @@
},
"showFlatList": "平铺结果"
},
"themeToggle": "主题切换",
"workflowTab": {
"confirmDelete": "您确定要删除此工作流吗?",
"confirmDeleteTitle": "删除工作流?",

View File

@@ -23,6 +23,7 @@ export const useMenuItemStore = defineStore('menuItem', () => {
// Create a new node if it doesn't exist
found = {
label: segment,
key: segment,
items: []
}
currentLevel.push(found)

View File

@@ -0,0 +1,27 @@
import { useEventListener } from '@vueuse/core'
export const whileMouseDown = (
elementOrEvent: HTMLElement | Event,
callback: (iteration: number) => void,
interval: number = 30
) => {
const element =
elementOrEvent instanceof HTMLElement
? elementOrEvent
: (elementOrEvent.target as HTMLElement)
let iteration = 0
const intervalId = setInterval(() => {
callback(iteration++)
}, interval)
const dispose = useEventListener(element, 'mouseup', () => {
clearInterval(intervalId)
dispose()
})
return {
dispose
}
}