Merge remote-tracking branch 'origin/main' into sno-storybook--settings-panel
7
.github/workflows/chromatic.yaml
vendored
@@ -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 }}
|
||||
|
||||
4
.github/workflows/test-ui.yaml
vendored
@@ -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
@@ -23,7 +23,6 @@ dist-ssr
|
||||
*.local
|
||||
# Claude configuration
|
||||
.claude/*.local.json
|
||||
.claude/settings.json
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
|
||||
@@ -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' })
|
||||
|
||||
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 88 KiB |
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 100 KiB |
@@ -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()
|
||||
|
||||
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 101 KiB |
@@ -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 }) => {
|
||||
|
||||
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 102 KiB |
@@ -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'
|
||||
])
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
Before Width: | Height: | Size: 170 KiB After Width: | Height: | Size: 114 KiB |
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
237
src/components/graph/modals/ZoomControlsModal.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
113
src/composables/canvas/useSelectionToolboxPosition.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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] || []
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
27
src/composables/useZoomControls.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -14,6 +14,7 @@ import './previewAny'
|
||||
import './rerouteNode'
|
||||
import './saveImageExtraOutput'
|
||||
import './saveMesh'
|
||||
import './selectionBorder'
|
||||
import './simpleTouchSupport'
|
||||
import './slotDefaults'
|
||||
import './uploadAudio'
|
||||
|
||||
70
src/extensions/core/selectionBorder.ts
Normal 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)
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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": "تكبير"
|
||||
},
|
||||
|
||||
@@ -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": "تكبير لتناسب الشاشة"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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": "ズームイン"
|
||||
},
|
||||
|
||||
@@ -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": "全体表示にズーム"
|
||||
}
|
||||
}
|
||||
@@ -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": "확대"
|
||||
},
|
||||
|
||||
@@ -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": "화면에 맞게 확대"
|
||||
}
|
||||
}
|
||||
@@ -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": "Увеличить"
|
||||
},
|
||||
|
||||
@@ -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": "Масштабировать по размеру"
|
||||
}
|
||||
}
|
||||
@@ -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": "放大"
|
||||
},
|
||||
|
||||
@@ -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": "縮放至適合大小"
|
||||
}
|
||||
}
|
||||
@@ -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": "放大"
|
||||
},
|
||||
|
||||
@@ -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": "適合畫面"
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
168
tests-ui/tests/components/graph/ZoomControlsModal.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
137
tests-ui/tests/store/searchBoxStore.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||