Merge branch 'main' into sno-fix-playwright-babel-config

This commit is contained in:
snomiao
2025-09-15 06:58:58 +00:00
151 changed files with 9341 additions and 659 deletions

View File

@@ -0,0 +1,120 @@
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import PrimeVue from 'primevue/config'
import Tooltip from 'primevue/tooltip'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import BypassButton from '@/components/graph/selectionToolbox/BypassButton.vue'
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCommandStore } from '@/stores/commandStore'
const mockLGraphNode = {
type: 'TestNode',
title: 'Test Node',
mode: LGraphEventMode.ALWAYS
}
vi.mock('@/utils/litegraphUtil', () => ({
isLGraphNode: vi.fn(() => true)
}))
describe('BypassButton', () => {
let canvasStore: ReturnType<typeof useCanvasStore>
let commandStore: ReturnType<typeof useCommandStore>
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
selectionToolbox: {
bypassButton: {
tooltip: 'Toggle bypass mode'
}
}
}
}
})
beforeEach(() => {
setActivePinia(createPinia())
canvasStore = useCanvasStore()
commandStore = useCommandStore()
vi.clearAllMocks()
})
const mountComponent = () => {
return mount(BypassButton, {
global: {
plugins: [i18n, PrimeVue],
directives: { tooltip: Tooltip },
stubs: {
'i-lucide:ban': true
}
}
})
}
it('should render bypass button', () => {
canvasStore.selectedItems = [mockLGraphNode] as any
const wrapper = mountComponent()
const button = wrapper.find('button')
expect(button.exists()).toBe(true)
})
it('should have correct test id', () => {
canvasStore.selectedItems = [mockLGraphNode] as any
const wrapper = mountComponent()
const button = wrapper.find('[data-testid="bypass-button"]')
expect(button.exists()).toBe(true)
})
it('should execute bypass command when clicked', async () => {
canvasStore.selectedItems = [mockLGraphNode] as any
const executeSpy = vi.spyOn(commandStore, 'execute').mockResolvedValue()
const wrapper = mountComponent()
await wrapper.find('button').trigger('click')
expect(executeSpy).toHaveBeenCalledWith(
'Comfy.Canvas.ToggleSelectedNodes.Bypass'
)
})
it('should show normal styling when node is not bypassed', () => {
const normalNode = { ...mockLGraphNode, mode: LGraphEventMode.ALWAYS }
canvasStore.selectedItems = [normalNode] as any
const wrapper = mountComponent()
const button = wrapper.find('button')
expect(button.classes()).not.toContain(
'dark-theme:[&:not(:active)]:!bg-[#262729]'
)
})
it('should show bypassed styling when node is bypassed', async () => {
const bypassedNode = { ...mockLGraphNode, mode: LGraphEventMode.BYPASS }
canvasStore.selectedItems = [bypassedNode] as any
vi.spyOn(commandStore, 'execute').mockResolvedValue()
const wrapper = mountComponent()
// Click to trigger the reactivity update
await wrapper.find('button').trigger('click')
await wrapper.vm.$nextTick()
const button = wrapper.find('button')
expect(button.exists()).toBe(true)
})
it('should handle multiple selected items', () => {
vi.spyOn(commandStore, 'execute').mockResolvedValue()
canvasStore.selectedItems = [mockLGraphNode, mockLGraphNode] as any
const wrapper = mountComponent()
const button = wrapper.find('button')
expect(button.exists()).toBe(true)
})
})

View File

@@ -1,6 +1,5 @@
<template>
<Button
v-show="canvasStore.nodeSelected"
v-tooltip.top="{
value: t('commands.Comfy_Canvas_ToggleSelectedNodes_Bypass.label'),
showDelay: 1000
@@ -8,12 +7,11 @@
severity="secondary"
text
data-testid="bypass-button"
@click="
() => commandStore.execute('Comfy.Canvas.ToggleSelectedNodes.Bypass')
"
class="hover:dark-theme:bg-charcoal-300 hover:bg-[#E7E6E6]"
@click="toggleBypass"
>
<template #icon>
<i-game-icons:detour />
<i-lucide:ban class="w-4 h-4" />
</template>
</Button>
</template>
@@ -23,9 +21,11 @@ import Button from 'primevue/button'
import { useI18n } from 'vue-i18n'
import { useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
const { t } = useI18n()
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
const toggleBypass = async () => {
await commandStore.execute('Comfy.Canvas.ToggleSelectedNodes.Bypass')
}
</script>

View File

@@ -7,7 +7,7 @@ import { createI18n } from 'vue-i18n'
// Import after mocks
import ColorPickerButton from '@/components/graph/selectionToolbox/ColorPickerButton.vue'
import { useCanvasStore } from '@/stores/graphStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useWorkflowStore } from '@/stores/workflowStore'
// Mock the litegraph module
@@ -95,17 +95,6 @@ describe('ColorPickerButton', () => {
expect(wrapper.find('button').exists()).toBe(true)
})
it('should not render when nothing is selected', () => {
// Keep selectedItems empty
canvasStore.selectedItems = []
const wrapper = createWrapper()
// The button exists but is hidden with v-show
expect(wrapper.find('button').exists()).toBe(true)
expect(wrapper.find('button').attributes('style')).toContain(
'display: none'
)
})
it('should toggle color picker visibility on button click', async () => {
canvasStore.selectedItems = [{ type: 'LGraphNode' } as any]
const wrapper = createWrapper()

View File

@@ -1,21 +1,25 @@
<template>
<div class="relative">
<Button
v-show="canvasStore.nodeSelected || canvasStore.groupSelected"
v-tooltip.top="{
value: localizedCurrentColorName ?? t('color.noColor'),
showDelay: 512
showDelay: 1000
}"
data-testid="color-picker-button"
severity="secondary"
text
@click="() => (showColorPicker = !showColorPicker)"
>
<template #icon>
<div class="flex items-center gap-1">
<i class="pi pi-circle-fill" :style="{ color: currentColor ?? '' }" />
<i class="pi pi-chevron-down" :style="{ fontSize: '0.5rem' }" />
</div>
</template>
<div class="flex items-center gap-1 px-0">
<i
class="w-4 h-4 pi pi-circle-fill"
:style="{ color: currentColor ?? '' }"
/>
<i
class="w-4 h-4 pi pi-chevron-down py-1"
:style="{ fontSize: '0.5rem' }"
/>
</div>
</Button>
<div
v-if="showColorPicker"
@@ -46,16 +50,19 @@
<script setup lang="ts">
import Button from 'primevue/button'
import SelectButton from 'primevue/selectbutton'
import { computed, ref, watch } from 'vue'
import { Raw, computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type { ColorOption as CanvasColorOption } from '@/lib/litegraph/src/litegraph'
import type {
ColorOption as CanvasColorOption,
Positionable
} from '@/lib/litegraph/src/litegraph'
import {
LGraphCanvas,
LiteGraph,
isColorable
} from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/stores/graphStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useWorkflowStore } from '@/stores/workflowStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { adjustColor } from '@/utils/colorUtil'
@@ -140,13 +147,17 @@ const localizedCurrentColorName = computed(() => {
)
return colorOption?.localizedName ?? NO_COLOR_OPTION.localizedName
})
const updateColorSelectionFromNode = (
newSelectedItems: Raw<Positionable[]>
) => {
showColorPicker.value = false
selectedColorOption.value = null
currentColorOption.value = getItemsColorOption(newSelectedItems)
}
watch(
() => canvasStore.selectedItems,
(newSelectedItems) => {
showColorPicker.value = false
selectedColorOption.value = null
currentColorOption.value = getItemsColorOption(newSelectedItems)
updateColorSelectionFromNode(newSelectedItems)
},
{ immediate: true }
)

View File

@@ -6,11 +6,12 @@
showDelay: 1000
}"
severity="secondary"
data-testid="convert-to-subgraph-button"
text
@click="() => commandStore.execute('Comfy.Graph.UnpackSubgraph')"
>
<template #icon>
<i-lucide:expand />
<i-lucide:expand class="w-4 h-4" />
</template>
</Button>
<Button
@@ -20,6 +21,7 @@
showDelay: 1000
}"
severity="secondary"
data-testid="convert-to-subgraph-button"
text
@click="() => commandStore.execute('Comfy.Graph.ConvertToSubgraph')"
>
@@ -34,25 +36,15 @@ import Button from 'primevue/button'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { useSelectionState } from '@/composables/graph/useSelectionState'
import { useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
const { t } = useI18n()
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
const { isSingleSubgraph, hasAnySelection } = useSelectionState()
const isUnpackVisible = computed(() => {
return (
canvasStore.selectedItems?.length === 1 &&
canvasStore.selectedItems[0] instanceof SubgraphNode
)
})
const isConvertVisible = computed(() => {
return (
canvasStore.groupSelected ||
canvasStore.rerouteSelected ||
canvasStore.nodeSelected
)
})
const isUnpackVisible = isSingleSubgraph
const isConvertVisible = computed(
() => hasAnySelection.value && !isSingleSubgraph.value
)
</script>

View File

@@ -5,9 +5,11 @@
value: t('commands.Comfy_Canvas_DeleteSelectedItems.label'),
showDelay: 1000
}"
severity="danger"
severity="secondary"
text
icon-class="w-4 h-4"
icon="pi pi-trash"
data-testid="delete-button"
@click="() => commandStore.execute('Comfy.Canvas.DeleteSelectedItems')"
/>
</template>
@@ -17,14 +19,15 @@ import Button from 'primevue/button'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useSelectionState } from '@/composables/graph/useSelectionState'
import { Positionable } from '@/lib/litegraph/src/interfaces'
import { useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
const { t } = useI18n()
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
const { selectedItems } = useSelectionState()
const isDeletable = computed(() =>
canvasStore.selectedItems.some((x) => x.removable !== false)
selectedItems.value.some((x: Positionable) => x.removable !== false)
)
</script>

View File

@@ -0,0 +1,128 @@
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import PrimeVue from 'primevue/config'
import Tooltip from 'primevue/tooltip'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import ExecuteButton from '@/components/graph/selectionToolbox/ExecuteButton.vue'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCommandStore } from '@/stores/commandStore'
// Mock the stores
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: vi.fn()
}))
vi.mock('@/stores/commandStore', () => ({
useCommandStore: vi.fn()
}))
// Mock the utils
vi.mock('@/utils/litegraphUtil', () => ({
isLGraphNode: vi.fn((node) => !!node?.type)
}))
vi.mock('@/utils/nodeFilterUtil', () => ({
isOutputNode: vi.fn((node) => !!node?.constructor?.nodeData?.output_node)
}))
// Mock the composables
vi.mock('@/composables/graph/useSelectionState', () => ({
useSelectionState: vi.fn(() => ({
selectedNodes: {
value: []
}
}))
}))
describe('ExecuteButton', () => {
let mockCanvas: any
let mockCanvasStore: any
let mockCommandStore: any
let mockSelectedNodes: any[]
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
selectionToolbox: {
executeButton: {
tooltip: 'Execute selected nodes'
}
}
}
}
})
beforeEach(async () => {
setActivePinia(createPinia())
// Reset mocks
mockCanvas = {
setDirty: vi.fn()
}
mockSelectedNodes = []
mockCanvasStore = {
getCanvas: vi.fn(() => mockCanvas),
selectedItems: []
}
mockCommandStore = {
execute: vi.fn()
}
// Setup store mocks
vi.mocked(useCanvasStore).mockReturnValue(mockCanvasStore as any)
vi.mocked(useCommandStore).mockReturnValue(mockCommandStore as any)
// Update the useSelectionState mock
const { useSelectionState } = vi.mocked(
await import('@/composables/graph/useSelectionState')
)
useSelectionState.mockReturnValue({
selectedNodes: {
value: mockSelectedNodes
}
} as any)
vi.clearAllMocks()
})
const mountComponent = () => {
return mount(ExecuteButton, {
global: {
plugins: [i18n, PrimeVue],
directives: { tooltip: Tooltip },
stubs: {
'i-lucide:play': { template: '<div class="play-icon" />' }
}
}
})
}
describe('Rendering', () => {
it('should be able to render', () => {
const wrapper = mountComponent()
const button = wrapper.find('button')
expect(button.exists()).toBe(true)
})
})
describe('Click Handler', () => {
it('should execute Comfy.QueueSelectedOutputNodes command on click', async () => {
const wrapper = mountComponent()
const button = wrapper.find('button')
await button.trigger('click')
expect(mockCommandStore.execute).toHaveBeenCalledWith(
'Comfy.QueueSelectedOutputNodes'
)
expect(mockCommandStore.execute).toHaveBeenCalledTimes(1)
})
})
})

View File

@@ -1,20 +1,16 @@
<template>
<Button
v-show="canvasStore.nodeSelected"
v-tooltip.top="{
value: isDisabled
? t('selectionToolbox.executeButton.disabledTooltip')
: t('selectionToolbox.executeButton.tooltip'),
value: t('selectionToolbox.executeButton.tooltip'),
showDelay: 1000
}"
:severity="isDisabled ? 'secondary' : 'success'"
class="dark-theme:bg-[#0B8CE9] bg-[#31B9F4] size-8 !p-0"
text
:disabled="isDisabled"
@mouseenter="() => handleMouseEnter()"
@mouseleave="() => handleMouseLeave()"
@click="handleClick"
>
<i-lucide:play />
<i-lucide:play class="fill-path-white w-4 h-4" />
</Button>
</template>
@@ -23,26 +19,24 @@ import Button from 'primevue/button'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useSelectionState } from '@/composables/graph/useSelectionState'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
import { isLGraphNode } from '@/utils/litegraphUtil'
import { isOutputNode } from '@/utils/nodeFilterUtil'
const { t } = useI18n()
const canvasStore = useCanvasStore()
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
const { selectedNodes } = useSelectionState()
const canvas = canvasStore.getCanvas()
const buttonHovered = ref(false)
const selectedOutputNodes = computed(
() =>
canvasStore.selectedItems.filter(
(item) => isLGraphNode(item) && item.constructor.nodeData?.output_node
) as LGraphNode[]
const selectedOutputNodes = computed(() =>
selectedNodes.value.filter(isLGraphNode).filter(isOutputNode)
)
const isDisabled = computed(() => selectedOutputNodes.value.length === 0)
function outputNodeStokeStyle(this: LGraphNode) {
if (
this.selected &&
@@ -70,3 +64,9 @@ const handleClick = async () => {
await commandStore.execute('Comfy.QueueSelectedOutputNodes')
}
</script>
<style scoped>
:deep.fill-path-white > path {
fill: white;
stroke: unset;
}
</style>

View File

@@ -7,6 +7,7 @@
}"
severity="secondary"
text
icon-class="w-4 h-4"
:icon="typeof command.icon === 'function' ? command.icon() : command.icon"
@click="() => commandStore.execute(command.id)"
/>

View File

@@ -0,0 +1,22 @@
<template>
<Button
v-tooltip.top="{
value: $t('g.frameNodes'),
showDelay: 1000
}"
class="frame-nodes-button"
text
severity="secondary"
@click="frameNodes"
>
<i-lucide:frame class="w-4 h-4" />
</Button>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { useFrameNodes } from '@/composables/graph/useFrameNodes'
const { frameNodes } = useFrameNodes()
</script>

View File

@@ -1,49 +0,0 @@
<template>
<Button
v-show="nodeDef"
v-tooltip.top="{
value: $t('g.help'),
showDelay: 1000
}"
class="help-button"
text
icon="pi pi-question-circle"
severity="secondary"
@click="showHelp"
/>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { computed } from 'vue'
import { useNodeLibrarySidebarTab } from '@/composables/sidebarTabs/useNodeLibrarySidebarTab'
import { useCanvasStore } from '@/stores/graphStore'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { isLGraphNode } from '@/utils/litegraphUtil'
const canvasStore = useCanvasStore()
const nodeDefStore = useNodeDefStore()
const sidebarTabStore = useSidebarTabStore()
const nodeHelpStore = useNodeHelpStore()
const { id: nodeLibraryTabId } = useNodeLibrarySidebarTab()
const nodeDef = computed<ComfyNodeDefImpl | null>(() => {
if (canvasStore.selectedItems.length !== 1) return null
const item = canvasStore.selectedItems[0]
if (!isLGraphNode(item)) return null
return nodeDefStore.fromLGraphNode(item)
})
const showHelp = () => {
const def = nodeDef.value
if (!def) return
if (sidebarTabStore.activeSidebarTabId !== nodeLibraryTabId) {
sidebarTabStore.toggleSidebarTab(nodeLibraryTabId)
}
nodeHelpStore.openHelp(def)
}
</script>

View File

@@ -0,0 +1,149 @@
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import PrimeVue from 'primevue/config'
import Tooltip from 'primevue/tooltip'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import InfoButton from '@/components/graph/selectionToolbox/InfoButton.vue'
// NOTE: The component import must come after mocks so they take effect.
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
const mockLGraphNode = {
type: 'TestNode',
title: 'Test Node'
}
vi.mock('@/utils/litegraphUtil', () => ({
isLGraphNode: vi.fn(() => true)
}))
vi.mock('@/composables/sidebarTabs/useNodeLibrarySidebarTab', () => ({
useNodeLibrarySidebarTab: () => ({
id: 'node-library'
})
}))
const openHelpMock = vi.fn()
const closeHelpMock = vi.fn()
const nodeHelpState: { currentHelpNode: any } = { currentHelpNode: null }
vi.mock('@/stores/workspace/nodeHelpStore', () => ({
useNodeHelpStore: () => ({
openHelp: (def: any) => {
nodeHelpState.currentHelpNode = def
openHelpMock(def)
},
closeHelp: () => {
nodeHelpState.currentHelpNode = null
closeHelpMock()
},
get currentHelpNode() {
return nodeHelpState.currentHelpNode
},
get isHelpOpen() {
return nodeHelpState.currentHelpNode !== null
}
})
}))
const toggleSidebarTabMock = vi.fn((id: string) => {
sidebarState.activeSidebarTabId =
sidebarState.activeSidebarTabId === id ? null : id
})
const sidebarState: { activeSidebarTabId: string | null } = {
activeSidebarTabId: 'other-tab'
}
vi.mock('@/stores/workspace/sidebarTabStore', () => ({
useSidebarTabStore: () => ({
get activeSidebarTabId() {
return sidebarState.activeSidebarTabId
},
toggleSidebarTab: toggleSidebarTabMock
})
}))
describe('InfoButton', () => {
let canvasStore: ReturnType<typeof useCanvasStore>
let nodeDefStore: ReturnType<typeof useNodeDefStore>
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
info: 'Node Info'
}
}
}
})
beforeEach(() => {
setActivePinia(createPinia())
canvasStore = useCanvasStore()
nodeDefStore = useNodeDefStore()
vi.clearAllMocks()
})
const mountComponent = () => {
return mount(InfoButton, {
global: {
plugins: [i18n, PrimeVue],
directives: { tooltip: Tooltip },
stubs: {
'i-lucide:info': true,
Button: {
template:
'<button class="help-button" severity="secondary"><slot /></button>',
props: ['severity', 'text', 'class'],
emits: ['click']
}
}
}
})
}
it('should handle click without errors', async () => {
const mockNodeDef = {
nodePath: 'test/node',
display_name: 'Test Node'
}
canvasStore.selectedItems = [mockLGraphNode] as any
vi.spyOn(nodeDefStore, 'fromLGraphNode').mockReturnValue(mockNodeDef as any)
const wrapper = mountComponent()
const button = wrapper.find('button')
await button.trigger('click')
expect(button.exists()).toBe(true)
})
it('should have correct CSS classes', () => {
const mockNodeDef = {
nodePath: 'test/node',
display_name: 'Test Node'
}
canvasStore.selectedItems = [mockLGraphNode] as any
vi.spyOn(nodeDefStore, 'fromLGraphNode').mockReturnValue(mockNodeDef as any)
const wrapper = mountComponent()
const button = wrapper.find('button')
expect(button.classes()).toContain('help-button')
expect(button.attributes('severity')).toBe('secondary')
})
it('should have correct tooltip', () => {
const mockNodeDef = {
nodePath: 'test/node',
display_name: 'Test Node'
}
canvasStore.selectedItems = [mockLGraphNode] as any
vi.spyOn(nodeDefStore, 'fromLGraphNode').mockReturnValue(mockNodeDef as any)
const wrapper = mountComponent()
const button = wrapper.find('button')
expect(button.exists()).toBe(true)
})
})

View File

@@ -0,0 +1,22 @@
<template>
<Button
v-tooltip.top="{
value: $t('g.info'),
showDelay: 1000
}"
data-testid="info-button"
text
severity="secondary"
@click="toggleHelp"
>
<i-lucide:info class="w-4 h-4" />
</Button>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { useSelectionState } from '@/composables/graph/useSelectionState'
const { showNodeHelp: toggleHelp } = useSelectionState()
</script>

View File

@@ -1,6 +1,5 @@
<template>
<Button
v-show="is3DNode"
v-tooltip.top="{
value: t('commands.Comfy_3DViewer_Open3DViewer.label'),
showDelay: 1000
@@ -8,29 +7,18 @@
severity="secondary"
text
icon="pi pi-pencil"
icon-class="w-4 h-4"
@click="open3DViewer"
/>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { computed } from 'vue'
import { t } from '@/i18n'
import { useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
import { useSettingStore } from '@/stores/settingStore'
import { isLGraphNode, isLoad3dNode } from '@/utils/litegraphUtil'
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
const is3DNode = computed(() => {
const enable3DViewer = useSettingStore().get('Comfy.Load3D.3DViewerEnable')
const nodes = canvasStore.selectedItems.filter(isLGraphNode)
return nodes.length === 1 && nodes.some(isLoad3dNode) && enable3DViewer
})
const open3DViewer = () => {
void commandStore.execute('Comfy.3DViewer.Open3DViewer')

View File

@@ -7,28 +7,21 @@
}"
severity="secondary"
text
icon="pi pi-pencil"
@click="openMaskEditor"
/>
>
<i-comfy:mask class="!w-4 !h-4" />
</Button>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { computed } from 'vue'
import { useSelectionState } from '@/composables/graph/useSelectionState'
import { t } from '@/i18n'
import { useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
import { isImageNode, isLGraphNode } from '@/utils/litegraphUtil'
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
const isSingleImageNode = computed(() => {
const { selectedItems } = canvasStore
const item = selectedItems[0]
return selectedItems.length === 1 && isLGraphNode(item) && isImageNode(item)
})
const { isSingleImageNode } = useSelectionState()
const openMaskEditor = () => {
void commandStore.execute('Comfy.MaskEditor.OpenMaskEditor')

View File

@@ -0,0 +1,59 @@
<template>
<div
v-if="option.type === 'divider'"
class="h-px bg-gray-200 dark-theme:bg-zinc-700 my-1"
/>
<div
v-else
role="button"
class="flex items-center gap-2 px-3 py-1.5 text-sm text-left hover:bg-gray-100 dark-theme:hover:bg-zinc-700 rounded cursor-pointer"
@click="handleClick"
>
<i v-if="option.icon" :class="[option.icon, 'w-4 h-4']" />
<span class="flex-1">{{ option.label }}</span>
<span v-if="option.shortcut" class="text-xs opacity-60">
{{ option.shortcut }}
</span>
<i-lucide:chevron-right
v-if="option.hasSubmenu"
:size="14"
class="opacity-60"
/>
<Badge
v-if="option.badge"
:severity="option.badge === 'new' ? 'info' : 'secondary'"
:value="t(option.badge)"
:class="{
'bg-[#31B9F4] dark-theme:bg-[#0B8CE9] rounded-4xl':
option.badge === 'new',
'bg-[#9C9EAB] dark-theme:bg-[#000] rounded-4xl':
option.badge === 'deprecated',
'text-white uppercase text-[9px] h-4 px-1 gap-2.5': true
}"
/>
</div>
</template>
<script setup lang="ts">
import Badge from 'primevue/badge'
import { useI18n } from 'vue-i18n'
import type { MenuOption } from '@/composables/graph/useMoreOptionsMenu'
const { t } = useI18n()
interface Props {
option: MenuOption
}
interface Emits {
(e: 'click', option: MenuOption, event: Event): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const handleClick = (event: Event) => {
emit('click', props.option, event)
}
</script>

View File

@@ -0,0 +1,316 @@
<template>
<div class="relative inline-flex items-center">
<Button
ref="buttonRef"
v-tooltip.top="{
value: $t('g.moreOptions'),
showDelay: 1000
}"
data-testid="more-options-button"
text
class="h-8 w-8 px-0"
severity="secondary"
@click="toggle"
>
<i-lucide:more-vertical class="w-4 h-4" />
</Button>
<Popover
ref="popover"
:append-to="'body'"
:auto-z-index="true"
:base-z-index="1000"
:dismissable="true"
:close-on-escape="true"
unstyled
:pt="pt"
@show="onPopoverShow"
@hide="onPopoverHide"
>
<div class="flex flex-col p-2 min-w-48">
<MenuOptionItem
v-for="(option, index) in menuOptions"
:key="option.label || `divider-${index}`"
:option="option"
@click="handleOptionClick"
/>
</div>
</Popover>
<SubmenuPopover
v-for="option in menuOptionsWithSubmenu"
:key="`submenu-${option.label}`"
:ref="(el) => setSubmenuRef(`submenu-${option.label}`, el)"
:option="option"
:container-styles="containerStyles"
@submenu-click="handleSubmenuClick"
/>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import Popover from 'primevue/popover'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformSync'
import {
forceCloseMoreOptionsSignal,
moreOptionsOpen,
moreOptionsRestorePending,
restoreMoreOptionsSignal
} from '@/composables/canvas/useSelectionToolboxPosition'
import {
type MenuOption,
type SubMenuOption,
useMoreOptionsMenu
} from '@/composables/graph/useMoreOptionsMenu'
import { useSubmenuPositioning } from '@/composables/graph/useSubmenuPositioning'
import { useMinimap } from '@/renderer/extensions/minimap/composables/useMinimap'
import MenuOptionItem from './MenuOptionItem.vue'
import SubmenuPopover from './SubmenuPopover.vue'
const popover = ref<InstanceType<typeof Popover>>()
const buttonRef = ref<InstanceType<typeof Button> | HTMLElement | null>(null)
// Track open state ourselves so we can restore after drag/move
const isOpen = ref(false)
const wasOpenBeforeHide = ref(false)
// Track why the popover was hidden so we only auto-reopen after drag.
type HideReason = 'manual' | 'drag'
const lastProgrammaticHideReason = ref<HideReason | null>(null)
const submenuRefs = ref<Record<string, InstanceType<typeof SubmenuPopover>>>({})
const currentSubmenu = ref<string | null>(null)
const { menuOptions, menuOptionsWithSubmenu, bump } = useMoreOptionsMenu()
const { toggleSubmenu, hideAllSubmenus } = useSubmenuPositioning()
const minimap = useMinimap()
const containerStyles = minimap.containerStyles
function getButtonEl(): HTMLElement | null {
const el = (buttonRef.value as any)?.$el || buttonRef.value
return el instanceof HTMLElement ? el : null
}
let lastLogTs = 0
const LOG_INTERVAL = 120 // ms
let overlayElCache: HTMLElement | null = null
function resolveOverlayEl(): HTMLElement | null {
// Prefer cached element (cleared on hide)
if (overlayElCache && overlayElCache.isConnected) return overlayElCache
// PrimeVue Popover root element (component instance $el)
const direct = (popover.value as any)?.$el
if (direct instanceof HTMLElement) {
overlayElCache = direct
return direct
}
// Fallback: try to locate a recent popover root near the button (same z-index class + absolute)
const btn = getButtonEl()
if (btn) {
const candidates = Array.from(
document.querySelectorAll('div.absolute.z-50')
) as HTMLElement[]
// Heuristic: pick the one closest (vertically) below the button
const rect = btn.getBoundingClientRect()
let best: { el: HTMLElement; dist: number } | null = null
for (const el of candidates) {
const r = el.getBoundingClientRect()
const dist = Math.abs(r.top - rect.bottom)
if (!best || dist < best.dist) best = { el, dist }
}
if (best && best.el) {
overlayElCache = best.el
return best.el
}
}
return null
}
const repositionPopover = () => {
if (!isOpen.value) return
const btn = getButtonEl()
const overlayEl = resolveOverlayEl()
if (!btn || !overlayEl) return
const rect = btn.getBoundingClientRect()
const marginY = 8 // tailwind mt-2 ~ 0.5rem = 8px
const left = rect.left + rect.width / 2
const top = rect.bottom + marginY
try {
overlayEl.style.position = 'fixed'
overlayEl.style.left = `${left}px`
overlayEl.style.top = `${top}px`
overlayEl.style.transform = 'translate(-50%, 0)'
} catch (e) {
console.warn('[MoreOptions] Failed to set overlay style', e)
return
}
const now = performance.now()
if (now - lastLogTs > LOG_INTERVAL) {
lastLogTs = now
}
}
const { startSync, stopSync } = useCanvasTransformSync(repositionPopover, {
autoStart: false
})
function openPopover(triggerEvent?: Event): boolean {
const el = getButtonEl()
if (!el || !el.isConnected) return false
bump()
popover.value?.show(triggerEvent ?? new Event('reopen'), el)
isOpen.value = true
moreOptionsOpen.value = true
moreOptionsRestorePending.value = false
return true
}
function closePopover(reason: HideReason = 'manual') {
lastProgrammaticHideReason.value = reason
popover.value?.hide()
isOpen.value = false
moreOptionsOpen.value = false
stopSync()
hideAll()
if (reason !== 'drag') {
wasOpenBeforeHide.value = false
// Natural hide: cancel any pending restore
moreOptionsRestorePending.value = false
} else {
if (!moreOptionsRestorePending.value) {
wasOpenBeforeHide.value = true
moreOptionsRestorePending.value = true
}
}
}
let restoreAttempts = 0
function attemptRestore() {
if (isOpen.value) return
if (!wasOpenBeforeHide.value && !moreOptionsRestorePending.value) return
// Try immediately
if (openPopover(new Event('reopen'))) {
wasOpenBeforeHide.value = false
restoreAttempts = 0
return
}
// Defer with limited retries (layout / mount race)
if (restoreAttempts >= 5) return
restoreAttempts++
requestAnimationFrame(() => attemptRestore())
}
const toggle = (event: Event) => {
if (isOpen.value) closePopover('manual')
else openPopover(event)
}
const hide = (reason: HideReason = 'manual') => closePopover(reason)
const hideAll = () => {
hideAllSubmenus(
menuOptionsWithSubmenu.value,
submenuRefs.value,
currentSubmenu
)
}
const handleOptionClick = (option: MenuOption, event: Event) => {
if (!option.hasSubmenu && option.action) {
option.action()
hide()
} else if (option.hasSubmenu) {
event.stopPropagation()
const submenuKey = `submenu-${option.label}`
const submenu = submenuRefs.value[submenuKey]
if (submenu) {
void toggleSubmenu(
option,
event,
submenu,
currentSubmenu,
menuOptionsWithSubmenu.value,
submenuRefs.value
)
}
}
}
const handleSubmenuClick = (subOption: SubMenuOption) => {
subOption.action()
hide('manual')
}
const setSubmenuRef = (key: string, el: any) => {
if (el) {
submenuRefs.value[key] = el
} else {
delete submenuRefs.value[key]
}
}
const pt = computed(() => ({
root: {
class: 'absolute z-50 w-[300px] px-[12]'
},
content: {
class: [
'mt-2 text-neutral dark-theme:text-white rounded-lg',
'shadow-lg border border-zinc-200 dark-theme:border-zinc-700'
],
style: {
backgroundColor: containerStyles.value.backgroundColor
}
}
}))
// Distinguish outside click (PrimeVue dismiss) from programmatic hides.
const onPopoverShow = () => {
overlayElCache = resolveOverlayEl()
// Delay first reposition slightly to ensure DOM fully painted
requestAnimationFrame(() => repositionPopover())
startSync()
}
const onPopoverHide = () => {
if (lastProgrammaticHideReason.value == null) {
isOpen.value = false
hideAll()
wasOpenBeforeHide.value = false
moreOptionsOpen.value = false
moreOptionsRestorePending.value = false
}
overlayElCache = null
stopSync()
lastProgrammaticHideReason.value = null
}
// Watch for forced close (drag start)
watch(
() => forceCloseMoreOptionsSignal.value,
() => {
if (isOpen.value) hide('drag')
else
wasOpenBeforeHide.value =
wasOpenBeforeHide.value || moreOptionsRestorePending.value
}
)
watch(
() => restoreMoreOptionsSignal.value,
() => attemptRestore()
)
onMounted(() => {
if (moreOptionsRestorePending.value && !isOpen.value) {
requestAnimationFrame(() => attemptRestore())
}
})
onUnmounted(() => {
stopSync()
})
</script>

View File

@@ -1,25 +0,0 @@
<template>
<Button
v-show="canvasStore.nodeSelected || canvasStore.groupSelected"
v-tooltip.top="{
value: t('commands.Comfy_Canvas_ToggleSelectedNodes_Pin.label'),
showDelay: 1000
}"
severity="secondary"
text
icon="pi pi-thumbtack"
@click="() => commandStore.execute('Comfy.Canvas.ToggleSelected.Pin')"
/>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { useI18n } from 'vue-i18n'
import { useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
const { t } = useI18n()
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
</script>

View File

@@ -1,17 +1,22 @@
<template>
<Button
v-show="isRefreshable"
severity="info"
v-tooltip.top="t('g.refreshNode')"
severity="secondary"
text
icon="pi pi-refresh"
data-testid="refresh-button"
@click="refreshSelected"
/>
>
<i-lucide:refresh-cw class="w-4 h-4" />
</Button>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { useI18n } from 'vue-i18n'
import { useRefreshableSelection } from '@/composables/useRefreshableSelection'
const { t } = useI18n()
const { isRefreshable, refreshSelected } = useRefreshableSelection()
</script>

View File

@@ -21,8 +21,8 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
const { t } = useI18n()
const commandStore = useCommandStore()

View File

@@ -0,0 +1,127 @@
<template>
<Popover
ref="popover"
:auto-z-index="true"
:base-z-index="1100"
:dismissable="true"
:close-on-escape="true"
unstyled
:pt="submenuPt"
>
<div
:class="
isColorSubmenu
? 'flex flex-col gap-1 p-2'
: 'flex flex-col p-2 min-w-40'
"
>
<div
v-for="subOption in option.submenu"
:key="subOption.label"
:class="
isColorSubmenu
? 'w-7 h-7 flex items-center justify-center hover:bg-gray-100 dark-theme:hover:bg-zinc-700 rounded cursor-pointer'
: 'flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-gray-100 dark-theme:hover:bg-zinc-700 rounded cursor-pointer'
"
:title="subOption.label"
@click="handleSubmenuClick(subOption)"
>
<div
v-if="subOption.color"
class="w-5 h-5 rounded-full border border-gray-300 dark-theme:border-zinc-600"
:style="{ backgroundColor: subOption.color }"
/>
<template v-else-if="!subOption.color">
<i-lucide:check
v-if="isShapeSelected(subOption)"
class="w-4 h-4 flex-shrink-0"
/>
<div v-else class="w-4 flex-shrink-0" />
<span>{{ subOption.label }}</span>
</template>
</div>
</div>
</Popover>
</template>
<script setup lang="ts">
import Popover from 'primevue/popover'
import { computed, ref } from 'vue'
import {
type MenuOption,
type SubMenuOption
} from '@/composables/graph/useMoreOptionsMenu'
import { useNodeCustomization } from '@/composables/graph/useNodeCustomization'
interface Props {
option: MenuOption
containerStyles: {
width: string
height: string
backgroundColor: string
border: string
borderRadius: string
}
}
interface Emits {
(e: 'submenu-click', subOption: SubMenuOption): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const { getCurrentShape } = useNodeCustomization()
const popover = ref<InstanceType<typeof Popover>>()
const show = (event: Event, target?: HTMLElement) => {
popover.value?.show(event, target)
}
const hide = () => {
popover.value?.hide()
}
defineExpose({
show,
hide
})
const handleSubmenuClick = (subOption: SubMenuOption) => {
emit('submenu-click', subOption)
}
const isShapeSelected = (subOption: SubMenuOption): boolean => {
if (subOption.color) return false
const currentShape = getCurrentShape()
if (!currentShape) return false
return currentShape.localizedName === subOption.label
}
const isColorSubmenu = computed(() => {
return (
props.option.submenu &&
props.option.submenu.length > 0 &&
props.option.submenu.every((item) => item.color && !item.icon)
)
})
const submenuPt = computed(() => ({
root: {
class: 'absolute z-[60]'
},
content: {
class: [
'text-neutral dark-theme:text-white rounded-lg',
'shadow-lg border border-zinc-200 dark-theme:border-zinc-700'
],
style: {
backgroundColor: props.containerStyles.backgroundColor
}
}
}))
</script>

View File

@@ -0,0 +1,3 @@
<template>
<div class="h-6 w-px bg-gray-300/10 dark-theme:bg-gray-600/10 self-center" />
</template>