mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-05 15:40:10 +00:00
Merge branch 'main' into sno-fix-playwright-babel-config
This commit is contained in:
120
src/components/graph/selectionToolbox/BypassButton.spec.ts
Normal file
120
src/components/graph/selectionToolbox/BypassButton.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
128
src/components/graph/selectionToolbox/ExecuteButton.spec.ts
Normal file
128
src/components/graph/selectionToolbox/ExecuteButton.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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)"
|
||||
/>
|
||||
|
||||
22
src/components/graph/selectionToolbox/FrameNodes.vue
Normal file
22
src/components/graph/selectionToolbox/FrameNodes.vue
Normal 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>
|
||||
@@ -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>
|
||||
149
src/components/graph/selectionToolbox/InfoButton.spec.ts
Normal file
149
src/components/graph/selectionToolbox/InfoButton.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
22
src/components/graph/selectionToolbox/InfoButton.vue
Normal file
22
src/components/graph/selectionToolbox/InfoButton.vue
Normal 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>
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
59
src/components/graph/selectionToolbox/MenuOptionItem.vue
Normal file
59
src/components/graph/selectionToolbox/MenuOptionItem.vue
Normal 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>
|
||||
316
src/components/graph/selectionToolbox/MoreOptions.vue
Normal file
316
src/components/graph/selectionToolbox/MoreOptions.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
127
src/components/graph/selectionToolbox/SubmenuPopover.vue
Normal file
127
src/components/graph/selectionToolbox/SubmenuPopover.vue
Normal 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>
|
||||
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<div class="h-6 w-px bg-gray-300/10 dark-theme:bg-gray-600/10 self-center" />
|
||||
</template>
|
||||
Reference in New Issue
Block a user