mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-10 07:30:08 +00:00
App mode - Unify menus - 2 (#9023)
## Summary Updates subgraph breadcrumbs menu, workflow tabs context menu & linear mode menu to use a single implementation. Adds new menu items for enter/exit app mode Hides menu when in builder mode ## Changes - **What**: Changes the components to use either a reka-ui context menu or dropdown, with a standard inner list - **Breaking**: Remove existing linear toggle from sidebar as it is now in the menu ## Screenshots (if applicable) It looks basically identical other than the icon changes based on mode: In Graph Mode: <img width="261" height="497" alt="image" src="https://github.com/user-attachments/assets/eb9968a2-b528-4e21-9e14-ab4a67e717ae" /> In App Mode: <img width="254" height="499" alt="image" src="https://github.com/user-attachments/assets/54a89fab-e7b2-4cb0-bcb7-43d6d076ac83" /> Right click tab: <img width="321" height="564" alt="image" src="https://github.com/user-attachments/assets/c12c7d64-2dba-45bb-be76-2615f3e38cc6" /> ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9023-App-mode-Unify-menus-2-30d6d73d36508162bfc0e308d5f705de) by [Unito](https://www.unito.io) --------- Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
class="subgraph-breadcrumb flex w-auto drop-shadow-[var(--interface-panel-drop-shadow)]"
|
||||
class="subgraph-breadcrumb flex w-auto drop-shadow-[var(--interface-panel-drop-shadow)] items-center"
|
||||
:class="{
|
||||
'subgraph-breadcrumb-collapse': collapseTabs,
|
||||
'subgraph-breadcrumb-overflow': overflowingTabs
|
||||
@@ -13,17 +13,10 @@
|
||||
'--p-breadcrumb-icon-width': `${ICON_WIDTH}px`
|
||||
}"
|
||||
>
|
||||
<Button
|
||||
class="context-menu-button pointer-events-auto h-8 w-8 shrink-0 border border-transparent bg-transparent p-0 transition-all hover:rounded-lg hover:border-interface-stroke hover:bg-comfy-menu-bg"
|
||||
icon="pi pi-bars"
|
||||
text
|
||||
severity="secondary"
|
||||
size="small"
|
||||
@click="handleMenuClick"
|
||||
/>
|
||||
<WorkflowActionsDropdown source="breadcrumb_subgraph_menu_selected" />
|
||||
<Button
|
||||
v-if="isInSubgraph"
|
||||
class="back-button pointer-events-auto h-8 w-8 shrink-0 border border-transparent bg-transparent p-0 transition-all hover:rounded-lg hover:border-interface-stroke hover:bg-comfy-menu-bg"
|
||||
class="back-button pointer-events-auto h-8 w-8 shrink-0 border border-transparent bg-transparent p-0 ml-1.5 transition-all hover:rounded-lg hover:border-interface-stroke hover:bg-comfy-menu-bg"
|
||||
text
|
||||
severity="secondary"
|
||||
size="small"
|
||||
@@ -41,7 +34,6 @@
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<SubgraphBreadcrumbItem
|
||||
:ref="(el) => setItemRef(item, el)"
|
||||
:item="item"
|
||||
:is-active="item.key === activeItemKey"
|
||||
/>
|
||||
@@ -60,6 +52,7 @@ import type { MenuItem } from 'primevue/menuitem'
|
||||
import { computed, onUpdated, ref, watch } from 'vue'
|
||||
|
||||
import SubgraphBreadcrumbItem from '@/components/breadcrumb/SubgraphBreadcrumbItem.vue'
|
||||
import WorkflowActionsDropdown from '@/components/common/WorkflowActionsDropdown.vue'
|
||||
import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
@@ -77,12 +70,6 @@ const ICON_WIDTH = 20
|
||||
const workflowStore = useWorkflowStore()
|
||||
const navigationStore = useSubgraphNavigationStore()
|
||||
const breadcrumbRef = ref<InstanceType<typeof Breadcrumb>>()
|
||||
const rootItemRef = ref<InstanceType<typeof SubgraphBreadcrumbItem>>()
|
||||
const setItemRef = (item: MenuItem, el: unknown) => {
|
||||
if (item.key === 'root') {
|
||||
rootItemRef.value = el as InstanceType<typeof SubgraphBreadcrumbItem>
|
||||
}
|
||||
}
|
||||
const workflowName = computed(() => workflowStore.activeWorkflow?.filename)
|
||||
const isBlueprint = computed(() =>
|
||||
useSubgraphStore().isSubgraphBlueprint(workflowStore.activeWorkflow)
|
||||
@@ -136,13 +123,6 @@ const items = computed(() => {
|
||||
|
||||
const activeItemKey = computed(() => items.value.at(-1)?.key)
|
||||
|
||||
const handleMenuClick = (event: MouseEvent) => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'breadcrumb_subgraph_menu_selected'
|
||||
})
|
||||
rootItemRef.value?.toggleMenu(event)
|
||||
}
|
||||
|
||||
const handleBackClick = () => {
|
||||
void useCommandStore().execute('Comfy.Graph.ExitSubgraph')
|
||||
}
|
||||
|
||||
@@ -185,14 +185,6 @@ const inputBlur = async (doRename: boolean) => {
|
||||
|
||||
isEditing.value = false
|
||||
}
|
||||
|
||||
const toggleMenu = (event: MouseEvent) => {
|
||||
menu.value?.toggle(event)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
toggleMenu
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
77
src/components/common/WorkflowActionsDropdown.vue
Normal file
77
src/components/common/WorkflowActionsDropdown.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRoot,
|
||||
DropdownMenuTrigger
|
||||
} from 'reka-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import WorkflowActionsList from '@/components/common/WorkflowActionsList.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useWorkflowActionsMenu } from '@/composables/useWorkflowActionsMenu'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
const { source, align = 'start' } = defineProps<{
|
||||
source: string
|
||||
align?: 'start' | 'center' | 'end'
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const { menuItems } = useWorkflowActionsMenu(
|
||||
() => useCommandStore().execute('Comfy.RenameWorkflow'),
|
||||
{ isRoot: true }
|
||||
)
|
||||
|
||||
function handleOpen(open: boolean) {
|
||||
if (open) {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: source
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuRoot @update:open="handleOpen">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<slot name="button">
|
||||
<Button
|
||||
v-tooltip="{
|
||||
value: t('breadcrumbsMenu.workflowActions'),
|
||||
showDelay: 300,
|
||||
hideDelay: 300
|
||||
}"
|
||||
variant="secondary"
|
||||
size="unset"
|
||||
:aria-label="t('breadcrumbsMenu.workflowActions')"
|
||||
class="h-10 rounded-lg pl-3 pr-2 pointer-events-auto gap-1 data-[state=open]:bg-secondary-background-hover data-[state=open]:shadow-interface"
|
||||
>
|
||||
<i
|
||||
class="size-4"
|
||||
:class="
|
||||
canvasStore.linearMode
|
||||
? 'icon-[lucide--panels-top-left]'
|
||||
: 'icon-[comfy--workflow]'
|
||||
"
|
||||
/>
|
||||
<i class="icon-[lucide--chevron-down] size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</slot>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
:align
|
||||
:side-offset="5"
|
||||
:collision-padding="10"
|
||||
class="z-1000 rounded-lg px-2 py-3 min-w-56 bg-base-background shadow-interface border border-border-subtle"
|
||||
>
|
||||
<WorkflowActionsList :items="menuItems" />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuRoot>
|
||||
</template>
|
||||
81
src/components/common/WorkflowActionsList.test.ts
Normal file
81
src/components/common/WorkflowActionsList.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { shallowMount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import WorkflowActionsList from '@/components/common/WorkflowActionsList.vue'
|
||||
import type {
|
||||
WorkflowMenuAction,
|
||||
WorkflowMenuItem
|
||||
} from '@/types/workflowMenuItem'
|
||||
|
||||
function createWrapper(items: WorkflowMenuItem[]) {
|
||||
return shallowMount(WorkflowActionsList, {
|
||||
props: { items },
|
||||
global: { renderStubDefaultSlot: true }
|
||||
})
|
||||
}
|
||||
|
||||
describe('WorkflowActionsList', () => {
|
||||
it('renders action items with label and icon', () => {
|
||||
const items: WorkflowMenuItem[] = [
|
||||
{ label: 'Save', icon: 'pi pi-save', command: vi.fn() }
|
||||
]
|
||||
|
||||
const wrapper = createWrapper(items)
|
||||
|
||||
expect(wrapper.text()).toContain('Save')
|
||||
expect(wrapper.find('.pi-save').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders separator items', () => {
|
||||
const items: WorkflowMenuItem[] = [
|
||||
{ label: 'Before', icon: 'pi pi-a', command: vi.fn() },
|
||||
{ separator: true },
|
||||
{ label: 'After', icon: 'pi pi-b', command: vi.fn() }
|
||||
]
|
||||
|
||||
const wrapper = createWrapper(items)
|
||||
const html = wrapper.html()
|
||||
|
||||
expect(html).toContain('dropdown-menu-separator-stub')
|
||||
expect(wrapper.text()).toContain('Before')
|
||||
expect(wrapper.text()).toContain('After')
|
||||
})
|
||||
|
||||
it('dispatches command on select', async () => {
|
||||
const command = vi.fn()
|
||||
const items: WorkflowMenuItem[] = [
|
||||
{ label: 'Action', icon: 'pi pi-play', command }
|
||||
]
|
||||
|
||||
const wrapper = createWrapper(items)
|
||||
const item = wrapper.findComponent({ name: 'DropdownMenuItem' })
|
||||
await item.vm.$emit('select')
|
||||
|
||||
expect(command).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('renders badge when present', () => {
|
||||
const items: WorkflowMenuItem[] = [
|
||||
{
|
||||
label: 'New Feature',
|
||||
icon: 'pi pi-star',
|
||||
command: vi.fn(),
|
||||
badge: 'NEW'
|
||||
}
|
||||
]
|
||||
|
||||
const wrapper = createWrapper(items)
|
||||
|
||||
expect(wrapper.text()).toContain('NEW')
|
||||
})
|
||||
|
||||
it('does not render badge when absent', () => {
|
||||
const items: WorkflowMenuAction[] = [
|
||||
{ label: 'Plain', icon: 'pi pi-check', command: vi.fn() }
|
||||
]
|
||||
|
||||
const wrapper = createWrapper(items)
|
||||
|
||||
expect(wrapper.text()).not.toContain('NEW')
|
||||
})
|
||||
})
|
||||
53
src/components/common/WorkflowActionsList.vue
Normal file
53
src/components/common/WorkflowActionsList.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import { DropdownMenuItem, DropdownMenuSeparator } from 'reka-ui'
|
||||
import type { Component } from 'vue'
|
||||
|
||||
import OverlayIcon from '@/components/common/OverlayIcon.vue'
|
||||
import type { WorkflowMenuItem } from '@/types/workflowMenuItem'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const {
|
||||
items,
|
||||
itemComponent = DropdownMenuItem,
|
||||
separatorComponent = DropdownMenuSeparator
|
||||
} = defineProps<{
|
||||
items: WorkflowMenuItem[]
|
||||
itemComponent?: Component
|
||||
separatorComponent?: Component
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-for="(item, index) in items" :key="index">
|
||||
<component
|
||||
:is="separatorComponent"
|
||||
v-if="item.separator"
|
||||
class="border-b w-full border-border-subtle my-1"
|
||||
/>
|
||||
<component
|
||||
:is="itemComponent"
|
||||
v-else
|
||||
:disabled="item.disabled"
|
||||
:class="
|
||||
cn(
|
||||
'flex min-h-6 p-2 items-center gap-2 self-stretch rounded-sm outline-none',
|
||||
!item.disabled && item.command && 'cursor-pointer',
|
||||
'data-[highlighted]:bg-secondary-background-hover',
|
||||
!item.disabled && 'hover:bg-secondary-background-hover',
|
||||
'data-[disabled]:opacity-50 data-[disabled]:cursor-default'
|
||||
)
|
||||
"
|
||||
@select="() => item.command?.()"
|
||||
>
|
||||
<OverlayIcon v-if="item.overlayIcon" v-bind="item.overlayIcon" />
|
||||
<i v-else-if="item.icon" :class="item.icon" />
|
||||
<span class="flex-1">{{ item.label }}</span>
|
||||
<span
|
||||
v-if="item.badge"
|
||||
class="rounded-full uppercase ml-3 flex items-center gap-1 bg-[var(--primary-background)] px-1.5 py-0.5 text-xxs text-base-foreground"
|
||||
>
|
||||
{{ item.badge }}
|
||||
</span>
|
||||
</component>
|
||||
</template>
|
||||
</template>
|
||||
@@ -45,9 +45,6 @@
|
||||
<SidebarBottomPanelToggleButton v-if="!isCloud" :is-small="isSmall" />
|
||||
<SidebarShortcutsToggleButton :is-small="isSmall" />
|
||||
<SidebarSettingsButton :is-small="isSmall" />
|
||||
<ModeToggle
|
||||
v-if="menuItemStore.hasSeenLinear || flags.linearToggleEnabled"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<HelpCenterPopups :is-small="isSmall" />
|
||||
@@ -62,18 +59,15 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import HelpCenterPopups from '@/components/helpcenter/HelpCenterPopups.vue'
|
||||
import ComfyMenuButton from '@/components/sidebar/ComfyMenuButton.vue'
|
||||
import ModeToggle from '@/components/sidebar/ModeToggle.vue'
|
||||
import SidebarBottomPanelToggleButton from '@/components/sidebar/SidebarBottomPanelToggleButton.vue'
|
||||
import SidebarSettingsButton from '@/components/sidebar/SidebarSettingsButton.vue'
|
||||
import SidebarShortcutsToggleButton from '@/components/sidebar/SidebarShortcutsToggleButton.vue'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
|
||||
import { useMenuItemStore } from '@/stores/menuItemStore'
|
||||
import { useUserStore } from '@/stores/userStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import type { SidebarTabExtension } from '@/types/extensionTypes'
|
||||
@@ -90,11 +84,9 @@ const settingStore = useSettingStore()
|
||||
const userStore = useUserStore()
|
||||
const commandStore = useCommandStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const menuItemStore = useMenuItemStore()
|
||||
const sideToolbarRef = ref<HTMLElement>()
|
||||
const topToolbarRef = ref<HTMLElement>()
|
||||
const bottomToolbarRef = ref<HTMLElement>()
|
||||
const { flags } = useFeatureFlags()
|
||||
|
||||
const isSmall = computed(
|
||||
() => settingStore.get('Comfy.Sidebar.Size') === 'small'
|
||||
|
||||
@@ -1,36 +1,53 @@
|
||||
<template>
|
||||
<div
|
||||
ref="workflowTabRef"
|
||||
class="workflow-tab group flex gap-2 p-2"
|
||||
v-bind="$attrs"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
@click="handleClick"
|
||||
>
|
||||
<i
|
||||
v-if="workflowOption.workflow.activeState?.extra?.linearMode"
|
||||
class="icon-[lucide--panels-top-left] bg-primary-background"
|
||||
/>
|
||||
<span class="workflow-label inline-block max-w-[150px] truncate text-sm">
|
||||
{{ workflowOption.workflow.filename }}
|
||||
</span>
|
||||
<div class="relative">
|
||||
<span
|
||||
v-if="shouldShowStatusIndicator"
|
||||
class="absolute top-1/2 left-1/2 z-10 w-4 -translate-1/2 bg-(--comfy-menu-bg) text-2xl font-bold group-hover:hidden"
|
||||
>•</span
|
||||
<ContextMenuRoot>
|
||||
<ContextMenuTrigger as-child>
|
||||
<div
|
||||
ref="workflowTabRef"
|
||||
class="workflow-tab group flex gap-2 p-2"
|
||||
v-bind="$attrs"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
@click="handleClick"
|
||||
>
|
||||
<Button
|
||||
class="close-button invisible w-auto p-0"
|
||||
variant="muted-textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="t('g.close')"
|
||||
@click.stop="onCloseWorkflow(workflowOption)"
|
||||
<i
|
||||
v-if="workflowOption.workflow.activeState?.extra?.linearMode"
|
||||
class="icon-[lucide--panels-top-left] bg-primary-background"
|
||||
/>
|
||||
<span
|
||||
class="workflow-label inline-block max-w-[150px] truncate text-sm"
|
||||
>
|
||||
{{ workflowOption.workflow.filename }}
|
||||
</span>
|
||||
<div class="relative">
|
||||
<span
|
||||
v-if="shouldShowStatusIndicator"
|
||||
class="absolute top-1/2 left-1/2 z-10 w-4 -translate-1/2 bg-(--comfy-menu-bg) text-2xl font-bold group-hover:hidden"
|
||||
>•</span
|
||||
>
|
||||
<Button
|
||||
class="close-button invisible w-auto p-0"
|
||||
variant="muted-textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="t('g.close')"
|
||||
@click.stop="onCloseWorkflow(workflowOption)"
|
||||
>
|
||||
<i class="pi pi-times" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuPortal>
|
||||
<ContextMenuContent
|
||||
class="z-1000 rounded-lg px-2 py-3 min-w-56 bg-base-background shadow-interface border border-border-subtle"
|
||||
>
|
||||
<i class="pi pi-times" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<WorkflowActionsList
|
||||
:items="contextMenuItems"
|
||||
:item-component="ContextMenuItem"
|
||||
:separator-component="ContextMenuSeparator"
|
||||
/>
|
||||
</ContextMenuContent>
|
||||
</ContextMenuPortal>
|
||||
</ContextMenuRoot>
|
||||
|
||||
<WorkflowTabPopover
|
||||
ref="popoverRef"
|
||||
@@ -41,20 +58,32 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuPortal,
|
||||
ContextMenuRoot,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger
|
||||
} from 'reka-ui'
|
||||
import { computed, onUnmounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import WorkflowActionsList from '@/components/common/WorkflowActionsList.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import {
|
||||
usePragmaticDraggable,
|
||||
usePragmaticDroppable
|
||||
} from '@/composables/usePragmaticDragAndDrop'
|
||||
import { useWorkflowActionsMenu } from '@/composables/useWorkflowActionsMenu'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowThumbnail } from '@/renderer/core/thumbnail/useWorkflowThumbnail'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import type { WorkflowMenuItem } from '@/types/workflowMenuItem'
|
||||
|
||||
import WorkflowTabPopover from './WorkflowTabPopover.vue'
|
||||
|
||||
@@ -65,6 +94,8 @@ interface WorkflowOption {
|
||||
|
||||
const props = defineProps<{
|
||||
workflowOption: WorkflowOption
|
||||
isFirst: boolean
|
||||
isLast: boolean
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -148,6 +179,67 @@ const closeWorkflows = async (options: WorkflowOption[]) => {
|
||||
const onCloseWorkflow = async (option: WorkflowOption) => {
|
||||
await closeWorkflows([option])
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
closeToLeft: []
|
||||
closeToRight: []
|
||||
closeOthers: []
|
||||
}>()
|
||||
|
||||
const commandStore = useCommandStore()
|
||||
const workflow = computed(() => props.workflowOption.workflow)
|
||||
|
||||
const { menuItems: baseMenuItems } = useWorkflowActionsMenu(
|
||||
() => commandStore.execute('Comfy.RenameWorkflow'),
|
||||
{ includeDelete: false, workflow }
|
||||
)
|
||||
|
||||
const contextMenuItems = computed<WorkflowMenuItem[]>(() => [
|
||||
...baseMenuItems.value,
|
||||
{ separator: true },
|
||||
{
|
||||
label: t('tabMenu.closeTab'),
|
||||
icon: 'pi pi-times',
|
||||
command: () => onCloseWorkflow(props.workflowOption)
|
||||
},
|
||||
{
|
||||
label: t('tabMenu.closeTabsToLeft'),
|
||||
overlayIcon: {
|
||||
mainIcon: 'pi pi-times',
|
||||
subIcon: 'pi pi-arrow-left',
|
||||
positionX: 'right',
|
||||
positionY: 'bottom',
|
||||
subIconScale: 0.5
|
||||
},
|
||||
command: () => emit('closeToLeft'),
|
||||
disabled: props.isFirst
|
||||
},
|
||||
{
|
||||
label: t('tabMenu.closeTabsToRight'),
|
||||
overlayIcon: {
|
||||
mainIcon: 'pi pi-times',
|
||||
subIcon: 'pi pi-arrow-right',
|
||||
positionX: 'right',
|
||||
positionY: 'bottom',
|
||||
subIconScale: 0.5
|
||||
},
|
||||
command: () => emit('closeToRight'),
|
||||
disabled: props.isLast
|
||||
},
|
||||
{
|
||||
label: t('tabMenu.closeOtherTabs'),
|
||||
overlayIcon: {
|
||||
mainIcon: 'pi pi-times',
|
||||
subIcon: 'pi pi-arrows-h',
|
||||
positionX: 'right',
|
||||
positionY: 'bottom',
|
||||
subIconScale: 0.5
|
||||
},
|
||||
command: () => emit('closeOthers'),
|
||||
disabled: props.isFirst && props.isLast
|
||||
}
|
||||
])
|
||||
|
||||
const tabGetter = () => workflowTabRef.value as HTMLElement
|
||||
|
||||
usePragmaticDraggable(tabGetter, {
|
||||
|
||||
@@ -32,11 +32,20 @@
|
||||
data-key="value"
|
||||
@update:model-value="onWorkflowChange"
|
||||
>
|
||||
<template #option="{ option }">
|
||||
<template #option="{ option, index }">
|
||||
<WorkflowTab
|
||||
:workflow-option="option"
|
||||
@contextmenu="showContextMenu($event, option)"
|
||||
:is-first="index === 0"
|
||||
:is-last="index === options.length - 1"
|
||||
@click.middle="onCloseWorkflow(option)"
|
||||
@close-to-left="closeWorkflows(options.slice(0, index))"
|
||||
@close-to-right="closeWorkflows(options.slice(index + 1))"
|
||||
@close-others="
|
||||
closeWorkflows([
|
||||
...options.slice(index + 1),
|
||||
...options.slice(0, index)
|
||||
])
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
</SelectButton>
|
||||
@@ -58,7 +67,10 @@
|
||||
:active-workflow="workflowStore.activeWorkflow"
|
||||
/>
|
||||
<Button
|
||||
v-tooltip="{ value: $t('sideToolbar.newBlankWorkflow'), showDelay: 300 }"
|
||||
v-tooltip="{
|
||||
value: $t('sideToolbar.newBlankWorkflow'),
|
||||
showDelay: 300
|
||||
}"
|
||||
class="new-blank-workflow-button no-drag shrink-0 rounded-none h-full w-auto aspect-square"
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
@@ -80,27 +92,17 @@
|
||||
/>
|
||||
<LoginButton v-else-if="isDesktop" class="p-1" />
|
||||
</div>
|
||||
<ContextMenu ref="menu" :model="contextMenuItems">
|
||||
<template #itemicon="{ item }">
|
||||
<OverlayIcon v-if="item.overlayIcon" v-bind="item.overlayIcon" />
|
||||
<i v-else-if="item.icon" :class="item.icon" />
|
||||
</template>
|
||||
</ContextMenu>
|
||||
<div v-if="isDesktop" class="window-actions-spacer app-drag shrink-0" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useScroll } from '@vueuse/core'
|
||||
import ContextMenu from 'primevue/contextmenu'
|
||||
import ScrollPanel from 'primevue/scrollpanel'
|
||||
import SelectButton from 'primevue/selectbutton'
|
||||
import { computed, nextTick, onUpdated, ref, watch } from 'vue'
|
||||
import type { WatchStopHandle } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import OverlayIcon from '@/components/common/OverlayIcon.vue'
|
||||
import type { OverlayIconProps } from '@/components/common/OverlayIcon.vue'
|
||||
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
|
||||
import LoginButton from '@/components/topbar/LoginButton.vue'
|
||||
import TopMenuHelpButton from '@/components/topbar/TopMenuHelpButton.vue'
|
||||
@@ -108,7 +110,6 @@ import WorkflowTab from '@/components/topbar/WorkflowTab.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
|
||||
import { useWorkflowActionsMenu } from '@/composables/useWorkflowActionsMenu'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
@@ -129,7 +130,6 @@ const props = defineProps<{
|
||||
class?: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const settingStore = useSettingStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
@@ -141,8 +141,6 @@ const isIntegratedTabBar = computed(
|
||||
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
|
||||
)
|
||||
|
||||
const rightClickedTab = ref<WorkflowOption | undefined>()
|
||||
const menu = ref()
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const showOverflowArrows = ref(false)
|
||||
const leftArrowEnabled = ref(false)
|
||||
@@ -192,78 +190,6 @@ const onCloseWorkflow = async (option: WorkflowOption) => {
|
||||
await closeWorkflows([option])
|
||||
}
|
||||
|
||||
const showContextMenu = (event: MouseEvent, option: WorkflowOption) => {
|
||||
rightClickedTab.value = option
|
||||
menu.value.show(event)
|
||||
}
|
||||
|
||||
const rightClickedWorkflow = computed(
|
||||
() => rightClickedTab.value?.workflow ?? null
|
||||
)
|
||||
|
||||
const { menuItems: baseMenuItems } = useWorkflowActionsMenu(
|
||||
() => commandStore.execute('Comfy.RenameWorkflow'),
|
||||
{
|
||||
includeDelete: false,
|
||||
workflow: rightClickedWorkflow
|
||||
}
|
||||
)
|
||||
|
||||
const contextMenuItems = computed(() => {
|
||||
const tab = rightClickedTab.value
|
||||
if (!tab) return []
|
||||
const index = options.value.findIndex((v) => v.workflow === tab.workflow)
|
||||
|
||||
return [
|
||||
...baseMenuItems.value,
|
||||
{
|
||||
label: t('tabMenu.closeTab'),
|
||||
icon: 'pi pi-times',
|
||||
command: () => onCloseWorkflow(tab)
|
||||
},
|
||||
{
|
||||
label: t('tabMenu.closeTabsToLeft'),
|
||||
overlayIcon: {
|
||||
mainIcon: 'pi pi-times',
|
||||
subIcon: 'pi pi-arrow-left',
|
||||
positionX: 'right',
|
||||
positionY: 'bottom',
|
||||
subIconScale: 0.5
|
||||
} as OverlayIconProps,
|
||||
command: () => closeWorkflows(options.value.slice(0, index)),
|
||||
disabled: index <= 0
|
||||
},
|
||||
{
|
||||
label: t('tabMenu.closeTabsToRight'),
|
||||
overlayIcon: {
|
||||
mainIcon: 'pi pi-times',
|
||||
subIcon: 'pi pi-arrow-right',
|
||||
positionX: 'right',
|
||||
positionY: 'bottom',
|
||||
subIconScale: 0.5
|
||||
} as OverlayIconProps,
|
||||
command: () => closeWorkflows(options.value.slice(index + 1)),
|
||||
disabled: index === options.value.length - 1
|
||||
},
|
||||
{
|
||||
label: t('tabMenu.closeOtherTabs'),
|
||||
overlayIcon: {
|
||||
mainIcon: 'pi pi-times',
|
||||
subIcon: 'pi pi-arrows-h',
|
||||
positionX: 'right',
|
||||
positionY: 'bottom',
|
||||
subIconScale: 0.5
|
||||
} as OverlayIconProps,
|
||||
command: () =>
|
||||
closeWorkflows([
|
||||
...options.value.slice(index + 1),
|
||||
...options.value.slice(0, index)
|
||||
]),
|
||||
disabled: options.value.length <= 1
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
// Horizontal scroll on wheel
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
const scrollElement = event.currentTarget as HTMLElement
|
||||
|
||||
Reference in New Issue
Block a user