Merge remote-tracking branch 'origin/main' into sno-storybook--settings-panel

This commit is contained in:
snomiao
2025-08-23 18:31:42 +00:00
68 changed files with 1700 additions and 367 deletions

View File

@@ -3,14 +3,15 @@ name: 'Chromatic'
# - [Automate Chromatic with GitHub Actions • Chromatic docs]( https://www.chromatic.com/docs/github-actions/ )
on:
push:
branches: [main]
workflow_dispatch: # Allow manual triggering
pull_request:
branches: [main]
jobs:
chromatic-deployment:
runs-on: ubuntu-latest
# Only run for PRs from version-bump-* branches or manual triggers
if: github.event_name == 'workflow_dispatch' || startsWith(github.head_ref, 'version-bump-')
permissions:
pull-requests: write
issues: write
@@ -32,6 +33,7 @@ jobs:
- name: Comment PR - Build Started
if: github.event_name == 'pull_request'
continue-on-error: true
uses: edumserrano/find-create-or-update-comment@v3
with:
issue-number: ${{ github.event.pull_request.number }}
@@ -68,6 +70,7 @@ jobs:
- name: Comment PR - Build Complete
if: github.event_name == 'pull_request' && always()
continue-on-error: true
uses: edumserrano/find-create-or-update-comment@v3
with:
issue-number: ${{ github.event.pull_request.number }}

View File

@@ -47,6 +47,7 @@ jobs:
- name: Comment PR - Tests Started
if: github.event_name == 'pull_request'
continue-on-error: true
uses: edumserrano/find-create-or-update-comment@v3
with:
issue-number: ${{ github.event.pull_request.number }}
@@ -134,6 +135,7 @@ jobs:
- name: Comment PR - Browser Test Started
if: github.event_name == 'pull_request'
continue-on-error: true
uses: edumserrano/find-create-or-update-comment@v3
with:
issue-number: ${{ github.event.pull_request.number }}
@@ -238,6 +240,7 @@ jobs:
- name: Comment PR - Browser Test Complete
if: always() && github.event_name == 'pull_request'
continue-on-error: true
uses: edumserrano/find-create-or-update-comment@v3
with:
issue-number: ${{ github.event.pull_request.number }}
@@ -323,6 +326,7 @@ jobs:
fi
- name: Comment PR - Tests Complete
continue-on-error: true
uses: edumserrano/find-create-or-update-comment@v3
with:
issue-number: ${{ github.event.pull_request.number }}

1
.gitignore vendored
View File

@@ -23,7 +23,6 @@ dist-ssr
*.local
# Claude configuration
.claude/*.local.json
.claude/settings.json
# Editor directories and files
.vscode/*

View File

@@ -65,6 +65,7 @@ export class Topbar {
}
async openTopbarMenu() {
await this.page.waitForTimeout(1000)
await this.page.locator('.comfyui-logo-wrapper').click()
const menu = this.page.locator('.comfy-command-menu')
await menu.waitFor({ state: 'visible' })

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 88 KiB

View File

@@ -7,13 +7,11 @@ test.describe('Graph Canvas Menu', () => {
// Set link render mode to spline to make sure it's not affected by other tests'
// side effects.
await comfyPage.setSetting('Comfy.LinkRenderMode', 2)
// Enable canvas menu for all tests
await comfyPage.setSetting('Comfy.Graph.CanvasMenu', true)
})
test('Can toggle link visibility', async ({ comfyPage }) => {
// Note: `Comfy.Graph.CanvasMenu` is disabled in comfyPage setup.
// so no cleanup is needed.
await comfyPage.setSetting('Comfy.Graph.CanvasMenu', true)
const button = comfyPage.page.getByTestId('toggle-link-visibility-button')
await button.click()
await comfyPage.nextFrame()
@@ -36,4 +34,45 @@ test.describe('Graph Canvas Menu', () => {
hiddenLinkRenderMode
)
})
test('Focus mode button is clickable and has correct test id', async ({
comfyPage
}) => {
const focusButton = comfyPage.page.getByTestId('focus-mode-button')
await expect(focusButton).toBeVisible()
await expect(focusButton).toBeEnabled()
// Test that the button can be clicked without error
await focusButton.click()
await comfyPage.nextFrame()
})
test('Zoom controls popup opens and closes', async ({ comfyPage }) => {
// Find the zoom button by its percentage text content
const zoomButton = comfyPage.page.locator('button').filter({
hasText: '%'
})
await expect(zoomButton).toBeVisible()
// Click to open zoom controls
await zoomButton.click()
await comfyPage.nextFrame()
// Zoom controls modal should be visible
const zoomModal = comfyPage.page
.locator('div')
.filter({
hasText: 'Zoom To Fit'
})
.first()
await expect(zoomModal).toBeVisible()
// Click backdrop to close
const backdrop = comfyPage.page.locator('.fixed.inset-0').first()
await backdrop.click()
await comfyPage.nextFrame()
// Modal should be hidden
await expect(zoomModal).not.toBeVisible()
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 100 KiB

View File

@@ -780,9 +780,18 @@ test.describe('Viewport settings', () => {
// Screenshot the canvas element
await comfyPage.setSetting('Comfy.Graph.CanvasMenu', true)
const toggleButton = comfyPage.page.getByTestId('toggle-minimap-button')
// Open zoom controls dropdown first
const zoomControlsButton = comfyPage.page.getByTestId(
'zoom-controls-button'
)
await zoomControlsButton.click()
const toggleButton = comfyPage.page.getByTestId('toggle-minimap-button')
await toggleButton.click()
// close zoom menu
await zoomControlsButton.click()
await comfyPage.setSetting('Comfy.Graph.CanvasMenu', false)
await comfyPage.menu.topbar.saveWorkflow('Workflow A')
await comfyPage.nextFrame()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 101 KiB

View File

@@ -35,34 +35,44 @@ test.describe('Minimap', () => {
})
test('Validate minimap toggle button state', async ({ comfyPage }) => {
// Open zoom controls dropdown first
const zoomControlsButton = comfyPage.page.getByTestId(
'zoom-controls-button'
)
await zoomControlsButton.click()
const toggleButton = comfyPage.page.getByTestId('toggle-minimap-button')
await expect(toggleButton).toBeVisible()
await expect(toggleButton).toHaveClass(/minimap-active/)
const minimapContainer = comfyPage.page.locator('.litegraph-minimap')
await expect(minimapContainer).toBeVisible()
})
test('Validate minimap can be toggled off and on', async ({ comfyPage }) => {
const minimapContainer = comfyPage.page.locator('.litegraph-minimap')
// Open zoom controls dropdown first
const zoomControlsButton = comfyPage.page.getByTestId(
'zoom-controls-button'
)
await zoomControlsButton.click()
const toggleButton = comfyPage.page.getByTestId('toggle-minimap-button')
await expect(minimapContainer).toBeVisible()
await expect(toggleButton).toHaveClass(/minimap-active/)
await toggleButton.click()
await comfyPage.nextFrame()
await expect(minimapContainer).not.toBeVisible()
await expect(toggleButton).not.toHaveClass(/minimap-active/)
await expect(toggleButton).toContainText('Show Minimap')
await toggleButton.click()
await comfyPage.nextFrame()
await expect(minimapContainer).toBeVisible()
await expect(toggleButton).toHaveClass(/minimap-active/)
await expect(toggleButton).toContainText('Hide Minimap')
})
test('Validate minimap keyboard shortcut Alt+M', async ({ comfyPage }) => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 102 KiB

View File

@@ -193,6 +193,7 @@ test.describe('Workflows sidebar', () => {
await comfyPage.menu.topbar.saveWorkflowAs('workflow5.json')
await comfyPage.confirmDialog.click('overwrite')
await comfyPage.page.waitForTimeout(200)
expect(await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()).toEqual([
'workflow5.json'
])

View File

@@ -256,6 +256,7 @@ test.describe('Animated image widget', () => {
await comfyPage.dragAndDropFile('animated_webp.webp', {
dropPosition: { x, y }
})
await comfyPage.page.waitForTimeout(200)
// Expect the filename combo value to be updated
const fileComboWidget = await loadAnimatedWebpNode.getWidget(0)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

After

Width:  |  Height:  |  Size: 114 KiB

View File

@@ -2,13 +2,11 @@
<!-- Load splitter overlay only after comfyApp is ready. -->
<!-- If load immediately, the top-level splitter stateKey won't be correctly
synced with the stateStorage (localStorage). -->
<LiteGraphCanvasSplitterOverlay
v-if="comfyAppReady && betaMenuEnabled && !workspaceStore.focusMode"
>
<template #side-bar-panel>
<LiteGraphCanvasSplitterOverlay v-if="comfyAppReady && betaMenuEnabled">
<template v-if="!workspaceStore.focusMode" #side-bar-panel>
<SideToolbar />
</template>
<template #bottom-panel>
<template v-if="!workspaceStore.focusMode" #bottom-panel>
<BottomPanel />
</template>
<template #graph-canvas-panel>
@@ -34,22 +32,20 @@
/>
<NodeTooltip v-if="tooltipEnabled" />
<NodeSearchboxPopover />
<NodeSearchboxPopover ref="nodeSearchboxPopoverRef" />
<!-- Initialize components after comfyApp is ready. useAbsolutePosition requires
canvasStore.canvas to be initialized. -->
<template v-if="comfyAppReady">
<TitleEditor />
<SelectionOverlay v-if="selectionToolboxEnabled">
<SelectionToolbox />
</SelectionOverlay>
<SelectionToolbox v-if="selectionToolboxEnabled" />
<DomWidgets />
</template>
</template>
<script setup lang="ts">
import { useEventListener, whenever } from '@vueuse/core'
import { computed, onMounted, ref, watch, watchEffect } from 'vue'
import { computed, onMounted, ref, shallowRef, watch, watchEffect } from 'vue'
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
import BottomPanel from '@/components/bottomPanel/BottomPanel.vue'
@@ -57,7 +53,6 @@ import DomWidgets from '@/components/graph/DomWidgets.vue'
import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue'
import MiniMap from '@/components/graph/MiniMap.vue'
import NodeTooltip from '@/components/graph/NodeTooltip.vue'
import SelectionOverlay from '@/components/graph/SelectionOverlay.vue'
import SelectionToolbox from '@/components/graph/SelectionToolbox.vue'
import TitleEditor from '@/components/graph/TitleEditor.vue'
import NodeSearchboxPopover from '@/components/searchbox/NodeSearchBoxPopover.vue'
@@ -91,12 +86,16 @@ import { useSettingStore } from '@/stores/settingStore'
import { useToastStore } from '@/stores/toastStore'
import { useWorkflowStore } from '@/stores/workflowStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
const emit = defineEmits<{
ready: []
}>()
const canvasRef = ref<HTMLCanvasElement | null>(null)
const nodeSearchboxPopoverRef = shallowRef<InstanceType<
typeof NodeSearchboxPopover
> | null>(null)
const settingStore = useSettingStore()
const nodeDefStore = useNodeDefStore()
const workspaceStore = useWorkspaceStore()
@@ -320,6 +319,7 @@ onMounted(async () => {
canvasStore.canvas = comfyApp.canvas
canvasStore.canvas.render_canvas_border = false
workspaceStore.spinner = false
useSearchBoxStore().setPopoverRef(nodeSearchboxPopoverRef.value)
window.app = comfyApp
window.graph = comfyApp.graph

View File

@@ -1,125 +1,278 @@
<template>
<ButtonGroup
class="p-buttongroup-vertical absolute bottom-[10px] right-[10px] z-[1000]"
@wheel="canvasInteractions.handleWheel"
>
<Button
v-tooltip.left="t('graphCanvasMenu.zoomIn')"
severity="secondary"
icon="pi pi-plus"
:aria-label="$t('graphCanvasMenu.zoomIn')"
@mousedown="repeat('Comfy.Canvas.ZoomIn')"
@mouseup="stopRepeat"
/>
<Button
v-tooltip.left="t('graphCanvasMenu.zoomOut')"
severity="secondary"
icon="pi pi-minus"
:aria-label="$t('graphCanvasMenu.zoomOut')"
@mousedown="repeat('Comfy.Canvas.ZoomOut')"
@mouseup="stopRepeat"
/>
<Button
v-tooltip.left="t('graphCanvasMenu.fitView')"
severity="secondary"
icon="pi pi-expand"
:aria-label="$t('graphCanvasMenu.fitView')"
@click="() => commandStore.execute('Comfy.Canvas.FitView')"
/>
<Button
v-tooltip.left="
t(
'graphCanvasMenu.' +
(canvasStore.canvas?.read_only ? 'panMode' : 'selectMode')
) + ' (Space)'
"
severity="secondary"
:aria-label="
t(
'graphCanvasMenu.' +
(canvasStore.canvas?.read_only ? 'panMode' : 'selectMode')
)
"
@click="() => commandStore.execute('Comfy.Canvas.ToggleLock')"
<div>
<ZoomControlsModal :visible="isModalVisible" />
<!-- Backdrop -->
<div
v-if="hasActivePopup"
class="fixed inset-0 z-[1200]"
@click="hideModal"
></div>
<ButtonGroup
class="p-buttongroup-vertical p-1 absolute bottom-4 right-2 md:right-4"
:style="stringifiedMinimapStyles.buttonGroupStyles"
@wheel="canvasInteractions.handleWheel"
>
<template #icon>
<i-material-symbols:pan-tool-outline
v-if="canvasStore.canvas?.read_only"
/>
<i-simple-line-icons:cursor v-else />
</template>
</Button>
<Button
v-tooltip.left="t('graphCanvasMenu.toggleLinkVisibility')"
severity="secondary"
:icon="linkHidden ? 'pi pi-eye-slash' : 'pi pi-eye'"
:aria-label="$t('graphCanvasMenu.toggleLinkVisibility')"
data-testid="toggle-link-visibility-button"
@click="() => commandStore.execute('Comfy.Canvas.ToggleLinkVisibility')"
/>
<Button
v-tooltip.left="minimapTooltip"
severity="secondary"
:icon="'pi pi-map'"
:aria-label="$t('graphCanvasMenu.toggleMinimap')"
:class="{ 'minimap-active': minimapVisible }"
data-testid="toggle-minimap-button"
@click="() => commandStore.execute('Comfy.Canvas.ToggleMinimap')"
/>
</ButtonGroup>
<Button
v-tooltip.top="selectTooltip"
:style="stringifiedMinimapStyles.buttonStyles"
severity="secondary"
:aria-label="selectTooltip"
:pressed="isCanvasReadOnly"
icon="i-material-symbols:pan-tool-outline"
:class="selectButtonClass"
@click="() => commandStore.execute('Comfy.Canvas.Unlock')"
>
<template #icon>
<i-lucide:mouse-pointer-2 />
</template>
</Button>
<Button
v-tooltip.top="handTooltip"
severity="secondary"
:aria-label="handTooltip"
:pressed="isCanvasUnlocked"
:class="handButtonClass"
:style="stringifiedMinimapStyles.buttonStyles"
@click="() => commandStore.execute('Comfy.Canvas.Lock')"
>
<template #icon>
<i-lucide:hand />
</template>
</Button>
<!-- vertical line with bg E1DED5 -->
<div class="w-px my-1 bg-[#E1DED5] dark-theme:bg-[#2E3037] mx-2" />
<Button
v-tooltip.top="fitViewTooltip"
severity="secondary"
icon="pi pi-expand"
:aria-label="fitViewTooltip"
:style="stringifiedMinimapStyles.buttonStyles"
class="hover:dark-theme:!bg-[#444444] hover:!bg-[#E7E6E6]"
@click="() => commandStore.execute('Comfy.Canvas.FitView')"
>
<template #icon>
<i-lucide:focus />
</template>
</Button>
<Button
ref="zoomButton"
v-tooltip.top="t('zoomControls.label')"
severity="secondary"
:label="t('zoomControls.label')"
:class="zoomButtonClass"
:aria-label="t('zoomControls.label')"
data-testid="zoom-controls-button"
:style="stringifiedMinimapStyles.buttonStyles"
@click="toggleModal"
>
<span class="inline-flex text-xs">
<span>{{ canvasStore.appScalePercentage }}%</span>
<i-lucide:chevron-down />
</span>
</Button>
<div class="w-px my-1 bg-[#E1DED5] dark-theme:bg-[#2E3037] mx-2" />
<Button
ref="focusButton"
v-tooltip.top="focusModeTooltip"
severity="secondary"
:aria-label="focusModeTooltip"
data-testid="focus-mode-button"
:style="stringifiedMinimapStyles.buttonStyles"
:class="focusButtonClass"
@click="() => commandStore.execute('Workspace.ToggleFocusMode')"
>
<template #icon>
<i-lucide:lightbulb />
</template>
</Button>
<Button
v-tooltip.top="{
value: linkVisibilityTooltip,
pt: {
root: {
style: 'z-index: 2; transform: translateY(-20px);'
}
}
}"
severity="secondary"
:class="linkVisibleClass"
:aria-label="linkVisibilityAriaLabel"
data-testid="toggle-link-visibility-button"
:style="stringifiedMinimapStyles.buttonStyles"
@click="() => commandStore.execute('Comfy.Canvas.ToggleLinkVisibility')"
>
<template #icon>
<i-lucide:route-off />
</template>
</Button>
</ButtonGroup>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import ButtonGroup from 'primevue/buttongroup'
import { computed } from 'vue'
import { computed, onBeforeUnmount, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCanvasInteractions } from '@/composables/graph/useCanvasInteractions'
import { useZoomControls } from '@/composables/useZoomControls'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { useMinimap } from '@/renderer/extensions/minimap/composables/useMinimap'
import { useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
import { useKeybindingStore } from '@/stores/keybindingStore'
import { useSettingStore } from '@/stores/settingStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import ZoomControlsModal from './modals/ZoomControlsModal.vue'
const { t } = useI18n()
const commandStore = useCommandStore()
const { formatKeySequence } = useCommandStore()
const canvasStore = useCanvasStore()
const keybindingStore = useKeybindingStore()
const settingStore = useSettingStore()
const canvasInteractions = useCanvasInteractions()
const workspaceStore = useWorkspaceStore()
const minimap = useMinimap()
const minimapVisible = computed(() => settingStore.get('Comfy.Minimap.Visible'))
const minimapTooltip = computed(() => {
const baseText = t('graphCanvasMenu.toggleMinimap')
const keybinding = keybindingStore.getKeybindingByCommandId(
'Comfy.Canvas.ToggleMinimap'
)
return keybinding ? `${baseText} (${keybinding.combo.toString()})` : baseText
const { isModalVisible, toggleModal, hideModal, hasActivePopup } =
useZoomControls()
const stringifiedMinimapStyles = computed(() => {
const buttonGroupKeys = ['backgroundColor', 'borderRadius', '']
const buttonKeys = ['backgroundColor', 'borderRadius']
const additionalButtonStyles = {
border: 'none',
width: '35px',
height: '35px',
'margin-right': '2px',
'margin-left': '2px'
}
const containerStyles = minimap.containerStyles.value
const buttonStyles = {
...Object.fromEntries(
Object.entries(containerStyles).filter(([key]) =>
buttonKeys.includes(key)
)
),
...additionalButtonStyles
}
const buttonGroupStyles = Object.entries(containerStyles)
.filter(([key]) => buttonGroupKeys.includes(key))
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {})
return { buttonStyles, buttonGroupStyles }
})
// Computed properties for reactive states
const isCanvasReadOnly = computed(() => canvasStore.canvas?.read_only ?? false)
const isCanvasUnlocked = computed(() => !isCanvasReadOnly.value)
const linkHidden = computed(
() => settingStore.get('Comfy.LinkRenderMode') === LiteGraph.HIDDEN_LINK
)
let interval: number | null = null
const repeat = async (command: string) => {
if (interval) return
const cmd = () => commandStore.execute(command)
await cmd()
interval = window.setInterval(cmd, 100)
}
const stopRepeat = () => {
if (interval) {
clearInterval(interval)
interval = null
}
}
// Computed properties for command text
const unlockCommandText = computed(() =>
formatKeySequence(
commandStore.getCommand('Comfy.Canvas.Unlock')
).toUpperCase()
)
const lockCommandText = computed(() =>
formatKeySequence(commandStore.getCommand('Comfy.Canvas.Lock')).toUpperCase()
)
const fitViewCommandText = computed(() =>
formatKeySequence(
commandStore.getCommand('Comfy.Canvas.FitView')
).toUpperCase()
)
const focusCommandText = computed(() =>
formatKeySequence(
commandStore.getCommand('Workspace.ToggleFocusMode')
).toUpperCase()
)
// Computed properties for button classes and states
const selectButtonClass = computed(() =>
isCanvasUnlocked.value
? 'dark-theme:[&:not(:active)]:!bg-[#262729] [&:not(:active)]:!bg-[#E7E6E6]'
: ''
)
const handButtonClass = computed(() =>
isCanvasReadOnly.value
? 'dark-theme:[&:not(:active)]:!bg-[#262729] [&:not(:active)]:!bg-[#E7E6E6]'
: ''
)
const zoomButtonClass = computed(() => [
'!w-16',
isModalVisible.value
? 'dark-theme:[&:not(:active)]:!bg-[#262729] [&:not(:active)]:!bg-[#E7E6E6]'
: '',
'hover:dark-theme:!bg-[#262729] hover:!bg-[#E7E6E6]'
])
const focusButtonClass = computed(() => ({
'hover:dark-theme:!bg-[#262729] hover:!bg-[#E7E6E6]': true,
'dark-theme:[&:not(:active)]:!bg-[#262729] [&:not(:active)]:!bg-[#E7E6E6]':
workspaceStore.focusMode
}))
// Computed properties for tooltip and aria-label texts
const selectTooltip = computed(
() => `${t('graphCanvasMenu.select')} (${unlockCommandText.value})`
)
const handTooltip = computed(
() => `${t('graphCanvasMenu.hand')} (${lockCommandText.value})`
)
const fitViewTooltip = computed(
() => `${t('graphCanvasMenu.fitView')} (${fitViewCommandText.value})`
)
const focusModeTooltip = computed(
() => `${t('graphCanvasMenu.focusMode')} (${focusCommandText.value})`
)
const linkVisibilityTooltip = computed(() =>
linkHidden.value
? t('graphCanvasMenu.showLinks')
: t('graphCanvasMenu.hideLinks')
)
const linkVisibilityAriaLabel = computed(() =>
linkHidden.value
? t('graphCanvasMenu.showLinks')
: t('graphCanvasMenu.hideLinks')
)
const linkVisibleClass = computed(() => [
linkHidden.value
? 'dark-theme:[&:not(:active)]:!bg-[#262729] [&:not(:active)]:!bg-[#E7E6E6]'
: '',
'hover:dark-theme:!bg-[#262729] hover:!bg-[#E7E6E6]'
])
onMounted(() => {
canvasStore.initScaleSync()
})
onBeforeUnmount(() => {
canvasStore.cleanupScaleSync()
})
</script>
<style scoped>
.p-buttongroup-vertical {
display: flex;
flex-direction: column;
flex-direction: row;
z-index: 1200;
border-radius: var(--p-button-border-radius);
overflow: hidden;
border: 1px solid var(--p-panel-border-color);
@@ -129,15 +282,4 @@ const stopRepeat = () => {
margin: 0;
border-radius: 0;
}
.p-button.minimap-active {
background-color: var(--p-button-primary-background);
border-color: var(--p-button-primary-border-color);
color: var(--p-button-primary-color);
}
.p-button.minimap-active:hover {
background-color: var(--p-button-primary-hover-background);
border-color: var(--p-button-primary-hover-border-color);
}
</style>

View File

@@ -2,7 +2,7 @@
<div
v-if="visible && initialized"
ref="minimapRef"
class="minimap-main-container flex absolute bottom-[20px] right-[90px] z-[1000]"
class="minimap-main-container flex absolute bottom-[66px] right-2 md:right-11 z-[1000]"
>
<MiniMapPanel
v-if="showOptionsPanel"
@@ -31,6 +31,25 @@
<i-lucide:settings-2 />
</template>
</Button>
<Button
class="absolute z-10 right-0"
size="small"
text
severity="secondary"
data-testid="close-minmap-button"
@click.stop="() => commandStore.execute('Comfy.Canvas.ToggleMinimap')"
>
<template #icon>
<i-lucide:x />
</template>
</Button>
<hr
class="absolute top-5 bg-[#E1DED5] dark-theme:bg-[#262729] h-[1px] border-0"
:style="{
width: containerStyles.width
}"
/>
<canvas
ref="canvasRef"
@@ -58,9 +77,12 @@ import Button from 'primevue/button'
import { onMounted, onUnmounted, ref } from 'vue'
import { useMinimap } from '@/renderer/extensions/minimap/composables/useMinimap'
import { useCommandStore } from '@/stores/commandStore'
import MiniMapPanel from './MiniMapPanel.vue'
const commandStore = useCommandStore()
const minimapRef = ref<HTMLDivElement>()
const {

View File

@@ -1,106 +0,0 @@
<!-- This component is used to bound the selected items on the canvas. -->
<template>
<div
v-show="visible"
class="selection-overlay-container pointer-events-none z-40"
:class="{
'show-border': showBorder
}"
:style="style"
>
<slot />
</div>
</template>
<script setup lang="ts">
import { whenever } from '@vueuse/core'
import { provide, readonly, ref, watch } from 'vue'
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
import { useAbsolutePosition } from '@/composables/element/useAbsolutePosition'
import { createBounds } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/stores/graphStore'
import { SelectionOverlayInjectionKey } from '@/types/selectionOverlayTypes'
const canvasStore = useCanvasStore()
const { style, updatePosition } = useAbsolutePosition()
const { getSelectableItems } = useSelectedLiteGraphItems()
const visible = ref(false)
const showBorder = ref(false)
// Increment counter to notify child components of position/visibility change
// This does not include viewport changes.
const overlayUpdateCount = ref(0)
provide(SelectionOverlayInjectionKey, {
visible: readonly(visible),
updateCount: readonly(overlayUpdateCount)
})
const positionSelectionOverlay = () => {
const selectableItems = getSelectableItems()
showBorder.value = selectableItems.size > 1
if (!selectableItems.size) {
visible.value = false
return
}
visible.value = true
const bounds = createBounds(selectableItems)
if (bounds) {
updatePosition({
pos: [bounds[0], bounds[1]],
size: [bounds[2], bounds[3]]
})
}
}
whenever(
() => canvasStore.getCanvas().state.selectionChanged,
() => {
requestAnimationFrame(() => {
positionSelectionOverlay()
overlayUpdateCount.value++
canvasStore.getCanvas().state.selectionChanged = false
})
},
{ immediate: true }
)
canvasStore.getCanvas().ds.onChanged = positionSelectionOverlay
watch(
() => canvasStore.canvas?.state?.draggingItems,
(draggingItems) => {
// Litegraph draggingItems state can end early before the bounding boxes of
// the selected items are updated. Delay to make sure we put the overlay in
// the correct position.
// https://github.com/Comfy-Org/ComfyUI_frontend/issues/2656
if (draggingItems === false) {
requestAnimationFrame(() => {
visible.value = true
positionSelectionOverlay()
overlayUpdateCount.value++
})
} else {
// Selection change update to visible state is delayed by a frame. Here
// we also delay a frame so that the order of events is correct when
// the initial selection and dragging happens at the same time.
requestAnimationFrame(() => {
visible.value = false
overlayUpdateCount.value++
})
}
}
)
</script>
<style scoped>
.selection-overlay-container > * {
pointer-events: auto;
}
.show-border {
@apply border-dashed rounded-md border-2 border-[var(--border-color)];
}
</style>

View File

@@ -1,34 +1,45 @@
<template>
<Panel
class="selection-toolbox absolute left-1/2 rounded-lg"
:class="{ 'animate-slide-up': shouldAnimate }"
:pt="{
header: 'hidden',
content: 'p-0 flex flex-row'
}"
@wheel="canvasInteractions.handleWheel"
>
<ExecuteButton />
<ColorPickerButton />
<BypassButton />
<PinButton />
<Load3DViewerButton />
<MaskEditorButton />
<ConvertToSubgraphButton />
<DeleteButton />
<RefreshSelectionButton />
<ExtensionCommandButton
v-for="command in extensionToolboxCommands"
:key="command.id"
:command="command"
/>
<HelpButton />
</Panel>
<Transition name="slide-up">
<!-- Wrapping panel in div to get correct ref because panel ref is not of raw dom el -->
<div
v-show="visible"
ref="toolboxRef"
style="
transform: translate(calc(var(--tb-x) - 50%), calc(var(--tb-y) - 120%));
"
class="selection-toolbox fixed left-0 top-0 z-40"
>
<Panel
class="rounded-lg"
:pt="{
header: 'hidden',
content: 'p-0 flex flex-row'
}"
@wheel="canvasInteractions.handleWheel"
>
<ExecuteButton />
<ColorPickerButton />
<BypassButton />
<PinButton />
<Load3DViewerButton />
<MaskEditorButton />
<ConvertToSubgraphButton />
<DeleteButton />
<RefreshSelectionButton />
<ExtensionCommandButton
v-for="command in extensionToolboxCommands"
:key="command.id"
:command="command"
/>
<HelpButton />
</Panel>
</div>
</Transition>
</template>
<script setup lang="ts">
import Panel from 'primevue/panel'
import { computed, inject } from 'vue'
import { computed, ref } from 'vue'
import BypassButton from '@/components/graph/selectionToolbox/BypassButton.vue'
import ColorPickerButton from '@/components/graph/selectionToolbox/ColorPickerButton.vue'
@@ -41,23 +52,19 @@ import Load3DViewerButton from '@/components/graph/selectionToolbox/Load3DViewer
import MaskEditorButton from '@/components/graph/selectionToolbox/MaskEditorButton.vue'
import PinButton from '@/components/graph/selectionToolbox/PinButton.vue'
import RefreshSelectionButton from '@/components/graph/selectionToolbox/RefreshSelectionButton.vue'
import { useRetriggerableAnimation } from '@/composables/element/useRetriggerableAnimation'
import { useSelectionToolboxPosition } from '@/composables/canvas/useSelectionToolboxPosition'
import { useCanvasInteractions } from '@/composables/graph/useCanvasInteractions'
import { useExtensionService } from '@/services/extensionService'
import { type ComfyCommandImpl, useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
import { SelectionOverlayInjectionKey } from '@/types/selectionOverlayTypes'
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
const extensionService = useExtensionService()
const canvasInteractions = useCanvasInteractions()
const selectionOverlayState = inject(SelectionOverlayInjectionKey)
const { shouldAnimate } = useRetriggerableAnimation(
selectionOverlayState?.updateCount,
{ animateOnMount: true }
)
const toolboxRef = ref<HTMLElement | undefined>()
const { visible } = useSelectionToolboxPosition(toolboxRef)
const extensionToolboxCommands = computed<ComfyCommandImpl[]>(() => {
const commandIds = new Set<string>(
@@ -77,23 +84,22 @@ const extensionToolboxCommands = computed<ComfyCommandImpl[]>(() => {
</script>
<style scoped>
.selection-toolbox {
transform: translateX(-50%) translateY(-120%);
.slide-up-enter-active {
opacity: 1;
transition: all 0.3s ease-out;
}
/* Slide up animation using CSS animation */
@keyframes slideUp {
from {
transform: translateX(-50%) translateY(-100%);
opacity: 0;
}
to {
transform: translateX(-50%) translateY(-120%);
opacity: 1;
}
.slide-up-leave-active {
transition: none;
}
.animate-slide-up {
animation: slideUp 0.3s ease-out;
.slide-up-enter-from {
transform: translateY(-100%);
opacity: 0;
}
.slide-up-leave-to {
transform: translateY(0);
opacity: 0;
}
</style>

View File

@@ -0,0 +1,237 @@
<template>
<div
v-if="visible"
class="w-[250px] absolute flex justify-center right-2 md:right-11 z-[1300] bottom-[66px] !bg-inherit !border-0"
>
<div
class="bg-white dark-theme:bg-[#2b2b2b] border border-gray-200 dark-theme:border-gray-700 rounded-lg shadow-lg p-4 w-4/5"
:style="filteredMinimapStyles"
@click.stop
>
<div>
<Button
severity="secondary"
text
:pt="{
root: {
class:
'flex items-center justify-between cursor-pointer p-2 rounded w-full text-left hover:!bg-transparent focus:!bg-transparent active:!bg-transparent'
},
label: {
class: 'flex flex-col items-start w-full'
}
}"
@mousedown="startRepeat('Comfy.Canvas.ZoomIn')"
@mouseup="stopRepeat"
@mouseleave="stopRepeat"
>
<template #default>
<span class="text-sm font-medium block">{{
$t('graphCanvasMenu.zoomIn')
}}</span>
<span class="text-sm text-gray-500 block">{{
zoomInCommandText
}}</span>
</template>
</Button>
<Button
severity="secondary"
text
:pt="{
root: {
class:
'flex items-center justify-between cursor-pointer p-2 rounded w-full text-left hover:!bg-transparent focus:!bg-transparent active:!bg-transparent'
},
label: {
class: 'flex flex-col items-start w-full'
}
}"
@mousedown="startRepeat('Comfy.Canvas.ZoomOut')"
@mouseup="stopRepeat"
@mouseleave="stopRepeat"
>
<template #default>
<span class="text-sm font-medium block">{{
$t('graphCanvasMenu.zoomOut')
}}</span>
<span class="text-sm text-gray-500 block">{{
zoomOutCommandText
}}</span>
</template>
</Button>
<Button
severity="secondary"
text
:pt="{
root: {
class:
'flex items-center justify-between cursor-pointer p-2 rounded w-full text-left hover:!bg-transparent focus:!bg-transparent active:!bg-transparent'
},
label: {
class: 'flex flex-col items-start w-full'
}
}"
@click="executeCommand('Comfy.Canvas.FitView')"
>
<template #default>
<span class="text-sm font-medium block">{{
$t('zoomControls.zoomToFit')
}}</span>
<span class="text-sm text-gray-500 block">{{
zoomToFitCommandText
}}</span>
</template>
</Button>
<hr class="border-[#E1DED5] mb-1 dark-theme:border-[#2E3037]" />
<Button
severity="secondary"
text
data-testid="toggle-minimap-button"
:pt="{
root: {
class:
'flex items-center justify-between cursor-pointer p-2 rounded w-full text-left hover:!bg-transparent focus:!bg-transparent active:!bg-transparent'
},
label: {
class: 'flex flex-col items-start w-full'
}
}"
@click="executeCommand('Comfy.Canvas.ToggleMinimap')"
>
<template #default>
<span class="text-sm font-medium block">{{
minimapToggleText
}}</span>
<span class="text-sm text-gray-500 block">{{
showMinimapCommandText
}}</span>
</template>
</Button>
<hr class="border-[#E1DED5] mt-1 dark-theme:border-[#2E3037]" />
<div
ref="zoomInputContainer"
class="flex items-center px-2 bg-[#E7E6E6] focus-within:bg-[#F3F3F3] dark-theme:bg-[#8282821A] rounded p-2 zoomInputContainer"
>
<InputNumber
ref="zoomInput"
:default-value="canvasStore.appScalePercentage"
:min="1"
:max="1000"
:show-buttons="false"
:use-grouping="false"
:unstyled="true"
input-class="flex-1 bg-transparent border-none outline-none text-sm shadow-none my-0 "
fluid
@input="applyZoom"
@keyup.enter="applyZoom"
/>
<span class="text-sm text-gray-500 -ml-4">%</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { Button, InputNumber, InputNumberInputEvent } from 'primevue'
import { computed, nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useMinimap } from '@/renderer/extensions/minimap/composables/useMinimap'
import { useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
import { useSettingStore } from '@/stores/settingStore'
const { t } = useI18n()
const minimap = useMinimap()
const settingStore = useSettingStore()
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
const { formatKeySequence } = useCommandStore()
interface Props {
visible: boolean
}
const props = defineProps<Props>()
const interval = ref<number | null>(null)
// Computed properties for reactive states
const minimapToggleText = computed(() =>
settingStore.get('Comfy.Minimap.Visible')
? t('zoomControls.hideMinimap')
: t('zoomControls.showMinimap')
)
const applyZoom = (val: InputNumberInputEvent) => {
const inputValue = val.value as number
if (isNaN(inputValue) || inputValue < 1 || inputValue > 1000) {
return
}
canvasStore.setAppZoomFromPercentage(inputValue)
}
const executeCommand = (command: string) => {
void commandStore.execute(command)
}
const startRepeat = (command: string) => {
if (interval.value) return
const cmd = () => commandStore.execute(command)
void cmd()
interval.value = window.setInterval(cmd, 100)
}
const stopRepeat = () => {
if (interval.value) {
clearInterval(interval.value)
interval.value = null
}
}
const filteredMinimapStyles = computed(() => {
return {
...minimap.containerStyles.value,
height: undefined,
width: undefined
}
})
const zoomInCommandText = computed(() =>
formatKeySequence(commandStore.getCommand('Comfy.Canvas.ZoomIn'))
)
const zoomOutCommandText = computed(() =>
formatKeySequence(commandStore.getCommand('Comfy.Canvas.ZoomOut'))
)
const zoomToFitCommandText = computed(() =>
formatKeySequence(commandStore.getCommand('Comfy.Canvas.FitView'))
)
const showMinimapCommandText = computed(() =>
formatKeySequence(commandStore.getCommand('Comfy.Canvas.ToggleMinimap'))
)
const zoomInput = ref<InstanceType<typeof InputNumber> | null>(null)
const zoomInputContainer = ref<HTMLDivElement | null>(null)
watch(
() => props.visible,
async (newVal) => {
if (newVal) {
await nextTick()
const input = zoomInputContainer.value?.querySelector(
'input'
) as HTMLInputElement
input?.focus()
}
}
)
</script>
<style>
.zoomInputContainer:focus-within {
border: 1px solid rgb(204, 204, 204);
}
.dark-theme .zoomInputContainer:focus-within {
border: 1px solid rgb(204, 204, 204);
}
</style>

View File

@@ -61,9 +61,10 @@ let listenerController: AbortController | null = null
let disconnectOnReset = false
const settingStore = useSettingStore()
const searchBoxStore = useSearchBoxStore()
const litegraphService = useLitegraphService()
const { visible } = storeToRefs(useSearchBoxStore())
const { visible, newSearchBoxEnabled } = storeToRefs(searchBoxStore)
const dismissable = ref(true)
const getNewNodeLocation = (): Point => {
return triggerEvent
@@ -107,12 +108,9 @@ const addNode = (nodeDef: ComfyNodeDefImpl) => {
window.requestAnimationFrame(closeDialog)
}
const newSearchBoxEnabled = computed(
() => settingStore.get('Comfy.NodeSearchBoxImpl') === 'default'
)
const showSearchBox = (e: CanvasPointerEvent) => {
const showSearchBox = (e: CanvasPointerEvent | null) => {
if (newSearchBoxEnabled.value) {
if (e.pointerType === 'touch') {
if (e?.pointerType === 'touch') {
setTimeout(() => {
showNewSearchBox(e)
}, 128)
@@ -128,7 +126,7 @@ const getFirstLink = () =>
canvasStore.getCanvas().linkConnector.renderLinks.at(0)
const nodeDefStore = useNodeDefStore()
const showNewSearchBox = (e: CanvasPointerEvent) => {
const showNewSearchBox = (e: CanvasPointerEvent | null) => {
const firstLink = getFirstLink()
if (firstLink) {
const filter =
@@ -304,6 +302,7 @@ watch(visible, () => {
})
useEventListener(document, 'litegraph:canvas', canvasEventHandler)
defineExpose({ showSearchBox })
</script>
<style>

View File

@@ -109,7 +109,7 @@ const computedTooltip = computed(() => t(tooltip) + tooltipSuffix)
}
.side-bar-button-label {
@apply text-[10px] text-center whitespace-nowrap;
@apply text-[10px] text-center;
line-height: 1;
}

View File

@@ -1,11 +1,6 @@
<template>
<SidebarIcon
:tooltip="
$t('shortcuts.keyboardShortcuts') +
' (' +
formatKeySequence(command.keybinding!.combo.getKeySequences()) +
')'
"
:tooltip="tooltipText"
:selected="isShortcutsPanelVisible"
@click="toggleShortcutsPanel"
>
@@ -17,28 +12,28 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCommandStore } from '@/stores/commandStore'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
import SidebarIcon from './SidebarIcon.vue'
const { t } = useI18n()
const bottomPanelStore = useBottomPanelStore()
const command = useCommandStore().getCommand(
'Workspace.ToggleBottomPanel.Shortcuts'
)
const commandStore = useCommandStore()
const command = commandStore.getCommand('Workspace.ToggleBottomPanel.Shortcuts')
const { formatKeySequence } = commandStore
const isShortcutsPanelVisible = computed(
() => bottomPanelStore.activePanel === 'shortcuts'
)
const tooltipText = computed(
() => `${t('shortcuts.keyboardShortcuts')} (${formatKeySequence(command)})`
)
const toggleShortcutsPanel = () => {
bottomPanelStore.togglePanel('shortcuts')
}
const formatKeySequence = (sequences: string[]): string => {
return sequences
.map((seq) => seq.replace(/Control/g, 'Ctrl').replace(/Shift/g, 'Shift'))
.join(' + ')
}
</script>

View File

@@ -0,0 +1,113 @@
import { ref, watch } from 'vue'
import type { Ref } from 'vue'
import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformSync'
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
import { createBounds } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/stores/graphStore'
/**
* Manages the position of the selection toolbox independently.
* Uses CSS custom properties for performant transform updates.
*/
export function useSelectionToolboxPosition(
toolboxRef: Ref<HTMLElement | undefined>
) {
const canvasStore = useCanvasStore()
const lgCanvas = canvasStore.getCanvas()
const { getSelectableItems } = useSelectedLiteGraphItems()
// World position of selection center
const worldPosition = ref({ x: 0, y: 0 })
const visible = ref(false)
/**
* Update position based on selection
*/
const updateSelectionBounds = () => {
const selectableItems = getSelectableItems()
if (!selectableItems.size) {
visible.value = false
return
}
visible.value = true
const bounds = createBounds(selectableItems)
if (!bounds) {
return
}
const [xBase, y, width] = bounds
worldPosition.value = {
x: xBase + width / 2,
y: y
}
updateTransform()
}
const updateTransform = () => {
if (!visible.value) return
const { scale, offset } = lgCanvas.ds
const canvasRect = lgCanvas.canvas.getBoundingClientRect()
const screenX =
(worldPosition.value.x + offset[0]) * scale + canvasRect.left
const screenY = (worldPosition.value.y + offset[1]) * scale + canvasRect.top
// Update CSS custom properties directly for best performance
if (toolboxRef.value) {
toolboxRef.value.style.setProperty('--tb-x', `${screenX}px`)
toolboxRef.value.style.setProperty('--tb-y', `${screenY}px`)
}
}
// Sync with canvas transform
const { startSync, stopSync } = useCanvasTransformSync(updateTransform, {
autoStart: false
})
// Watch for selection changes
watch(
() => canvasStore.getCanvas().state.selectionChanged,
(changed) => {
if (changed) {
updateSelectionBounds()
canvasStore.getCanvas().state.selectionChanged = false
// Start transform sync if we have selection
if (visible.value) {
startSync()
} else {
stopSync()
}
}
},
{ immediate: true }
)
// Watch for dragging state
watch(
() => canvasStore.canvas?.state?.draggingItems,
(dragging) => {
if (dragging) {
// Hide during node dragging
visible.value = false
} else {
// Update after dragging ends
requestAnimationFrame(() => {
updateSelectionBounds()
})
}
}
)
return {
visible
}
}

View File

@@ -1383,6 +1383,38 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
},
ViduStartEndToVideoNode: {
displayPrice: '$0.4/Run'
},
ByteDanceImageNode: {
displayPrice: (node: LGraphNode): string => {
const modelWidget = node.widgets?.find(
(w) => w.name === 'model'
) as IComboWidget
if (!modelWidget) return 'Token-based'
const model = String(modelWidget.value)
if (model.includes('seedream-3-0-t2i')) {
return '$0.03/Run'
}
return 'Token-based'
}
},
ByteDanceImageEditNode: {
displayPrice: (node: LGraphNode): string => {
const modelWidget = node.widgets?.find(
(w) => w.name === 'model'
) as IComboWidget
if (!modelWidget) return 'Token-based'
const model = String(modelWidget.value)
if (model.includes('seededit-3-0-i2i')) {
return '$0.03/Run'
}
return 'Token-based'
}
}
}
@@ -1470,7 +1502,10 @@ export const useNodePricing = () => {
// Google/Gemini nodes
GeminiNode: ['model'],
// OpenAI nodes
OpenAIChatNode: ['model']
OpenAIChatNode: ['model'],
// ByteDance
ByteDanceImageNode: ['model'],
ByteDanceImageEditNode: ['model']
}
return widgetMap[nodeType] || []
}

View File

@@ -298,8 +298,26 @@ export function useCoreCommands(): ComfyCommand[] {
id: 'Comfy.Canvas.ToggleLock',
icon: 'pi pi-lock',
label: 'Canvas Toggle Lock',
category: 'view-controls' as const,
function: () => {
app.canvas['read_only'] = !app.canvas['read_only']
app.canvas.state.readOnly = !app.canvas.state.readOnly
}
},
{
id: 'Comfy.Canvas.Lock',
icon: 'pi pi-lock',
label: 'Lock Canvas',
category: 'view-controls' as const,
function: () => {
app.canvas.state.readOnly = true
}
},
{
id: 'Comfy.Canvas.Unlock',
icon: 'pi pi-lock-open',
label: 'Unlock Canvas',
function: () => {
app.canvas.state.readOnly = false
}
},
{

View File

@@ -0,0 +1,27 @@
import { computed, ref } from 'vue'
export function useZoomControls() {
const isModalVisible = ref(false)
const showModal = () => {
isModalVisible.value = true
}
const hideModal = () => {
isModalVisible.value = false
}
const toggleModal = () => {
isModalVisible.value = !isModalVisible.value
}
const hasActivePopup = computed(() => isModalVisible.value)
return {
isModalVisible,
showModal,
hideModal,
toggleModal,
hasActivePopup
}
}

View File

@@ -191,6 +191,18 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
},
commandId: 'Workspace.ToggleBottomPanel.Shortcuts'
},
{
combo: {
key: 'v'
},
commandId: 'Comfy.Canvas.Unlock'
},
{
combo: {
key: 'h'
},
commandId: 'Comfy.Canvas.Lock'
},
{
combo: {
key: 'Escape'

View File

@@ -14,6 +14,7 @@ import './previewAny'
import './rerouteNode'
import './saveImageExtraOutput'
import './saveMesh'
import './selectionBorder'
import './simpleTouchSupport'
import './slotDefaults'
import './uploadAudio'

View File

@@ -0,0 +1,70 @@
import { type LGraphCanvas, createBounds } from '@/lib/litegraph/src/litegraph'
import { app } from '@/scripts/app'
/**
* Draws a dashed border around selected items that maintains constant pixel size
* regardless of zoom level, similar to the DOM selection overlay.
*/
function drawSelectionBorder(
ctx: CanvasRenderingContext2D,
canvas: LGraphCanvas
) {
const selectedItems = canvas.selectedItems
// Only draw if multiple items selected
if (selectedItems.size <= 1) return
// Use the same bounds calculation as the toolbox
const bounds = createBounds(selectedItems, 10)
if (!bounds) return
const [x, y, width, height] = bounds
// Save context state
ctx.save()
// Set up dashed line style that doesn't scale with zoom
const borderWidth = 2 / canvas.ds.scale // Constant 2px regardless of zoom
ctx.lineWidth = borderWidth
ctx.strokeStyle =
getComputedStyle(document.documentElement)
.getPropertyValue('--border-color')
.trim() || '#ffffff66'
// Create dash pattern that maintains visual size
const dashSize = 5 / canvas.ds.scale
ctx.setLineDash([dashSize, dashSize])
// Draw the border using the bounds directly
ctx.beginPath()
ctx.roundRect(x, y, width, height, 8 / canvas.ds.scale)
ctx.stroke()
// Restore context
ctx.restore()
}
/**
* Extension that adds a dashed selection border for multiple selected nodes
*/
const ext = {
name: 'Comfy.SelectionBorder',
async init() {
// Hook into the canvas drawing
const originalDrawForeground = app.canvas.onDrawForeground
app.canvas.onDrawForeground = function (
ctx: CanvasRenderingContext2D,
visibleArea: any
) {
// Call original if it exists
originalDrawForeground?.call(this, ctx, visibleArea)
// Draw our selection border
drawSelectionBorder(ctx, app.canvas)
}
}
}
app.registerExtension(ext)

View File

@@ -135,7 +135,7 @@ interface ICreateDefaultNodeOptions extends ICreateNodeOptions {
interface HasShowSearchCallback {
/** See {@link LGraphCanvas.showSearchBox} */
showSearchBox: (
event: MouseEvent,
event: MouseEvent | null,
options?: IShowSearchOptions
) => HTMLDivElement | void
}
@@ -6870,7 +6870,7 @@ export class LGraphCanvas
}
showSearchBox(
event: MouseEvent,
event: MouseEvent | null,
searchOptions?: IShowSearchOptions
): HTMLDivElement {
// proposed defaults
@@ -7105,14 +7105,25 @@ export class LGraphCanvas
// compute best position
const rect = canvas.getBoundingClientRect()
const left = (event ? event.clientX : rect.left + rect.width * 0.5) - 80
const top = (event ? event.clientY : rect.top + rect.height * 0.5) - 20
// Handles cases where the searchbox is initiated by
// non-click events. e.g. Keyboard shortcuts
const safeEvent =
event ??
new MouseEvent('click', {
clientX: rect.left + rect.width * 0.5,
clientY: rect.top + rect.height * 0.5,
// @ts-expect-error layerY is a nonstandard property
layerY: rect.top + rect.height * 0.5
})
const left = safeEvent.clientX - 80
const top = safeEvent.clientY - 20
dialog.style.left = `${left}px`
dialog.style.top = `${top}px`
// To avoid out of screen problems
if (event.layerY > rect.height - 200) {
helper.style.maxHeight = `${rect.height - event.layerY - 20}px`
if (safeEvent.layerY > rect.height - 200) {
helper.style.maxHeight = `${rect.height - safeEvent.layerY - 20}px`
}
requestAnimationFrame(function () {
input.focus()
@@ -7122,14 +7133,14 @@ export class LGraphCanvas
function select(name: string) {
if (name) {
if (that.onSearchBoxSelection) {
that.onSearchBoxSelection(name, event, graphcanvas)
that.onSearchBoxSelection(name, safeEvent, graphcanvas)
} else {
if (!graphcanvas.graph) throw new NullGraphError()
graphcanvas.graph.beforeChange()
const node = LiteGraph.createNode(name)
if (node) {
node.pos = graphcanvas.convertEventToCanvasOffset(event)
node.pos = graphcanvas.convertEventToCanvasOffset(safeEvent)
graphcanvas.graph.add(node, false)
}

View File

@@ -47,6 +47,9 @@
"Comfy_Canvas_FitView": {
"label": "تعديل العرض ليناسب العقد المحددة"
},
"Comfy_Canvas_Lock": {
"label": "قفل اللوحة"
},
"Comfy_Canvas_MoveSelectedNodes_Down": {
"label": "تحريك العقد المحددة للأسفل"
},
@@ -89,6 +92,9 @@
"Comfy_Canvas_ToggleSelected_Pin": {
"label": "تثبيت/إلغاء تثبيت العناصر المحددة"
},
"Comfy_Canvas_Unlock": {
"label": "فتح اللوحة"
},
"Comfy_Canvas_ZoomIn": {
"label": "تكبير"
},

View File

@@ -410,12 +410,17 @@
},
"graphCanvasMenu": {
"fitView": "ملائمة العرض",
"focusMode": "وضع التركيز",
"hand": "يد",
"hideLinks": "إخفاء الروابط",
"panMode": "وضع التحريك",
"resetView": "إعادة تعيين العرض",
"select": "تحديد",
"selectMode": "وضع التحديد",
"toggleLinkVisibility": "تبديل ظهور الروابط",
"showLinks": "إظهار الروابط",
"toggleMinimap": "تبديل الخريطة المصغرة",
"zoomIn": "تكبير",
"zoomOptions": "خيارات التكبير",
"zoomOut": "تصغير"
},
"groupNode": {
@@ -801,6 +806,7 @@
"Increase Brush Size in MaskEditor": "زيادة حجم الفرشاة في محرر القناع",
"Interrupt": "إيقاف مؤقت",
"Load Default Workflow": "تحميل سير العمل الافتراضي",
"Lock Canvas": "قفل اللوحة",
"Manage group nodes": "إدارة عقد المجموعة",
"Manager": "المدير",
"Minimap": "خريطة مصغرة",
@@ -855,6 +861,7 @@
"Toggle the Custom Nodes Manager Progress Bar": "تبديل شريط تقدم مدير العقد المخصصة",
"Undo": "تراجع",
"Ungroup selected group nodes": "فك تجميع عقد المجموعة المحددة",
"Unlock Canvas": "فتح قفل اللوحة",
"Unpack the selected Subgraph": "فك تجميع الرسم البياني الفرعي المحدد",
"Workflows": "سير العمل",
"Zoom In": "تكبير",
@@ -1694,5 +1701,11 @@
"enterFilename": "أدخل اسم الملف",
"exportWorkflow": "تصدير سير العمل",
"saveWorkflow": "حفظ سير العمل"
},
"zoomControls": {
"hideMinimap": "إخفاء الخريطة المصغرة",
"label": "عناصر التحكم في التكبير",
"showMinimap": "إظهار الخريطة المصغرة",
"zoomToFit": "تكبير لتناسب الشاشة"
}
}

View File

@@ -47,6 +47,9 @@
"Comfy_Canvas_FitView": {
"label": "Fit view to selected nodes"
},
"Comfy_Canvas_Lock": {
"label": "Lock Canvas"
},
"Comfy_Canvas_MoveSelectedNodes_Down": {
"label": "Move Selected Nodes Down"
},
@@ -89,6 +92,9 @@
"Comfy_Canvas_ToggleSelectedNodes_Pin": {
"label": "Pin/Unpin Selected Nodes"
},
"Comfy_Canvas_Unlock": {
"label": "Unlock Canvas"
},
"Comfy_Canvas_ZoomIn": {
"label": "Zoom In"
},

View File

@@ -894,8 +894,19 @@
"fitView": "Fit View",
"selectMode": "Select Mode",
"panMode": "Pan Mode",
"toggleLinkVisibility": "Toggle Link Visibility",
"toggleMinimap": "Toggle Minimap"
"toggleMinimap": "Toggle Minimap",
"select": "Select",
"hand": "Hand",
"zoomOptions": "Zoom Options",
"focusMode": "Focus Mode",
"hideLinks": "Hide Links",
"showLinks": "Show Links"
},
"zoomControls": {
"label": "Zoom Controls",
"zoomToFit": "Zoom To Fit",
"showMinimap": "Show Minimap",
"hideMinimap": "Hide Minimap"
},
"groupNode": {
"create": "Create group node",
@@ -963,6 +974,7 @@
"Browse Templates": "Browse Templates",
"Delete Selected Items": "Delete Selected Items",
"Zoom to fit": "Zoom to fit",
"Lock Canvas": "Lock Canvas",
"Move Selected Nodes Down": "Move Selected Nodes Down",
"Move Selected Nodes Left": "Move Selected Nodes Left",
"Move Selected Nodes Right": "Move Selected Nodes Right",
@@ -977,6 +989,7 @@
"Collapse/Expand Selected Nodes": "Collapse/Expand Selected Nodes",
"Mute/Unmute Selected Nodes": "Mute/Unmute Selected Nodes",
"Pin/Unpin Selected Nodes": "Pin/Unpin Selected Nodes",
"Unlock Canvas": "Unlock Canvas",
"Zoom In": "Zoom In",
"Zoom Out": "Zoom Out",
"Clear Pending Tasks": "Clear Pending Tasks",

View File

@@ -47,6 +47,9 @@
"Comfy_Canvas_FitView": {
"label": "Ajustar vista a los nodos seleccionados"
},
"Comfy_Canvas_Lock": {
"label": "Bloquear lienzo"
},
"Comfy_Canvas_MoveSelectedNodes_Down": {
"label": "Mover nodos seleccionados hacia abajo"
},
@@ -89,6 +92,9 @@
"Comfy_Canvas_ToggleSelected_Pin": {
"label": "Anclar/Desanclar elementos seleccionados"
},
"Comfy_Canvas_Unlock": {
"label": "Desbloquear lienzo"
},
"Comfy_Canvas_ZoomIn": {
"label": "Acercar"
},

View File

@@ -410,12 +410,17 @@
},
"graphCanvasMenu": {
"fitView": "Ajustar vista",
"focusMode": "Modo de enfoque",
"hand": "Mano",
"hideLinks": "Ocultar enlaces",
"panMode": "Modo de desplazamiento",
"resetView": "Restablecer vista",
"select": "Seleccionar",
"selectMode": "Modo de selección",
"toggleLinkVisibility": "Alternar visibilidad de enlace",
"showLinks": "Mostrar enlaces",
"toggleMinimap": "Alternar minimapa",
"zoomIn": "Acercar",
"zoomOptions": "Opciones de zoom",
"zoomOut": "Alejar"
},
"groupNode": {
@@ -801,6 +806,7 @@
"Increase Brush Size in MaskEditor": "Aumentar tamaño del pincel en MaskEditor",
"Interrupt": "Interrumpir",
"Load Default Workflow": "Cargar flujo de trabajo predeterminado",
"Lock Canvas": "Bloquear lienzo",
"Manage group nodes": "Gestionar nodos de grupo",
"Manager": "Administrador",
"Minimap": "Minimapa",
@@ -855,6 +861,7 @@
"Toggle the Custom Nodes Manager Progress Bar": "Alternar la Barra de Progreso del Administrador de Nodos Personalizados",
"Undo": "Deshacer",
"Ungroup selected group nodes": "Desagrupar nodos de grupo seleccionados",
"Unlock Canvas": "Desbloquear lienzo",
"Unpack the selected Subgraph": "Desempaquetar el Subgrafo seleccionado",
"Workflows": "Flujos de trabajo",
"Zoom In": "Acercar",
@@ -1694,5 +1701,11 @@
"enterFilename": "Introduzca el nombre del archivo",
"exportWorkflow": "Exportar flujo de trabajo",
"saveWorkflow": "Guardar flujo de trabajo"
},
"zoomControls": {
"hideMinimap": "Ocultar minimapa",
"label": "Controles de zoom",
"showMinimap": "Mostrar minimapa",
"zoomToFit": "Ajustar al zoom"
}
}

View File

@@ -47,6 +47,9 @@
"Comfy_Canvas_FitView": {
"label": "Ajuster la vue aux nœuds sélectionnés"
},
"Comfy_Canvas_Lock": {
"label": "Verrouiller la toile"
},
"Comfy_Canvas_MoveSelectedNodes_Down": {
"label": "Déplacer les nœuds sélectionnés vers le bas"
},
@@ -89,6 +92,9 @@
"Comfy_Canvas_ToggleSelected_Pin": {
"label": "Épingler/Désépingler les éléments sélectionnés"
},
"Comfy_Canvas_Unlock": {
"label": "Déverrouiller le Canvas"
},
"Comfy_Canvas_ZoomIn": {
"label": "Zoom avant"
},

View File

@@ -410,12 +410,17 @@
},
"graphCanvasMenu": {
"fitView": "Adapter la vue",
"focusMode": "Mode focus",
"hand": "Main",
"hideLinks": "Masquer les liens",
"panMode": "Mode panoramique",
"resetView": "Réinitialiser la vue",
"select": "Sélectionner",
"selectMode": "Mode sélection",
"toggleLinkVisibility": "Basculer la visibilité des liens",
"showLinks": "Afficher les liens",
"toggleMinimap": "Afficher/Masquer la mini-carte",
"zoomIn": "Zoom avant",
"zoomOptions": "Options de zoom",
"zoomOut": "Zoom arrière"
},
"groupNode": {
@@ -801,6 +806,7 @@
"Increase Brush Size in MaskEditor": "Augmenter la taille du pinceau dans MaskEditor",
"Interrupt": "Interrompre",
"Load Default Workflow": "Charger le flux de travail par défaut",
"Lock Canvas": "Verrouiller le canevas",
"Manage group nodes": "Gérer les nœuds de groupe",
"Manager": "Gestionnaire",
"Minimap": "Minicarte",
@@ -855,6 +861,7 @@
"Toggle the Custom Nodes Manager Progress Bar": "Basculer la barre de progression du gestionnaire de nœuds personnalisés",
"Undo": "Annuler",
"Ungroup selected group nodes": "Dégrouper les nœuds de groupe sélectionnés",
"Unlock Canvas": "Déverrouiller le canevas",
"Unpack the selected Subgraph": "Décompresser le Subgraph sélectionné",
"Workflows": "Flux de travail",
"Zoom In": "Zoom avant",
@@ -1694,5 +1701,11 @@
"enterFilename": "Entrez le nom du fichier",
"exportWorkflow": "Exporter le flux de travail",
"saveWorkflow": "Enregistrer le flux de travail"
},
"zoomControls": {
"hideMinimap": "Masquer la mini-carte",
"label": "Contrôles de zoom",
"showMinimap": "Afficher la mini-carte",
"zoomToFit": "Ajuster à lécran"
}
}

View File

@@ -47,6 +47,9 @@
"Comfy_Canvas_FitView": {
"label": "選択したノードにビューを合わせる"
},
"Comfy_Canvas_Lock": {
"label": "キャンバスをロック"
},
"Comfy_Canvas_MoveSelectedNodes_Down": {
"label": "選択したノードを下に移動"
},
@@ -89,6 +92,9 @@
"Comfy_Canvas_ToggleSelected_Pin": {
"label": "選択したアイテムのピン留め/ピン留め解除"
},
"Comfy_Canvas_Unlock": {
"label": "キャンバスをロック解除"
},
"Comfy_Canvas_ZoomIn": {
"label": "ズームイン"
},

View File

@@ -410,12 +410,17 @@
},
"graphCanvasMenu": {
"fitView": "ビューに合わせる",
"focusMode": "フォーカスモード",
"hand": "手のひら",
"hideLinks": "リンクを非表示",
"panMode": "パンモード",
"resetView": "ビューをリセット",
"select": "選択",
"selectMode": "選択モード",
"toggleLinkVisibility": "リンク表示切り替え",
"showLinks": "リンク表示",
"toggleMinimap": "ミニマップの切り替え",
"zoomIn": "拡大",
"zoomOptions": "ズームオプション",
"zoomOut": "縮小"
},
"groupNode": {
@@ -801,6 +806,7 @@
"Increase Brush Size in MaskEditor": "マスクエディタでブラシサイズを大きくする",
"Interrupt": "中断",
"Load Default Workflow": "デフォルトワークフローを読み込む",
"Lock Canvas": "キャンバスをロック",
"Manage group nodes": "グループノードを管理",
"Manager": "マネージャー",
"Minimap": "ミニマップ",
@@ -855,6 +861,7 @@
"Toggle the Custom Nodes Manager Progress Bar": "カスタムノードマネージャーの進行状況バーを切り替え",
"Undo": "元に戻す",
"Ungroup selected group nodes": "選択したグループノードのグループ解除",
"Unlock Canvas": "キャンバスのロックを解除",
"Unpack the selected Subgraph": "選択したサブグラフを展開",
"Workflows": "ワークフロー",
"Zoom In": "ズームイン",
@@ -1694,5 +1701,11 @@
"enterFilename": "ファイル名を入力",
"exportWorkflow": "ワークフローをエクスポート",
"saveWorkflow": "ワークフローを保存"
},
"zoomControls": {
"hideMinimap": "ミニマップを非表示",
"label": "ズームコントロール",
"showMinimap": "ミニマップを表示",
"zoomToFit": "全体表示にズーム"
}
}

View File

@@ -47,6 +47,9 @@
"Comfy_Canvas_FitView": {
"label": "선택한 노드에 뷰 맞추기"
},
"Comfy_Canvas_Lock": {
"label": "캔버스 잠금"
},
"Comfy_Canvas_MoveSelectedNodes_Down": {
"label": "선택한 노드 아래로 이동"
},
@@ -89,6 +92,9 @@
"Comfy_Canvas_ToggleSelected_Pin": {
"label": "선택한 항목 고정/고정 해제"
},
"Comfy_Canvas_Unlock": {
"label": "캔버스 잠금 해제"
},
"Comfy_Canvas_ZoomIn": {
"label": "확대"
},

View File

@@ -410,12 +410,17 @@
},
"graphCanvasMenu": {
"fitView": "보기 맞춤",
"focusMode": "포커스 모드",
"hand": "손 도구",
"hideLinks": "링크 숨기기",
"panMode": "팬 모드",
"resetView": "보기 재설정",
"select": "선택",
"selectMode": "선택 모드",
"toggleLinkVisibility": "링크 가시성 전환",
"showLinks": "링크 표시",
"toggleMinimap": "미니맵 전환",
"zoomIn": "확대",
"zoomOptions": "확대/축소 옵션",
"zoomOut": "축소"
},
"groupNode": {
@@ -801,6 +806,7 @@
"Increase Brush Size in MaskEditor": "마스크 편집기에서 브러시 크기 늘리기",
"Interrupt": "중단",
"Load Default Workflow": "기본 워크플로 불러오기",
"Lock Canvas": "캔버스 잠금",
"Manage group nodes": "그룹 노드 관리",
"Manager": "매니저",
"Minimap": "미니맵",
@@ -855,6 +861,7 @@
"Toggle the Custom Nodes Manager Progress Bar": "커스텀 노드 매니저 진행률 표시줄 전환",
"Undo": "실행 취소",
"Ungroup selected group nodes": "선택한 그룹 노드 그룹 해제",
"Unlock Canvas": "캔버스 잠금 해제",
"Unpack the selected Subgraph": "선택한 서브그래프 풀기",
"Workflows": "워크플로우",
"Zoom In": "확대",
@@ -1694,5 +1701,11 @@
"enterFilename": "파일 이름 입력",
"exportWorkflow": "워크플로 내보내기",
"saveWorkflow": "워크플로 저장"
},
"zoomControls": {
"hideMinimap": "미니맵 숨기기",
"label": "확대/축소 컨트롤",
"showMinimap": "미니맵 표시",
"zoomToFit": "화면에 맞게 확대"
}
}

View File

@@ -47,6 +47,9 @@
"Comfy_Canvas_FitView": {
"label": "Подогнать вид к выбранным нодам"
},
"Comfy_Canvas_Lock": {
"label": "Заблокировать холст"
},
"Comfy_Canvas_MoveSelectedNodes_Down": {
"label": "Переместить выбранные узлы вниз"
},
@@ -89,6 +92,9 @@
"Comfy_Canvas_ToggleSelected_Pin": {
"label": "Закрепить/Открепить выбранных нод"
},
"Comfy_Canvas_Unlock": {
"label": "Разблокировать Canvas"
},
"Comfy_Canvas_ZoomIn": {
"label": "Увеличить"
},

View File

@@ -410,12 +410,17 @@
},
"graphCanvasMenu": {
"fitView": "Подгонять под выделенные",
"focusMode": "Режим фокуса",
"hand": "Рука",
"hideLinks": "Скрыть связи",
"panMode": "Режим панорамирования",
"resetView": "Сбросить вид",
"select": "Выбрать",
"selectMode": "Выбрать режим",
"toggleLinkVisibility": "Переключить видимость ссылок",
"showLinks": "Показать связи",
"toggleMinimap": "Показать/скрыть миникарту",
"zoomIn": "Увеличить",
"zoomOptions": "Параметры масштабирования",
"zoomOut": "Уменьшить"
},
"groupNode": {
@@ -801,6 +806,7 @@
"Increase Brush Size in MaskEditor": "Увеличить размер кисти в MaskEditor",
"Interrupt": "Прервать",
"Load Default Workflow": "Загрузить стандартный рабочий процесс",
"Lock Canvas": "Заблокировать холст",
"Manage group nodes": "Управление групповыми нодами",
"Manager": "Менеджер",
"Minimap": "Мини-карта",
@@ -855,6 +861,7 @@
"Toggle the Custom Nodes Manager Progress Bar": "Переключить индикатор выполнения менеджера пользовательских узлов",
"Undo": "Отменить",
"Ungroup selected group nodes": "Разгруппировать выбранные групповые ноды",
"Unlock Canvas": "Разблокировать холст",
"Unpack the selected Subgraph": "Распаковать выбранный подграф",
"Workflows": "Рабочие процессы",
"Zoom In": "Увеличить",
@@ -1694,5 +1701,11 @@
"enterFilename": "Введите название файла",
"exportWorkflow": "Экспорт рабочего процесса",
"saveWorkflow": "Сохранить рабочий процесс"
},
"zoomControls": {
"hideMinimap": "Скрыть миникарту",
"label": "Элементы управления масштабом",
"showMinimap": "Показать миникарту",
"zoomToFit": "Масштабировать по размеру"
}
}

View File

@@ -47,6 +47,9 @@
"Comfy_Canvas_FitView": {
"label": "將視圖適應至所選節點"
},
"Comfy_Canvas_Lock": {
"label": "鎖定畫布"
},
"Comfy_Canvas_MoveSelectedNodes_Down": {
"label": "將選取的節點下移"
},
@@ -89,6 +92,9 @@
"Comfy_Canvas_ToggleSelected_Pin": {
"label": "釘選/取消釘選已選項目"
},
"Comfy_Canvas_Unlock": {
"label": "解鎖畫布"
},
"Comfy_Canvas_ZoomIn": {
"label": "放大"
},

View File

@@ -410,12 +410,17 @@
},
"graphCanvasMenu": {
"fitView": "適合視窗",
"focusMode": "專注模式",
"hand": "拖曳",
"hideLinks": "隱藏連結",
"panMode": "平移模式",
"resetView": "重設視圖",
"select": "選取",
"selectMode": "選取模式",
"toggleLinkVisibility": "切換連結顯示",
"showLinks": "顯示連結",
"toggleMinimap": "切換小地圖",
"zoomIn": "放大",
"zoomOptions": "縮放選項",
"zoomOut": "縮小"
},
"groupNode": {
@@ -801,6 +806,7 @@
"Increase Brush Size in MaskEditor": "在 MaskEditor 中增大筆刷大小",
"Interrupt": "中斷",
"Load Default Workflow": "載入預設工作流程",
"Lock Canvas": "鎖定畫布",
"Manage group nodes": "管理群組節點",
"Manager": "管理員",
"Minimap": "縮圖地圖",
@@ -855,6 +861,7 @@
"Toggle the Custom Nodes Manager Progress Bar": "切換自訂節點管理器進度條",
"Undo": "復原",
"Ungroup selected group nodes": "取消群組選取的群組節點",
"Unlock Canvas": "解除鎖定畫布",
"Unpack the selected Subgraph": "解包所選子圖",
"Workflows": "工作流程",
"Zoom In": "放大",
@@ -1694,5 +1701,11 @@
"enterFilename": "輸入檔案名稱",
"exportWorkflow": "匯出工作流程",
"saveWorkflow": "儲存工作流程"
},
"zoomControls": {
"hideMinimap": "隱藏小地圖",
"label": "縮放控制",
"showMinimap": "顯示小地圖",
"zoomToFit": "縮放至適合大小"
}
}

View File

@@ -47,6 +47,9 @@
"Comfy_Canvas_FitView": {
"label": "适应视图到选中节点"
},
"Comfy_Canvas_Lock": {
"label": "鎖定畫布"
},
"Comfy_Canvas_MoveSelectedNodes_Down": {
"label": "下移选中的节点"
},
@@ -89,6 +92,9 @@
"Comfy_Canvas_ToggleSelected_Pin": {
"label": "固定/取消固定选中项"
},
"Comfy_Canvas_Unlock": {
"label": "解鎖畫布"
},
"Comfy_Canvas_ZoomIn": {
"label": "放大"
},

View File

@@ -410,12 +410,17 @@
},
"graphCanvasMenu": {
"fitView": "适应视图",
"focusMode": "專注模式",
"hand": "拖曳",
"hideLinks": "隱藏連結",
"panMode": "平移模式",
"resetView": "重置视图",
"select": "選取",
"selectMode": "选择模式",
"toggleLinkVisibility": "切换连线可见性",
"showLinks": "顯示連結",
"toggleMinimap": "切换小地图",
"zoomIn": "放大",
"zoomOptions": "縮放選項",
"zoomOut": "缩小"
},
"groupNode": {
@@ -801,6 +806,7 @@
"Increase Brush Size in MaskEditor": "在 MaskEditor 中增大笔刷大小",
"Interrupt": "中断",
"Load Default Workflow": "加载默认工作流",
"Lock Canvas": "鎖定畫布",
"Manage group nodes": "管理组节点",
"Manager": "管理器",
"Minimap": "小地图",
@@ -855,6 +861,7 @@
"Toggle the Custom Nodes Manager Progress Bar": "切换自定义节点管理器进度条",
"Undo": "撤销",
"Ungroup selected group nodes": "解散选中组节点",
"Unlock Canvas": "解除鎖定畫布",
"Unpack the selected Subgraph": "解包选中子图",
"Workflows": "工作流",
"Zoom In": "放大画面",
@@ -1694,5 +1701,11 @@
"enterFilename": "输入文件名",
"exportWorkflow": "导出工作流",
"saveWorkflow": "保存工作流"
},
"zoomControls": {
"hideMinimap": "隱藏小地圖",
"label": "縮放控制",
"showMinimap": "顯示小地圖",
"zoomToFit": "適合畫面"
}
}

View File

@@ -1303,8 +1303,7 @@ export class ComfyApp {
const executionStore = useExecutionStore()
executionStore.lastNodeErrors = null
let comfyOrgAuthToken =
(await useFirebaseAuthStore().getIdToken()) ?? undefined
let comfyOrgAuthToken = await useFirebaseAuthStore().getIdToken()
let comfyOrgApiKey = useApiKeyAuthStore().getApiKey()
try {

View File

@@ -120,6 +120,13 @@ export const useCommandStore = defineStore('command', () => {
}
}
const formatKeySequence = (command: ComfyCommandImpl): string => {
const sequences = command.keybinding?.combo.getKeySequences() || []
return sequences
.map((seq) => seq.replace(/Control/g, 'Ctrl').replace(/Shift/g, 'Shift'))
.join(' + ')
}
return {
commands,
execute,
@@ -127,6 +134,7 @@ export const useCommandStore = defineStore('command', () => {
registerCommand,
registerCommands,
isRegistered,
loadExtensionCommands
loadExtensionCommands,
formatKeySequence
}
})

View File

@@ -1,5 +1,7 @@
import { FirebaseError } from 'firebase/app'
import {
type Auth,
AuthErrorCodes,
GithubAuthProvider,
GoogleAuthProvider,
type User,
@@ -20,6 +22,7 @@ import { useFirebaseAuth } from 'vuefire'
import { COMFY_API_BASE_URL } from '@/config/comfyApi'
import { t } from '@/i18n'
import { useDialogService } from '@/services/dialogService'
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
import { type AuthHeader } from '@/types/authTypes'
import { operations } from '@/types/comfyRegistryTypes'
@@ -88,11 +91,27 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
lastBalanceUpdateTime.value = null
})
const getIdToken = async (): Promise<string | null> => {
if (currentUser.value) {
return currentUser.value.getIdToken()
const getIdToken = async (): Promise<string | undefined> => {
if (!currentUser.value) return
try {
return await currentUser.value.getIdToken()
} catch (error: unknown) {
if (
error instanceof FirebaseError &&
error.code === AuthErrorCodes.NETWORK_REQUEST_FAILED
) {
console.warn(
'Could not authenticate with Firebase. Features requiring authentication might not work.'
)
return
}
useDialogService().showErrorDialog(error, {
title: t('errorDialog.defaultTitle'),
reportType: 'authenticationError'
})
console.error(error)
}
return null
}
/**

View File

@@ -1,12 +1,13 @@
import { defineStore } from 'pinia'
import { type Raw, computed, markRaw, ref, shallowRef } from 'vue'
import type { Positionable } from '@/lib/litegraph/src/interfaces'
import type { Point, Positionable } from '@/lib/litegraph/src/interfaces'
import type {
LGraphCanvas,
LGraphGroup,
LGraphNode
} from '@/lib/litegraph/src/litegraph'
import { app } from '@/scripts/app'
import { isLGraphGroup, isLGraphNode, isReroute } from '@/utils/litegraphUtil'
export const useTitleEditorStore = defineStore('titleEditor', () => {
@@ -33,6 +34,36 @@ export const useCanvasStore = defineStore('canvas', () => {
selectedItems.value = items.map((item) => markRaw(item))
}
// Reactive scale percentage that syncs with app.canvas.ds.scale
const appScalePercentage = ref(100)
// Set up scale synchronization when canvas is available
let originalOnChanged: ((scale: number, offset: Point) => void) | undefined =
undefined
const initScaleSync = () => {
if (app.canvas?.ds) {
// Initial sync
originalOnChanged = app.canvas.ds.onChanged
appScalePercentage.value = Math.round(app.canvas.ds.scale * 100)
// Set up continuous sync
app.canvas.ds.onChanged = () => {
if (app.canvas?.ds?.scale) {
appScalePercentage.value = Math.round(app.canvas.ds.scale * 100)
}
// Call original handler if exists
originalOnChanged?.(app.canvas.ds.scale, app.canvas.ds.offset)
}
}
}
const cleanupScaleSync = () => {
if (app.canvas?.ds) {
app.canvas.ds.onChanged = originalOnChanged
originalOnChanged = undefined
}
}
const nodeSelected = computed(() => selectedItems.value.some(isLGraphNode))
const groupSelected = computed(() => selectedItems.value.some(isLGraphGroup))
const rerouteSelected = computed(() => selectedItems.value.some(isReroute))
@@ -42,13 +73,38 @@ export const useCanvasStore = defineStore('canvas', () => {
return canvas.value
}
/**
* Sets the canvas zoom level from a percentage value
* @param percentage - Zoom percentage value (1-1000, where 1000 = 1000% zoom)
*/
const setAppZoomFromPercentage = (percentage: number) => {
if (!app.canvas?.ds || percentage <= 0) return
// Convert percentage to scale (1000% = 10.0 scale)
const newScale = percentage / 100
const ds = app.canvas.ds
ds.changeScale(
newScale,
ds.element ? [ds.element.width / 2, ds.element.height / 2] : undefined
)
app.canvas.setDirty(true, true)
// Update reactive value immediately for UI consistency
appScalePercentage.value = Math.round(newScale * 100)
}
return {
canvas,
selectedItems,
nodeSelected,
groupSelected,
rerouteSelected,
appScalePercentage,
updateSelectedItems,
getCanvas
getCanvas,
setAppZoomFromPercentage,
initScaleSync,
cleanupScaleSync
}
})

View File

@@ -1,14 +1,50 @@
import { useMouse } from '@vueuse/core'
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { computed, ref, shallowRef } from 'vue'
import type NodeSearchBoxPopover from '@/components/searchbox/NodeSearchBoxPopover.vue'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/stores/settingStore'
export const useSearchBoxStore = defineStore('searchBox', () => {
const settingStore = useSettingStore()
const { x, y } = useMouse()
const newSearchBoxEnabled = computed(
() => settingStore.get('Comfy.NodeSearchBoxImpl') === 'default'
)
const popoverRef = shallowRef<InstanceType<
typeof NodeSearchBoxPopover
> | null>(null)
function setPopoverRef(
popover: InstanceType<typeof NodeSearchBoxPopover> | null
) {
popoverRef.value = popover
}
const visible = ref(false)
function toggleVisible() {
visible.value = !visible.value
if (newSearchBoxEnabled.value) {
visible.value = !visible.value
return
}
if (!popoverRef.value) return
popoverRef.value.showSearchBox(
new MouseEvent('click', {
clientX: x.value,
clientY: y.value,
// @ts-expect-error layerY is a nonstandard property
layerY: y.value
}) as unknown as CanvasPointerEvent
)
}
return {
visible,
toggleVisible
newSearchBoxEnabled,
setPopoverRef,
toggleVisible,
visible
}
})

View File

@@ -1,9 +0,0 @@
import type { InjectionKey, Ref } from 'vue'
export interface SelectionOverlayState {
visible: Readonly<Ref<boolean>>
updateCount: Readonly<Ref<number>>
}
export const SelectionOverlayInjectionKey: InjectionKey<SelectionOverlayState> =
Symbol('selectionOverlayState')

View File

@@ -0,0 +1,168 @@
import { describe, expect, it, vi } from 'vitest'
// Mock functions
const mockExecute = vi.fn()
const mockGetCommand = vi.fn().mockReturnValue({
keybinding: {
combo: {
getKeySequences: () => ['Ctrl', '+']
}
}
})
const mockFormatKeySequence = vi.fn().mockReturnValue('Ctrl+')
const mockSetAppZoom = vi.fn()
const mockSettingGet = vi.fn().mockReturnValue(true)
// Mock dependencies
vi.mock('@/renderer/extensions/minimap/composables/useMinimap', () => ({
useMinimap: () => ({
containerStyles: { value: { backgroundColor: '#fff', borderRadius: '8px' } }
})
}))
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({
execute: mockExecute,
getCommand: mockGetCommand,
formatKeySequence: mockFormatKeySequence
})
}))
vi.mock('@/stores/graphStore', () => ({
useCanvasStore: () => ({
appScalePercentage: 100,
setAppZoomFromPercentage: mockSetAppZoom
})
}))
vi.mock('@/stores/settingStore', () => ({
useSettingStore: () => ({
get: mockSettingGet
})
}))
describe('ZoomControlsModal', () => {
it('should have proper props interface', () => {
// Test that the component file structure and basic exports work
expect(mockExecute).toBeDefined()
expect(mockGetCommand).toBeDefined()
expect(mockFormatKeySequence).toBeDefined()
expect(mockSetAppZoom).toBeDefined()
expect(mockSettingGet).toBeDefined()
})
it('should call command store execute when executeCommand is invoked', () => {
mockExecute.mockClear()
// Simulate the executeCommand function behavior
const executeCommand = (command: string) => {
mockExecute(command)
}
executeCommand('Comfy.Canvas.FitView')
expect(mockExecute).toHaveBeenCalledWith('Comfy.Canvas.FitView')
})
it('should validate zoom input ranges correctly', () => {
mockSetAppZoom.mockClear()
// Simulate the applyZoom function behavior
const applyZoom = (val: { value: number }) => {
const inputValue = val.value as number
if (isNaN(inputValue) || inputValue < 1 || inputValue > 1000) {
return
}
mockSetAppZoom(inputValue)
}
// Test invalid values
applyZoom({ value: 0 })
applyZoom({ value: 1010 })
applyZoom({ value: NaN })
expect(mockSetAppZoom).not.toHaveBeenCalled()
// Test valid value
applyZoom({ value: 50 })
expect(mockSetAppZoom).toHaveBeenCalledWith(50)
})
it('should return correct minimap toggle text based on setting', () => {
const t = (key: string) => {
const translations: Record<string, string> = {
'zoomControls.showMinimap': 'Show Minimap',
'zoomControls.hideMinimap': 'Hide Minimap'
}
return translations[key] || key
}
// Simulate the minimapToggleText computed property
const minimapToggleText = () =>
mockSettingGet('Comfy.Minimap.Visible')
? t('zoomControls.hideMinimap')
: t('zoomControls.showMinimap')
// Test when minimap is visible
mockSettingGet.mockReturnValue(true)
expect(minimapToggleText()).toBe('Hide Minimap')
// Test when minimap is hidden
mockSettingGet.mockReturnValue(false)
expect(minimapToggleText()).toBe('Show Minimap')
})
it('should format keyboard shortcuts correctly', () => {
mockFormatKeySequence.mockReturnValue('Ctrl+')
expect(mockFormatKeySequence()).toBe('Ctrl+')
expect(mockGetCommand).toBeDefined()
})
it('should handle repeat command functionality', () => {
mockExecute.mockClear()
let interval: number | null = null
// Simulate the repeat functionality
const startRepeat = (command: string) => {
if (interval) return
const cmd = () => mockExecute(command)
cmd() // Execute immediately
interval = 1 // Mock interval ID
}
const stopRepeat = () => {
if (interval) {
interval = null
}
}
startRepeat('Comfy.Canvas.ZoomIn')
expect(mockExecute).toHaveBeenCalledWith('Comfy.Canvas.ZoomIn')
stopRepeat()
expect(interval).toBeNull()
})
it('should have proper filteredMinimapStyles computed property', () => {
const mockContainerStyles = {
backgroundColor: '#fff',
borderRadius: '8px',
height: '100px',
width: '200px'
}
// Simulate the filteredMinimapStyles computed property
const filteredMinimapStyles = () => {
return {
...mockContainerStyles,
height: undefined,
width: undefined
}
}
const result = filteredMinimapStyles()
expect(result.backgroundColor).toBe('#fff')
expect(result.borderRadius).toBe('8px')
expect(result.height).toBeUndefined()
expect(result.width).toBeUndefined()
})
})

View File

@@ -1694,6 +1694,30 @@ describe('useNodePricing', () => {
'$0.1-0.4/Run (varies with quad, style, texture & quality)'
)
})
it('should return correct pricing for exposed ByteDance models', () => {
const { getNodeDisplayPrice } = useNodePricing()
const testCases = [
{
node_name: 'ByteDanceImageNode',
model: 'seedream-3-0-t2i-250415',
expected: '$0.03/Run'
},
{
node_name: 'ByteDanceImageEditNode',
model: 'seededit-3-0-i2i-250628',
expected: '$0.03/Run'
}
]
testCases.forEach(({ node_name, model, expected }) => {
const node = createMockNode(node_name, [
{ name: 'model', value: model }
])
expect(getNodeDisplayPrice(node)).toBe(expected)
})
})
})
})
})

View File

@@ -1,8 +1,10 @@
import { FirebaseError } from 'firebase/app'
import * as firebaseAuth from 'firebase/auth'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import * as vuefire from 'vuefire'
import { useDialogService } from '@/services/dialogService'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
// Mock fetch
@@ -46,21 +48,24 @@ vi.mock('vue-i18n', () => ({
})
}))
vi.mock('firebase/auth', () => ({
signInWithEmailAndPassword: vi.fn(),
createUserWithEmailAndPassword: vi.fn(),
signOut: vi.fn(),
onAuthStateChanged: vi.fn(),
signInWithPopup: vi.fn(),
GoogleAuthProvider: class {
setCustomParameters = vi.fn()
},
GithubAuthProvider: class {
setCustomParameters = vi.fn()
},
browserLocalPersistence: 'browserLocalPersistence',
setPersistence: vi.fn().mockResolvedValue(undefined)
}))
vi.mock('firebase/auth', async (importOriginal) => {
const actual = await importOriginal<typeof import('firebase/auth')>()
return {
...actual,
signInWithEmailAndPassword: vi.fn(),
createUserWithEmailAndPassword: vi.fn(),
signOut: vi.fn(),
onAuthStateChanged: vi.fn(),
signInWithPopup: vi.fn(),
GoogleAuthProvider: class {
setCustomParameters = vi.fn()
},
GithubAuthProvider: class {
setCustomParameters = vi.fn()
},
setPersistence: vi.fn().mockResolvedValue(undefined)
}
})
// Mock useToastStore
vi.mock('@/stores/toastStore', () => ({
@@ -70,11 +75,7 @@ vi.mock('@/stores/toastStore', () => ({
}))
// Mock useDialogService
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({
showSettingsDialog: vi.fn()
})
}))
vi.mock('@/services/dialogService')
describe('useFirebaseAuthStore', () => {
let store: ReturnType<typeof useFirebaseAuthStore>
@@ -93,6 +94,12 @@ describe('useFirebaseAuthStore', () => {
beforeEach(() => {
vi.resetAllMocks()
// Setup dialog service mock
vi.mocked(useDialogService, { partial: true }).mockReturnValue({
showSettingsDialog: vi.fn(),
showErrorDialog: vi.fn()
})
// Mock useFirebaseAuth to return our mock auth object
vi.mocked(vuefire.useFirebaseAuth).mockReturnValue(mockAuth as any)
@@ -297,7 +304,7 @@ describe('useFirebaseAuthStore', () => {
const token = await store.getIdToken()
expect(token).toBeNull()
expect(token).toBeUndefined()
})
it('should return null for token after login and logout sequence', async () => {
@@ -329,7 +336,75 @@ describe('useFirebaseAuthStore', () => {
// Verify token is null after logout
const tokenAfterLogout = await store.getIdToken()
expect(tokenAfterLogout).toBeNull()
expect(tokenAfterLogout).toBeUndefined()
})
it('should handle network errors gracefully when offline (reproduces issue #4468)', async () => {
// This test reproduces the issue where Firebase Auth makes network requests when offline
// and fails without graceful error handling, causing toast error messages
// Simulate a user with an expired token that requires network refresh
mockUser.getIdToken.mockReset()
// Mock network failure (auth/network-request-failed error from Firebase)
const networkError = new FirebaseError(
firebaseAuth.AuthErrorCodes.NETWORK_REQUEST_FAILED,
'mock error'
)
mockUser.getIdToken.mockRejectedValue(networkError)
const token = await store.getIdToken()
expect(token).toBeUndefined() // Should return undefined instead of throwing
})
it('should show error dialog when getIdToken fails with non-network error', async () => {
// This test verifies that non-network errors trigger the error dialog
mockUser.getIdToken.mockReset()
// Mock a non-network error using actual Firebase Auth error code
const authError = new FirebaseError(
firebaseAuth.AuthErrorCodes.USER_DISABLED,
'User account is disabled.'
)
mockUser.getIdToken.mockRejectedValue(authError)
// Should call the error dialog instead of throwing
const token = await store.getIdToken()
const dialogService = useDialogService()
expect(dialogService.showErrorDialog).toHaveBeenCalledWith(authError, {
title: 'errorDialog.defaultTitle',
reportType: 'authenticationError'
})
expect(token).toBeUndefined()
})
})
describe('getAuthHeader', () => {
it('should handle network errors gracefully when getting Firebase token (reproduces issue #4468)', async () => {
// This test reproduces the issue where getAuthHeader fails due to network errors
// when Firebase Auth tries to refresh tokens offline
// Mock useApiKeyAuthStore to return null (no API key fallback)
const mockApiKeyStore = {
getAuthHeader: vi.fn().mockReturnValue(null)
}
vi.doMock('@/stores/apiKeyAuthStore', () => ({
useApiKeyAuthStore: () => mockApiKeyStore
}))
// Setup user with network error on token refresh
mockUser.getIdToken.mockReset()
const networkError = new FirebaseError(
firebaseAuth.AuthErrorCodes.NETWORK_REQUEST_FAILED,
'mock error'
)
mockUser.getIdToken.mockRejectedValue(networkError)
const authHeader = await store.getAuthHeader()
expect(authHeader).toBeNull() // Should fallback gracefully
})
})

View File

@@ -0,0 +1,137 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type NodeSearchBoxPopover from '@/components/searchbox/NodeSearchBoxPopover.vue'
import type { useSettingStore } from '@/stores/settingStore'
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
// Mock dependencies
vi.mock('@vueuse/core', () => ({
useMouse: vi.fn(() => ({
x: { value: 100 },
y: { value: 200 }
}))
}))
const mockSettingStore = createMockSettingStore()
vi.mock('@/stores/settingStore', () => ({
useSettingStore: vi.fn(() => mockSettingStore)
}))
function createMockPopover(): InstanceType<typeof NodeSearchBoxPopover> {
return { showSearchBox: vi.fn() } satisfies Partial<
InstanceType<typeof NodeSearchBoxPopover>
> as unknown as InstanceType<typeof NodeSearchBoxPopover>
}
function createMockSettingStore(): ReturnType<typeof useSettingStore> {
return {
get: vi.fn()
} satisfies Partial<
ReturnType<typeof useSettingStore>
> as unknown as ReturnType<typeof useSettingStore>
}
describe('useSearchBoxStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.restoreAllMocks()
})
describe('when user has new search box enabled', () => {
beforeEach(() => {
vi.mocked(mockSettingStore.get).mockReturnValue('default')
})
it('should show new search box is enabled', () => {
const store = useSearchBoxStore()
expect(store.newSearchBoxEnabled).toBe(true)
})
it('should toggle search box visibility when user presses shortcut', () => {
const store = useSearchBoxStore()
expect(store.visible).toBe(false)
store.toggleVisible()
expect(store.visible).toBe(true)
store.toggleVisible()
expect(store.visible).toBe(false)
})
})
describe('when user has legacy search box enabled', () => {
beforeEach(() => {
vi.mocked(mockSettingStore.get).mockReturnValue('legacy')
})
it('should show new search box is disabled', () => {
const store = useSearchBoxStore()
expect(store.newSearchBoxEnabled).toBe(false)
})
it('should open legacy search box at mouse position when user presses shortcut', () => {
const store = useSearchBoxStore()
const mockPopover = createMockPopover()
store.setPopoverRef(mockPopover)
expect(vi.mocked(store.visible)).toBe(false)
store.toggleVisible()
expect(vi.mocked(store.visible)).toBe(false) // Doesn't become visible in legacy mode.
expect(vi.mocked(mockPopover.showSearchBox)).toHaveBeenCalledWith(
expect.objectContaining({
clientX: 100,
clientY: 200
})
)
})
it('should do nothing when user presses shortcut but popover is not ready', () => {
const store = useSearchBoxStore()
store.setPopoverRef(null)
store.toggleVisible()
expect(store.visible).toBe(false)
})
})
describe('when user configures popover reference', () => {
beforeEach(() => {
vi.mocked(mockSettingStore.get).mockReturnValue('legacy')
})
it('should enable legacy search when popover is set', () => {
const store = useSearchBoxStore()
const mockPopover = createMockPopover()
store.setPopoverRef(mockPopover)
store.toggleVisible()
expect(vi.mocked(mockPopover.showSearchBox)).toHaveBeenCalled()
})
it('should disable legacy search when popover is cleared', () => {
const store = useSearchBoxStore()
const mockPopover = createMockPopover()
store.setPopoverRef(mockPopover)
store.setPopoverRef(null)
store.toggleVisible()
expect(vi.mocked(mockPopover.showSearchBox)).not.toHaveBeenCalled()
})
})
describe('when user first loads the application', () => {
it('should have search box hidden by default', () => {
const store = useSearchBoxStore()
expect(store.visible).toBe(false)
})
})
})