mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-05 07:30:11 +00:00
Keyboard Shortcut Bottom Panel (#4635)
This commit is contained in:
committed by
GitHub
parent
f4482eb35a
commit
70c06d10bb
280
browser_tests/tests/bottomPanelShortcuts.spec.ts
Normal file
280
browser_tests/tests/bottomPanelShortcuts.spec.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Bottom Panel Shortcuts', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
test('should toggle shortcuts panel visibility', async ({ comfyPage }) => {
|
||||
// Initially shortcuts panel should be hidden
|
||||
await expect(comfyPage.page.locator('.bottom-panel')).not.toBeVisible()
|
||||
|
||||
// Click shortcuts toggle button in sidebar
|
||||
await comfyPage.page
|
||||
.locator('button[aria-label*="Keyboard Shortcuts"]')
|
||||
.click()
|
||||
|
||||
// Shortcuts panel should now be visible
|
||||
await expect(comfyPage.page.locator('.bottom-panel')).toBeVisible()
|
||||
|
||||
// Click toggle button again to hide
|
||||
await comfyPage.page
|
||||
.locator('button[aria-label*="Keyboard Shortcuts"]')
|
||||
.click()
|
||||
|
||||
// Panel should be hidden again
|
||||
await expect(comfyPage.page.locator('.bottom-panel')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('should display essentials shortcuts tab', async ({ comfyPage }) => {
|
||||
// Open shortcuts panel
|
||||
await comfyPage.page
|
||||
.locator('button[aria-label*="Keyboard Shortcuts"]')
|
||||
.click()
|
||||
|
||||
// Essentials tab should be visible and active by default
|
||||
await expect(
|
||||
comfyPage.page.getByRole('tab', { name: /Essential/i })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByRole('tab', { name: /Essential/i })
|
||||
).toHaveAttribute('aria-selected', 'true')
|
||||
|
||||
// Should display shortcut categories
|
||||
await expect(
|
||||
comfyPage.page.locator('.subcategory-title').first()
|
||||
).toBeVisible()
|
||||
|
||||
// Should display some keyboard shortcuts
|
||||
await expect(comfyPage.page.locator('.key-badge').first()).toBeVisible()
|
||||
|
||||
// Should have workflow, node, and queue sections
|
||||
await expect(
|
||||
comfyPage.page.getByRole('heading', { name: 'Workflow' })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByRole('heading', { name: 'Node' })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByRole('heading', { name: 'Queue' })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('should display view controls shortcuts tab', async ({ comfyPage }) => {
|
||||
// Open shortcuts panel
|
||||
await comfyPage.page
|
||||
.locator('button[aria-label*="Keyboard Shortcuts"]')
|
||||
.click()
|
||||
|
||||
// Click view controls tab
|
||||
await comfyPage.page.getByRole('tab', { name: /View Controls/i }).click()
|
||||
|
||||
// View controls tab should be active
|
||||
await expect(
|
||||
comfyPage.page.getByRole('tab', { name: /View Controls/i })
|
||||
).toHaveAttribute('aria-selected', 'true')
|
||||
|
||||
// Should display view controls shortcuts
|
||||
await expect(comfyPage.page.locator('.key-badge').first()).toBeVisible()
|
||||
|
||||
// Should have view and panel controls sections
|
||||
await expect(
|
||||
comfyPage.page.getByRole('heading', { name: 'View' })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByRole('heading', { name: 'Panel Controls' })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('should switch between shortcuts tabs', async ({ comfyPage }) => {
|
||||
// Open shortcuts panel
|
||||
await comfyPage.page
|
||||
.locator('button[aria-label*="Keyboard Shortcuts"]')
|
||||
.click()
|
||||
|
||||
// Essentials should be active initially
|
||||
await expect(
|
||||
comfyPage.page.getByRole('tab', { name: /Essential/i })
|
||||
).toHaveAttribute('aria-selected', 'true')
|
||||
|
||||
// Click view controls tab
|
||||
await comfyPage.page.getByRole('tab', { name: /View Controls/i }).click()
|
||||
|
||||
// View controls should now be active
|
||||
await expect(
|
||||
comfyPage.page.getByRole('tab', { name: /View Controls/i })
|
||||
).toHaveAttribute('aria-selected', 'true')
|
||||
await expect(
|
||||
comfyPage.page.getByRole('tab', { name: /Essential/i })
|
||||
).not.toHaveAttribute('aria-selected', 'true')
|
||||
|
||||
// Switch back to essentials
|
||||
await comfyPage.page.getByRole('tab', { name: /Essential/i }).click()
|
||||
|
||||
// Essentials should be active again
|
||||
await expect(
|
||||
comfyPage.page.getByRole('tab', { name: /Essential/i })
|
||||
).toHaveAttribute('aria-selected', 'true')
|
||||
await expect(
|
||||
comfyPage.page.getByRole('tab', { name: /View Controls/i })
|
||||
).not.toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
|
||||
test('should display formatted keyboard shortcuts', async ({ comfyPage }) => {
|
||||
// Open shortcuts panel
|
||||
await comfyPage.page
|
||||
.locator('button[aria-label*="Keyboard Shortcuts"]')
|
||||
.click()
|
||||
|
||||
// Wait for shortcuts to load
|
||||
await comfyPage.page.waitForSelector('.key-badge')
|
||||
|
||||
// Check for common formatted keys
|
||||
const keyBadges = comfyPage.page.locator('.key-badge')
|
||||
const count = await keyBadges.count()
|
||||
expect(count).toBeGreaterThanOrEqual(1)
|
||||
|
||||
// Should show formatted modifier keys
|
||||
const badgeText = await keyBadges.allTextContents()
|
||||
const hasModifiers = badgeText.some((text) =>
|
||||
['Ctrl', 'Cmd', 'Shift', 'Alt'].includes(text)
|
||||
)
|
||||
expect(hasModifiers).toBeTruthy()
|
||||
})
|
||||
|
||||
test('should maintain panel state when switching to terminal', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Open shortcuts panel first
|
||||
await comfyPage.page
|
||||
.locator('button[aria-label*="Keyboard Shortcuts"]')
|
||||
.click()
|
||||
await expect(comfyPage.page.locator('.bottom-panel')).toBeVisible()
|
||||
|
||||
// Open terminal panel (should switch panels)
|
||||
await comfyPage.page
|
||||
.locator('button[aria-label*="Toggle Bottom Panel"]')
|
||||
.click()
|
||||
|
||||
// Panel should still be visible but showing terminal content
|
||||
await expect(comfyPage.page.locator('.bottom-panel')).toBeVisible()
|
||||
|
||||
// Switch back to shortcuts
|
||||
await comfyPage.page
|
||||
.locator('button[aria-label*="Keyboard Shortcuts"]')
|
||||
.click()
|
||||
|
||||
// Should show shortcuts content again
|
||||
await expect(
|
||||
comfyPage.page.locator('[id*="tab_shortcuts-essentials"]')
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('should handle keyboard navigation', async ({ comfyPage }) => {
|
||||
// Open shortcuts panel
|
||||
await comfyPage.page
|
||||
.locator('button[aria-label*="Keyboard Shortcuts"]')
|
||||
.click()
|
||||
|
||||
// Focus the first tab
|
||||
await comfyPage.page.getByRole('tab', { name: /Essential/i }).focus()
|
||||
|
||||
// Use arrow keys to navigate between tabs
|
||||
await comfyPage.page.keyboard.press('ArrowRight')
|
||||
|
||||
// View controls tab should now have focus
|
||||
await expect(
|
||||
comfyPage.page.getByRole('tab', { name: /View Controls/i })
|
||||
).toBeFocused()
|
||||
|
||||
// Press Enter to activate the tab
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
|
||||
// Tab should be selected
|
||||
await expect(
|
||||
comfyPage.page.getByRole('tab', { name: /View Controls/i })
|
||||
).toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
|
||||
test('should close panel by clicking shortcuts button again', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Open shortcuts panel
|
||||
await comfyPage.page
|
||||
.locator('button[aria-label*="Keyboard Shortcuts"]')
|
||||
.click()
|
||||
await expect(comfyPage.page.locator('.bottom-panel')).toBeVisible()
|
||||
|
||||
// Click shortcuts button again to close
|
||||
await comfyPage.page
|
||||
.locator('button[aria-label*="Keyboard Shortcuts"]')
|
||||
.click()
|
||||
|
||||
// Panel should be hidden
|
||||
await expect(comfyPage.page.locator('.bottom-panel')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('should display shortcuts in organized columns', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Open shortcuts panel
|
||||
await comfyPage.page
|
||||
.locator('button[aria-label*="Keyboard Shortcuts"]')
|
||||
.click()
|
||||
|
||||
// Should have 3-column grid layout
|
||||
await expect(comfyPage.page.locator('.md\\:grid-cols-3')).toBeVisible()
|
||||
|
||||
// Should have multiple subcategory sections
|
||||
const subcategoryTitles = comfyPage.page.locator('.subcategory-title')
|
||||
const titleCount = await subcategoryTitles.count()
|
||||
expect(titleCount).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
test('should open shortcuts panel with Ctrl+Shift+K', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Initially shortcuts panel should be hidden
|
||||
await expect(comfyPage.page.locator('.bottom-panel')).not.toBeVisible()
|
||||
|
||||
// Press Ctrl+Shift+K to open shortcuts panel
|
||||
await comfyPage.page.keyboard.press('Control+Shift+KeyK')
|
||||
|
||||
// Shortcuts panel should now be visible
|
||||
await expect(comfyPage.page.locator('.bottom-panel')).toBeVisible()
|
||||
|
||||
// Should show essentials tab by default
|
||||
await expect(
|
||||
comfyPage.page.getByRole('tab', { name: /Essential/i })
|
||||
).toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
|
||||
test('should open settings dialog when clicking manage shortcuts button', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Open shortcuts panel
|
||||
await comfyPage.page
|
||||
.locator('button[aria-label*="Keyboard Shortcuts"]')
|
||||
.click()
|
||||
|
||||
// Manage shortcuts button should be visible
|
||||
await expect(
|
||||
comfyPage.page.getByRole('button', { name: /Manage Shortcuts/i })
|
||||
).toBeVisible()
|
||||
|
||||
// Click manage shortcuts button
|
||||
await comfyPage.page
|
||||
.getByRole('button', { name: /Manage Shortcuts/i })
|
||||
.click()
|
||||
|
||||
// Settings dialog should open with keybinding tab
|
||||
await expect(comfyPage.page.getByRole('dialog')).toBeVisible()
|
||||
|
||||
// Should show keybinding settings (check for keybinding-related content)
|
||||
await expect(
|
||||
comfyPage.page.getByRole('option', { name: 'Keybinding' })
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -11,18 +11,33 @@
|
||||
class="p-3 border-none"
|
||||
>
|
||||
<span class="font-bold">
|
||||
{{ tab.title.toUpperCase() }}
|
||||
{{
|
||||
shouldCapitalizeTab(tab.id)
|
||||
? tab.title.toUpperCase()
|
||||
: tab.title
|
||||
}}
|
||||
</span>
|
||||
</Tab>
|
||||
</div>
|
||||
<Button
|
||||
class="justify-self-end"
|
||||
icon="pi pi-times"
|
||||
severity="secondary"
|
||||
size="small"
|
||||
text
|
||||
@click="bottomPanelStore.bottomPanelVisible = false"
|
||||
/>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
v-if="isShortcutsTabActive"
|
||||
:label="$t('shortcuts.manageShortcuts')"
|
||||
icon="pi pi-cog"
|
||||
severity="secondary"
|
||||
size="small"
|
||||
text
|
||||
@click="openKeybindingSettings"
|
||||
/>
|
||||
<Button
|
||||
class="justify-self-end"
|
||||
icon="pi pi-times"
|
||||
severity="secondary"
|
||||
size="small"
|
||||
text
|
||||
@click="closeBottomPanel"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabList>
|
||||
</Tabs>
|
||||
@@ -44,9 +59,32 @@ import Button from 'primevue/button'
|
||||
import Tab from 'primevue/tab'
|
||||
import TabList from 'primevue/tablist'
|
||||
import Tabs from 'primevue/tabs'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||
|
||||
const bottomPanelStore = useBottomPanelStore()
|
||||
const dialogService = useDialogService()
|
||||
|
||||
const isShortcutsTabActive = computed(() => {
|
||||
const activeTabId = bottomPanelStore.activeBottomPanelTabId
|
||||
return (
|
||||
activeTabId === 'shortcuts-essentials' ||
|
||||
activeTabId === 'shortcuts-view-controls'
|
||||
)
|
||||
})
|
||||
|
||||
const shouldCapitalizeTab = (tabId: string): boolean => {
|
||||
return tabId !== 'shortcuts-essentials' && tabId !== 'shortcuts-view-controls'
|
||||
}
|
||||
|
||||
const openKeybindingSettings = async () => {
|
||||
dialogService.showSettingsDialog('keybinding')
|
||||
}
|
||||
|
||||
const closeBottomPanel = () => {
|
||||
bottomPanelStore.activePanel = null
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<div class="h-full flex flex-col p-4">
|
||||
<div class="flex-1 min-h-0 overflow-auto">
|
||||
<ShortcutsList
|
||||
:commands="essentialsCommands"
|
||||
:subcategories="essentialsSubcategories"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import {
|
||||
ESSENTIALS_CONFIG,
|
||||
useCommandSubcategories
|
||||
} from '@/composables/bottomPanelTabs/useCommandSubcategories'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
import ShortcutsList from './ShortcutsList.vue'
|
||||
|
||||
const commandStore = useCommandStore()
|
||||
|
||||
const essentialsCommands = computed(() =>
|
||||
commandStore.commands.filter((cmd) => cmd.category === 'essentials')
|
||||
)
|
||||
|
||||
const { subcategories: essentialsSubcategories } = useCommandSubcategories(
|
||||
essentialsCommands,
|
||||
ESSENTIALS_CONFIG
|
||||
)
|
||||
</script>
|
||||
119
src/components/bottomPanel/tabs/shortcuts/ShortcutsList.vue
Normal file
119
src/components/bottomPanel/tabs/shortcuts/ShortcutsList.vue
Normal file
@@ -0,0 +1,119 @@
|
||||
<template>
|
||||
<div class="shortcuts-list flex justify-center">
|
||||
<div class="grid gap-4 md:gap-24 h-full grid-cols-1 md:grid-cols-3 w-[90%]">
|
||||
<div
|
||||
v-for="(subcategoryCommands, subcategory) in filteredSubcategories"
|
||||
:key="subcategory"
|
||||
class="flex flex-col"
|
||||
>
|
||||
<h3
|
||||
class="subcategory-title text-xs font-bold uppercase tracking-wide text-surface-600 dark-theme:text-surface-400 mb-4"
|
||||
>
|
||||
{{ getSubcategoryTitle(subcategory) }}
|
||||
</h3>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<div
|
||||
v-for="command in subcategoryCommands"
|
||||
:key="command.id"
|
||||
class="shortcut-item flex justify-between items-center py-2 rounded hover:bg-surface-100 dark-theme:hover:bg-surface-700 transition-colors duration-200"
|
||||
>
|
||||
<div class="shortcut-info flex-grow pr-4">
|
||||
<div class="shortcut-name text-sm font-medium">
|
||||
{{ command.label || command.id }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="keybinding-display flex-shrink-0">
|
||||
<div
|
||||
class="keybinding-combo flex gap-1"
|
||||
:aria-label="`Keyboard shortcut: ${command.keybinding!.combo.getKeySequences().join(' + ')}`"
|
||||
>
|
||||
<span
|
||||
v-for="key in command.keybinding!.combo.getKeySequences()"
|
||||
:key="key"
|
||||
class="key-badge px-2 py-1 text-xs font-mono bg-surface-200 dark-theme:bg-surface-600 rounded border min-w-6 text-center"
|
||||
>
|
||||
{{ formatKey(key) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { ComfyCommandImpl } from '@/stores/commandStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { subcategories } = defineProps<{
|
||||
commands: ComfyCommandImpl[]
|
||||
subcategories: Record<string, ComfyCommandImpl[]>
|
||||
}>()
|
||||
|
||||
const filteredSubcategories = computed(() => {
|
||||
const result: Record<string, ComfyCommandImpl[]> = {}
|
||||
|
||||
for (const [subcategory, commands] of Object.entries(subcategories)) {
|
||||
result[subcategory] = commands.filter((cmd) => !!cmd.keybinding)
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const getSubcategoryTitle = (subcategory: string): string => {
|
||||
const titleMap: Record<string, string> = {
|
||||
workflow: t('shortcuts.subcategories.workflow'),
|
||||
node: t('shortcuts.subcategories.node'),
|
||||
queue: t('shortcuts.subcategories.queue'),
|
||||
view: t('shortcuts.subcategories.view'),
|
||||
'panel-controls': t('shortcuts.subcategories.panelControls')
|
||||
}
|
||||
|
||||
return titleMap[subcategory] || subcategory
|
||||
}
|
||||
|
||||
const formatKey = (key: string): string => {
|
||||
const keyMap: Record<string, string> = {
|
||||
Control: 'Ctrl',
|
||||
Meta: 'Cmd',
|
||||
ArrowUp: '↑',
|
||||
ArrowDown: '↓',
|
||||
ArrowLeft: '←',
|
||||
ArrowRight: '→',
|
||||
Backspace: '⌫',
|
||||
Delete: '⌦',
|
||||
Enter: '↵',
|
||||
Escape: 'Esc',
|
||||
Tab: '⇥',
|
||||
' ': 'Space'
|
||||
}
|
||||
|
||||
return keyMap[key] || key
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.subcategory-title {
|
||||
color: var(--p-text-muted-color);
|
||||
}
|
||||
|
||||
.key-badge {
|
||||
background-color: var(--p-surface-200);
|
||||
border: 1px solid var(--p-surface-300);
|
||||
min-width: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dark-theme .key-badge {
|
||||
background-color: var(--p-surface-600);
|
||||
border-color: var(--p-surface-500);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<div class="h-full flex flex-col p-4">
|
||||
<div class="flex-1 min-h-0 overflow-auto">
|
||||
<ShortcutsList
|
||||
:commands="viewControlsCommands"
|
||||
:subcategories="viewControlsSubcategories"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import {
|
||||
VIEW_CONTROLS_CONFIG,
|
||||
useCommandSubcategories
|
||||
} from '@/composables/bottomPanelTabs/useCommandSubcategories'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
import ShortcutsList from './ShortcutsList.vue'
|
||||
|
||||
const commandStore = useCommandStore()
|
||||
|
||||
const viewControlsCommands = computed(() =>
|
||||
commandStore.commands.filter((cmd) => cmd.category === 'view-controls')
|
||||
)
|
||||
|
||||
const { subcategories: viewControlsSubcategories } = useCommandSubcategories(
|
||||
viewControlsCommands,
|
||||
VIEW_CONTROLS_CONFIG
|
||||
)
|
||||
</script>
|
||||
@@ -2,7 +2,11 @@
|
||||
<div
|
||||
v-if="visible && initialized"
|
||||
ref="containerRef"
|
||||
class="litegraph-minimap absolute bottom-[20px] right-[90px] z-[1000]"
|
||||
class="litegraph-minimap absolute right-[90px] z-[1000]"
|
||||
:class="{
|
||||
'bottom-[20px]': !bottomPanelStore.bottomPanelVisible,
|
||||
'bottom-[280px]': bottomPanelStore.bottomPanelVisible
|
||||
}"
|
||||
:style="containerStyles"
|
||||
@pointerdown="handlePointerDown"
|
||||
@pointermove="handlePointerMove"
|
||||
@@ -25,9 +29,11 @@ import { onMounted, onUnmounted, watch } from 'vue'
|
||||
|
||||
import { useMinimap } from '@/composables/useMinimap'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||
|
||||
const minimap = useMinimap()
|
||||
const canvasStore = useCanvasStore()
|
||||
const bottomPanelStore = useBottomPanelStore()
|
||||
|
||||
const {
|
||||
initialized,
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<SidebarLogoutIcon v-if="userStore.isMultiUserServer" />
|
||||
<SidebarHelpCenterIcon />
|
||||
<SidebarBottomPanelToggleButton />
|
||||
<SidebarShortcutsToggleButton />
|
||||
</div>
|
||||
</nav>
|
||||
</teleport>
|
||||
@@ -32,6 +33,7 @@ import { computed } from 'vue'
|
||||
|
||||
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
|
||||
import SidebarBottomPanelToggleButton from '@/components/sidebar/SidebarBottomPanelToggleButton.vue'
|
||||
import SidebarShortcutsToggleButton from '@/components/sidebar/SidebarShortcutsToggleButton.vue'
|
||||
import { useKeybindingStore } from '@/stores/keybindingStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useUserStore } from '@/stores/userStore'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<SidebarIcon
|
||||
:tooltip="$t('menu.toggleBottomPanel')"
|
||||
:selected="bottomPanelStore.bottomPanelVisible"
|
||||
:selected="bottomPanelStore.activePanel == 'terminal'"
|
||||
@click="bottomPanelStore.toggleBottomPanel"
|
||||
>
|
||||
<template #icon>
|
||||
|
||||
44
src/components/sidebar/SidebarShortcutsToggleButton.vue
Normal file
44
src/components/sidebar/SidebarShortcutsToggleButton.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<SidebarIcon
|
||||
:tooltip="
|
||||
$t('shortcuts.keyboardShortcuts') +
|
||||
' (' +
|
||||
formatKeySequence(command.keybinding!.combo.getKeySequences()) +
|
||||
')'
|
||||
"
|
||||
:selected="isShortcutsPanelVisible"
|
||||
@click="toggleShortcutsPanel"
|
||||
>
|
||||
<template #icon>
|
||||
<i-lucide:keyboard />
|
||||
</template>
|
||||
</SidebarIcon>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||
|
||||
import SidebarIcon from './SidebarIcon.vue'
|
||||
|
||||
const bottomPanelStore = useBottomPanelStore()
|
||||
const command = useCommandStore().getCommand(
|
||||
'Workspace.ToggleBottomPanel.Shortcuts'
|
||||
)
|
||||
|
||||
const isShortcutsPanelVisible = computed(
|
||||
() => bottomPanelStore.activePanel === 'shortcuts'
|
||||
)
|
||||
|
||||
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>
|
||||
78
src/composables/bottomPanelTabs/useCommandSubcategories.ts
Normal file
78
src/composables/bottomPanelTabs/useCommandSubcategories.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { type ComputedRef, computed } from 'vue'
|
||||
|
||||
import { type ComfyCommandImpl } from '@/stores/commandStore'
|
||||
|
||||
export type SubcategoryRule = {
|
||||
pattern: string | RegExp
|
||||
subcategory: string
|
||||
}
|
||||
|
||||
export type SubcategoryConfig = {
|
||||
defaultSubcategory: string
|
||||
rules: SubcategoryRule[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for grouping commands by subcategory based on configurable rules
|
||||
*/
|
||||
export function useCommandSubcategories(
|
||||
commands: ComputedRef<ComfyCommandImpl[]>,
|
||||
config: SubcategoryConfig
|
||||
) {
|
||||
const subcategories = computed(() => {
|
||||
const result: Record<string, ComfyCommandImpl[]> = {}
|
||||
|
||||
for (const command of commands.value) {
|
||||
let subcategory = config.defaultSubcategory
|
||||
|
||||
// Find the first matching rule
|
||||
for (const rule of config.rules) {
|
||||
const matches =
|
||||
typeof rule.pattern === 'string'
|
||||
? command.id.includes(rule.pattern)
|
||||
: rule.pattern.test(command.id)
|
||||
|
||||
if (matches) {
|
||||
subcategory = rule.subcategory
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!result[subcategory]) {
|
||||
result[subcategory] = []
|
||||
}
|
||||
result[subcategory].push(command)
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
return {
|
||||
subcategories
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Predefined configuration for view controls subcategories
|
||||
*/
|
||||
export const VIEW_CONTROLS_CONFIG: SubcategoryConfig = {
|
||||
defaultSubcategory: 'view',
|
||||
rules: [
|
||||
{ pattern: 'Zoom', subcategory: 'view' },
|
||||
{ pattern: 'Fit', subcategory: 'view' },
|
||||
{ pattern: 'Panel', subcategory: 'panel-controls' },
|
||||
{ pattern: 'Sidebar', subcategory: 'panel-controls' }
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Predefined configuration for essentials subcategories
|
||||
*/
|
||||
export const ESSENTIALS_CONFIG: SubcategoryConfig = {
|
||||
defaultSubcategory: 'workflow',
|
||||
rules: [
|
||||
{ pattern: 'Workflow', subcategory: 'workflow' },
|
||||
{ pattern: 'Node', subcategory: 'node' },
|
||||
{ pattern: 'Queue', subcategory: 'queue' }
|
||||
]
|
||||
}
|
||||
27
src/composables/bottomPanelTabs/useShortcutsTab.ts
Normal file
27
src/composables/bottomPanelTabs/useShortcutsTab.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { markRaw } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import EssentialsPanel from '@/components/bottomPanel/tabs/shortcuts/EssentialsPanel.vue'
|
||||
import ViewControlsPanel from '@/components/bottomPanel/tabs/shortcuts/ViewControlsPanel.vue'
|
||||
import { BottomPanelExtension } from '@/types/extensionTypes'
|
||||
|
||||
export const useShortcutsTab = (): BottomPanelExtension[] => {
|
||||
const { t } = useI18n()
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'shortcuts-essentials',
|
||||
title: t('shortcuts.essentials'),
|
||||
component: markRaw(EssentialsPanel),
|
||||
type: 'vue',
|
||||
targetPanel: 'shortcuts'
|
||||
},
|
||||
{
|
||||
id: 'shortcuts-view-controls',
|
||||
title: t('shortcuts.viewControls'),
|
||||
component: markRaw(ViewControlsPanel),
|
||||
type: 'vue',
|
||||
targetPanel: 'shortcuts'
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -46,6 +46,9 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
const toastStore = useToastStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionStore = useExecutionStore()
|
||||
|
||||
const bottomPanelStore = useBottomPanelStore()
|
||||
|
||||
const { getSelectedNodes, toggleSelectedNodesMode } =
|
||||
useSelectedLiteGraphItems()
|
||||
const getTracker = () => workflowStore.activeWorkflow?.changeTracker
|
||||
@@ -70,6 +73,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
icon: 'pi pi-plus',
|
||||
label: 'New Blank Workflow',
|
||||
menubarLabel: 'New',
|
||||
category: 'essentials' as const,
|
||||
function: () => workflowService.loadBlankWorkflow()
|
||||
},
|
||||
{
|
||||
@@ -77,6 +81,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
icon: 'pi pi-folder-open',
|
||||
label: 'Open Workflow',
|
||||
menubarLabel: 'Open',
|
||||
category: 'essentials' as const,
|
||||
function: () => {
|
||||
app.ui.loadFile()
|
||||
}
|
||||
@@ -92,6 +97,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
icon: 'pi pi-save',
|
||||
label: 'Save Workflow',
|
||||
menubarLabel: 'Save',
|
||||
category: 'essentials' as const,
|
||||
function: async () => {
|
||||
const workflow = useWorkflowStore().activeWorkflow as ComfyWorkflow
|
||||
if (!workflow) return
|
||||
@@ -104,6 +110,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
icon: 'pi pi-save',
|
||||
label: 'Save Workflow As',
|
||||
menubarLabel: 'Save As',
|
||||
category: 'essentials' as const,
|
||||
function: async () => {
|
||||
const workflow = useWorkflowStore().activeWorkflow as ComfyWorkflow
|
||||
if (!workflow) return
|
||||
@@ -116,6 +123,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
icon: 'pi pi-download',
|
||||
label: 'Export Workflow',
|
||||
menubarLabel: 'Export',
|
||||
category: 'essentials' as const,
|
||||
function: async () => {
|
||||
await workflowService.exportWorkflow('workflow', 'workflow')
|
||||
}
|
||||
@@ -133,6 +141,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
id: 'Comfy.Undo',
|
||||
icon: 'pi pi-undo',
|
||||
label: 'Undo',
|
||||
category: 'essentials' as const,
|
||||
function: async () => {
|
||||
await getTracker()?.undo?.()
|
||||
}
|
||||
@@ -141,6 +150,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
id: 'Comfy.Redo',
|
||||
icon: 'pi pi-refresh',
|
||||
label: 'Redo',
|
||||
category: 'essentials' as const,
|
||||
function: async () => {
|
||||
await getTracker()?.redo?.()
|
||||
}
|
||||
@@ -149,6 +159,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
id: 'Comfy.ClearWorkflow',
|
||||
icon: 'pi pi-trash',
|
||||
label: 'Clear Workflow',
|
||||
category: 'essentials' as const,
|
||||
function: () => {
|
||||
const settingStore = useSettingStore()
|
||||
if (
|
||||
@@ -190,6 +201,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
id: 'Comfy.RefreshNodeDefinitions',
|
||||
icon: 'pi pi-refresh',
|
||||
label: 'Refresh Node Definitions',
|
||||
category: 'essentials' as const,
|
||||
function: async () => {
|
||||
await app.refreshComboInNodes()
|
||||
}
|
||||
@@ -198,6 +210,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
id: 'Comfy.Interrupt',
|
||||
icon: 'pi pi-stop',
|
||||
label: 'Interrupt',
|
||||
category: 'essentials' as const,
|
||||
function: async () => {
|
||||
await api.interrupt(executionStore.activePromptId)
|
||||
toastStore.add({
|
||||
@@ -212,6 +225,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
id: 'Comfy.ClearPendingTasks',
|
||||
icon: 'pi pi-stop',
|
||||
label: 'Clear Pending Tasks',
|
||||
category: 'essentials' as const,
|
||||
function: async () => {
|
||||
await useQueueStore().clear(['queue'])
|
||||
toastStore.add({
|
||||
@@ -234,6 +248,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
id: 'Comfy.Canvas.ZoomIn',
|
||||
icon: 'pi pi-plus',
|
||||
label: 'Zoom In',
|
||||
category: 'view-controls' as const,
|
||||
function: () => {
|
||||
const ds = app.canvas.ds
|
||||
ds.changeScale(
|
||||
@@ -247,6 +262,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
id: 'Comfy.Canvas.ZoomOut',
|
||||
icon: 'pi pi-minus',
|
||||
label: 'Zoom Out',
|
||||
category: 'view-controls' as const,
|
||||
function: () => {
|
||||
const ds = app.canvas.ds
|
||||
ds.changeScale(
|
||||
@@ -260,6 +276,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
id: 'Comfy.Canvas.FitView',
|
||||
icon: 'pi pi-expand',
|
||||
label: 'Fit view to selected nodes',
|
||||
category: 'view-controls' as const,
|
||||
function: () => {
|
||||
if (app.canvas.empty) {
|
||||
toastStore.add({
|
||||
@@ -325,6 +342,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
icon: 'pi pi-play',
|
||||
label: 'Queue Prompt',
|
||||
versionAdded: '1.3.7',
|
||||
category: 'essentials' as const,
|
||||
function: async () => {
|
||||
const batchCount = useQueueSettingsStore().batchCount
|
||||
await app.queuePrompt(0, batchCount)
|
||||
@@ -335,6 +353,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
icon: 'pi pi-play',
|
||||
label: 'Queue Prompt (Front)',
|
||||
versionAdded: '1.3.7',
|
||||
category: 'essentials' as const,
|
||||
function: async () => {
|
||||
const batchCount = useQueueSettingsStore().batchCount
|
||||
await app.queuePrompt(-1, batchCount)
|
||||
@@ -371,6 +390,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
icon: 'pi pi-cog',
|
||||
label: 'Show Settings Dialog',
|
||||
versionAdded: '1.3.7',
|
||||
category: 'view-controls' as const,
|
||||
function: () => {
|
||||
dialogService.showSettingsDialog()
|
||||
}
|
||||
@@ -380,6 +400,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
icon: 'pi pi-sitemap',
|
||||
label: 'Group Selected Nodes',
|
||||
versionAdded: '1.3.7',
|
||||
category: 'essentials' as const,
|
||||
function: () => {
|
||||
const { canvas } = app
|
||||
if (!canvas.selectedItems?.size) {
|
||||
@@ -423,6 +444,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
icon: 'pi pi-volume-off',
|
||||
label: 'Mute/Unmute Selected Nodes',
|
||||
versionAdded: '1.3.11',
|
||||
category: 'essentials' as const,
|
||||
function: () => {
|
||||
toggleSelectedNodesMode(LGraphEventMode.NEVER)
|
||||
app.canvas.setDirty(true, true)
|
||||
@@ -433,6 +455,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
icon: 'pi pi-shield',
|
||||
label: 'Bypass/Unbypass Selected Nodes',
|
||||
versionAdded: '1.3.11',
|
||||
category: 'essentials' as const,
|
||||
function: () => {
|
||||
toggleSelectedNodesMode(LGraphEventMode.BYPASS)
|
||||
app.canvas.setDirty(true, true)
|
||||
@@ -443,6 +466,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
icon: 'pi pi-pin',
|
||||
label: 'Pin/Unpin Selected Nodes',
|
||||
versionAdded: '1.3.11',
|
||||
category: 'essentials' as const,
|
||||
function: () => {
|
||||
getSelectedNodes().forEach((node) => {
|
||||
node.pin(!node.pinned)
|
||||
@@ -516,8 +540,9 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
icon: 'pi pi-list',
|
||||
label: 'Toggle Bottom Panel',
|
||||
versionAdded: '1.3.22',
|
||||
category: 'view-controls' as const,
|
||||
function: () => {
|
||||
useBottomPanelStore().toggleBottomPanel()
|
||||
bottomPanelStore.toggleBottomPanel()
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -525,6 +550,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
icon: 'pi pi-eye',
|
||||
label: 'Toggle Focus Mode',
|
||||
versionAdded: '1.3.27',
|
||||
category: 'view-controls' as const,
|
||||
function: () => {
|
||||
useWorkspaceStore().toggleFocusMode()
|
||||
}
|
||||
@@ -750,6 +776,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
icon: 'pi pi-sitemap',
|
||||
label: 'Convert Selection to Subgraph',
|
||||
versionAdded: '1.20.1',
|
||||
category: 'essentials' as const,
|
||||
function: () => {
|
||||
const canvas = canvasStore.getCanvas()
|
||||
const graph = canvas.subgraph ?? canvas.graph
|
||||
@@ -768,6 +795,16 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
const { node } = res
|
||||
canvas.select(node)
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Workspace.ToggleBottomPanel.Shortcuts',
|
||||
icon: 'pi pi-key',
|
||||
label: 'Show Keybindings Dialog',
|
||||
versionAdded: '1.24.1',
|
||||
category: 'view-controls' as const,
|
||||
function: () => {
|
||||
bottomPanelStore.togglePanel('shortcuts')
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -182,5 +182,13 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
|
||||
alt: true
|
||||
},
|
||||
commandId: 'Comfy.Canvas.ToggleMinimap'
|
||||
},
|
||||
{
|
||||
combo: {
|
||||
ctrl: true,
|
||||
shift: true,
|
||||
key: 'k'
|
||||
},
|
||||
commandId: 'Workspace.ToggleBottomPanel.Shortcuts'
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1630,5 +1630,19 @@
|
||||
"clearWorkflow": "Clear Workflow",
|
||||
"deleteWorkflow": "Delete Workflow",
|
||||
"enterNewName": "Enter new name"
|
||||
},
|
||||
"shortcuts": {
|
||||
"essentials": "Essential",
|
||||
"viewControls": "View Controls",
|
||||
"manageShortcuts": "Manage Shortcuts",
|
||||
"noKeybinding": "No keybinding",
|
||||
"keyboardShortcuts": "Keyboard Shortcuts",
|
||||
"subcategories": {
|
||||
"workflow": "Workflow",
|
||||
"node": "Node",
|
||||
"queue": "Queue",
|
||||
"view": "View",
|
||||
"panelControls": "Panel Controls"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ export interface ComfyCommand {
|
||||
versionAdded?: string
|
||||
confirmation?: string // If non-nullish, this command will prompt for confirmation
|
||||
source?: string
|
||||
category?: 'essentials' | 'view-controls' // For shortcuts panel organization
|
||||
}
|
||||
|
||||
export class ComfyCommandImpl implements ComfyCommand {
|
||||
@@ -29,6 +30,7 @@ export class ComfyCommandImpl implements ComfyCommand {
|
||||
versionAdded?: string
|
||||
confirmation?: string
|
||||
source?: string
|
||||
category?: 'essentials' | 'view-controls'
|
||||
|
||||
constructor(command: ComfyCommand) {
|
||||
this.id = command.id
|
||||
@@ -40,6 +42,7 @@ export class ComfyCommandImpl implements ComfyCommand {
|
||||
this.versionAdded = command.versionAdded
|
||||
this.confirmation = command.confirmation
|
||||
this.source = command.source
|
||||
this.category = command.category
|
||||
}
|
||||
|
||||
get label() {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useShortcutsTab } from '@/composables/bottomPanelTabs/useShortcutsTab'
|
||||
import {
|
||||
useCommandTerminalTab,
|
||||
useLogsTerminalTab
|
||||
@@ -10,45 +11,110 @@ import { ComfyExtension } from '@/types/comfy'
|
||||
import type { BottomPanelExtension } from '@/types/extensionTypes'
|
||||
import { isElectron } from '@/utils/envUtil'
|
||||
|
||||
type PanelType = 'terminal' | 'shortcuts'
|
||||
|
||||
interface PanelState {
|
||||
tabs: BottomPanelExtension[]
|
||||
activeTabId: string
|
||||
visible: boolean
|
||||
}
|
||||
|
||||
export const useBottomPanelStore = defineStore('bottomPanel', () => {
|
||||
const bottomPanelVisible = ref(false)
|
||||
const toggleBottomPanel = () => {
|
||||
// If there are no tabs, don't show the bottom panel
|
||||
if (bottomPanelTabs.value.length === 0) {
|
||||
return
|
||||
// Multi-panel state
|
||||
const panels = ref<Record<PanelType, PanelState>>({
|
||||
terminal: { tabs: [], activeTabId: '', visible: false },
|
||||
shortcuts: { tabs: [], activeTabId: '', visible: false }
|
||||
})
|
||||
|
||||
const activePanel = ref<PanelType | null>(null)
|
||||
|
||||
// Computed properties for active panel
|
||||
const activePanelState = computed(() =>
|
||||
activePanel.value ? panels.value[activePanel.value] : null
|
||||
)
|
||||
|
||||
const activeBottomPanelTab = computed<BottomPanelExtension | null>(() => {
|
||||
const state = activePanelState.value
|
||||
if (!state) return null
|
||||
return state.tabs.find((tab) => tab.id === state.activeTabId) ?? null
|
||||
})
|
||||
|
||||
const bottomPanelVisible = computed({
|
||||
get: () => !!activePanel.value,
|
||||
set: (visible: boolean) => {
|
||||
if (!visible) {
|
||||
activePanel.value = null
|
||||
}
|
||||
}
|
||||
})
|
||||
const bottomPanelTabs = computed(() => activePanelState.value?.tabs ?? [])
|
||||
const activeBottomPanelTabId = computed({
|
||||
get: () => activePanelState.value?.activeTabId ?? '',
|
||||
set: (tabId: string) => {
|
||||
const state = activePanelState.value
|
||||
if (state) {
|
||||
state.activeTabId = tabId
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const togglePanel = (panelType: PanelType) => {
|
||||
const panel = panels.value[panelType]
|
||||
if (panel.tabs.length === 0) return
|
||||
|
||||
if (activePanel.value === panelType) {
|
||||
// Hide current panel
|
||||
activePanel.value = null
|
||||
} else {
|
||||
// Show target panel
|
||||
activePanel.value = panelType
|
||||
if (!panel.activeTabId && panel.tabs.length > 0) {
|
||||
panel.activeTabId = panel.tabs[0].id
|
||||
}
|
||||
}
|
||||
bottomPanelVisible.value = !bottomPanelVisible.value
|
||||
}
|
||||
|
||||
const bottomPanelTabs = ref<BottomPanelExtension[]>([])
|
||||
const activeBottomPanelTabId = ref<string>('')
|
||||
const activeBottomPanelTab = computed<BottomPanelExtension | null>(() => {
|
||||
return (
|
||||
bottomPanelTabs.value.find(
|
||||
(tab) => tab.id === activeBottomPanelTabId.value
|
||||
) ?? null
|
||||
)
|
||||
})
|
||||
const setActiveTab = (tabId: string) => {
|
||||
activeBottomPanelTabId.value = tabId
|
||||
const toggleBottomPanel = () => {
|
||||
// Legacy method - toggles terminal panel
|
||||
togglePanel('terminal')
|
||||
}
|
||||
|
||||
const setActiveTab = (tabId: string) => {
|
||||
const state = activePanelState.value
|
||||
if (state) {
|
||||
state.activeTabId = tabId
|
||||
}
|
||||
}
|
||||
|
||||
const toggleBottomPanelTab = (tabId: string) => {
|
||||
if (activeBottomPanelTabId.value === tabId && bottomPanelVisible.value) {
|
||||
bottomPanelVisible.value = false
|
||||
} else {
|
||||
activeBottomPanelTabId.value = tabId
|
||||
bottomPanelVisible.value = true
|
||||
// Find which panel contains this tab
|
||||
for (const [panelType, panel] of Object.entries(panels.value)) {
|
||||
const tab = panel.tabs.find((t) => t.id === tabId)
|
||||
if (tab) {
|
||||
if (activePanel.value === panelType && panel.activeTabId === tabId) {
|
||||
activePanel.value = null
|
||||
} else {
|
||||
activePanel.value = panelType as PanelType
|
||||
panel.activeTabId = tabId
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
const registerBottomPanelTab = (tab: BottomPanelExtension) => {
|
||||
bottomPanelTabs.value = [...bottomPanelTabs.value, tab]
|
||||
if (bottomPanelTabs.value.length === 1) {
|
||||
activeBottomPanelTabId.value = tab.id
|
||||
const targetPanel = tab.targetPanel ?? 'terminal'
|
||||
const panel = panels.value[targetPanel]
|
||||
|
||||
panel.tabs = [...panel.tabs, tab]
|
||||
if (panel.tabs.length === 1) {
|
||||
panel.activeTabId = tab.id
|
||||
}
|
||||
|
||||
useCommandStore().registerCommand({
|
||||
id: `Workspace.ToggleBottomPanelTab.${tab.id}`,
|
||||
icon: 'pi pi-list',
|
||||
label: `Toggle ${tab.title} Bottom Panel`,
|
||||
category: 'view-controls' as const,
|
||||
function: () => toggleBottomPanelTab(tab.id),
|
||||
source: 'System'
|
||||
})
|
||||
@@ -59,6 +125,7 @@ export const useBottomPanelStore = defineStore('bottomPanel', () => {
|
||||
if (isElectron()) {
|
||||
registerBottomPanelTab(useCommandTerminalTab())
|
||||
}
|
||||
useShortcutsTab().forEach(registerBottomPanelTab)
|
||||
}
|
||||
|
||||
const registerExtensionBottomPanelTabs = (extension: ComfyExtension) => {
|
||||
@@ -68,6 +135,11 @@ export const useBottomPanelStore = defineStore('bottomPanel', () => {
|
||||
}
|
||||
|
||||
return {
|
||||
// Multi-panel API
|
||||
panels,
|
||||
activePanel,
|
||||
togglePanel,
|
||||
|
||||
bottomPanelVisible,
|
||||
toggleBottomPanel,
|
||||
bottomPanelTabs,
|
||||
|
||||
@@ -44,6 +44,7 @@ export const useSidebarTabStore = defineStore('sidebarTab', () => {
|
||||
label: labelFunction,
|
||||
tooltip: tooltipFunction,
|
||||
versionAdded: '1.3.9',
|
||||
category: 'view-controls' as const,
|
||||
function: () => {
|
||||
toggleSidebarTab(tab.id)
|
||||
},
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface BaseSidebarTabExtension {
|
||||
export interface BaseBottomPanelExtension {
|
||||
id: string
|
||||
title: string
|
||||
targetPanel?: 'terminal' | 'shortcuts'
|
||||
}
|
||||
|
||||
export interface VueExtension {
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import EssentialsPanel from '@/components/bottomPanel/tabs/shortcuts/EssentialsPanel.vue'
|
||||
import ShortcutsList from '@/components/bottomPanel/tabs/shortcuts/ShortcutsList.vue'
|
||||
import type { ComfyCommandImpl } from '@/stores/commandStore'
|
||||
|
||||
// Mock ShortcutsList component
|
||||
vi.mock('@/components/bottomPanel/tabs/shortcuts/ShortcutsList.vue', () => ({
|
||||
default: {
|
||||
name: 'ShortcutsList',
|
||||
props: ['commands', 'subcategories', 'columns'],
|
||||
template:
|
||||
'<div class="shortcuts-list-mock">{{ commands.length }} commands</div>'
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock command store
|
||||
const mockCommands: ComfyCommandImpl[] = [
|
||||
{
|
||||
id: 'Workflow.New',
|
||||
label: 'New Workflow',
|
||||
category: 'essentials'
|
||||
} as ComfyCommandImpl,
|
||||
{
|
||||
id: 'Node.Add',
|
||||
label: 'Add Node',
|
||||
category: 'essentials'
|
||||
} as ComfyCommandImpl,
|
||||
{
|
||||
id: 'Queue.Clear',
|
||||
label: 'Clear Queue',
|
||||
category: 'essentials'
|
||||
} as ComfyCommandImpl,
|
||||
{
|
||||
id: 'Other.Command',
|
||||
label: 'Other Command',
|
||||
category: 'view-controls',
|
||||
function: vi.fn(),
|
||||
icon: 'pi pi-test',
|
||||
tooltip: 'Test tooltip',
|
||||
menubarLabel: 'Other Command',
|
||||
keybinding: null
|
||||
} as ComfyCommandImpl
|
||||
]
|
||||
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => ({
|
||||
commands: mockCommands
|
||||
})
|
||||
}))
|
||||
|
||||
describe('EssentialsPanel', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('should render ShortcutsList with essentials commands', () => {
|
||||
const wrapper = mount(EssentialsPanel)
|
||||
|
||||
const shortcutsList = wrapper.findComponent(ShortcutsList)
|
||||
expect(shortcutsList.exists()).toBe(true)
|
||||
|
||||
// Should pass only essentials commands
|
||||
const commands = shortcutsList.props('commands')
|
||||
expect(commands).toHaveLength(3)
|
||||
commands.forEach((cmd: ComfyCommandImpl) => {
|
||||
expect(cmd.category).toBe('essentials')
|
||||
})
|
||||
})
|
||||
|
||||
it('should categorize commands into subcategories', () => {
|
||||
const wrapper = mount(EssentialsPanel)
|
||||
|
||||
const shortcutsList = wrapper.findComponent(ShortcutsList)
|
||||
const subcategories = shortcutsList.props('subcategories')
|
||||
|
||||
expect(subcategories).toHaveProperty('workflow')
|
||||
expect(subcategories).toHaveProperty('node')
|
||||
expect(subcategories).toHaveProperty('queue')
|
||||
|
||||
expect(subcategories.workflow).toContain(mockCommands[0])
|
||||
expect(subcategories.node).toContain(mockCommands[1])
|
||||
expect(subcategories.queue).toContain(mockCommands[2])
|
||||
})
|
||||
})
|
||||
165
tests-ui/tests/components/bottomPanel/ShortcutsList.spec.ts
Normal file
165
tests-ui/tests/components/bottomPanel/ShortcutsList.spec.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import ShortcutsList from '@/components/bottomPanel/tabs/shortcuts/ShortcutsList.vue'
|
||||
import type { ComfyCommandImpl } from '@/stores/commandStore'
|
||||
|
||||
// Mock vue-i18n
|
||||
const mockT = vi.fn((key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
'shortcuts.subcategories.workflow': 'Workflow',
|
||||
'shortcuts.subcategories.node': 'Node',
|
||||
'shortcuts.subcategories.queue': 'Queue',
|
||||
'shortcuts.subcategories.view': 'View',
|
||||
'shortcuts.subcategories.panelControls': 'Panel Controls'
|
||||
}
|
||||
return translations[key] || key
|
||||
})
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: mockT
|
||||
})
|
||||
}))
|
||||
|
||||
describe('ShortcutsList', () => {
|
||||
const mockCommands: ComfyCommandImpl[] = [
|
||||
{
|
||||
id: 'Workflow.New',
|
||||
label: 'New Workflow',
|
||||
category: 'essentials',
|
||||
keybinding: {
|
||||
combo: {
|
||||
getKeySequences: () => ['Control', 'n']
|
||||
}
|
||||
}
|
||||
} as ComfyCommandImpl,
|
||||
{
|
||||
id: 'Node.Add',
|
||||
label: 'Add Node',
|
||||
category: 'essentials',
|
||||
keybinding: {
|
||||
combo: {
|
||||
getKeySequences: () => ['Shift', 'a']
|
||||
}
|
||||
}
|
||||
} as ComfyCommandImpl,
|
||||
{
|
||||
id: 'Queue.Clear',
|
||||
label: 'Clear Queue',
|
||||
category: 'essentials',
|
||||
keybinding: {
|
||||
combo: {
|
||||
getKeySequences: () => ['Control', 'Shift', 'c']
|
||||
}
|
||||
}
|
||||
} as ComfyCommandImpl
|
||||
]
|
||||
|
||||
const mockSubcategories = {
|
||||
workflow: [mockCommands[0]],
|
||||
node: [mockCommands[1]],
|
||||
queue: [mockCommands[2]]
|
||||
}
|
||||
|
||||
it('should render shortcuts organized by subcategories', () => {
|
||||
const wrapper = mount(ShortcutsList, {
|
||||
props: {
|
||||
commands: mockCommands,
|
||||
subcategories: mockSubcategories
|
||||
}
|
||||
})
|
||||
|
||||
// Check that subcategories are rendered
|
||||
expect(wrapper.text()).toContain('Workflow')
|
||||
expect(wrapper.text()).toContain('Node')
|
||||
expect(wrapper.text()).toContain('Queue')
|
||||
|
||||
// Check that commands are rendered
|
||||
expect(wrapper.text()).toContain('New Workflow')
|
||||
expect(wrapper.text()).toContain('Add Node')
|
||||
expect(wrapper.text()).toContain('Clear Queue')
|
||||
})
|
||||
|
||||
it('should format keyboard shortcuts correctly', () => {
|
||||
const wrapper = mount(ShortcutsList, {
|
||||
props: {
|
||||
commands: mockCommands,
|
||||
subcategories: mockSubcategories
|
||||
}
|
||||
})
|
||||
|
||||
// Check for formatted keys
|
||||
expect(wrapper.text()).toContain('Ctrl')
|
||||
expect(wrapper.text()).toContain('n')
|
||||
expect(wrapper.text()).toContain('Shift')
|
||||
expect(wrapper.text()).toContain('a')
|
||||
expect(wrapper.text()).toContain('c')
|
||||
})
|
||||
|
||||
it('should filter out commands without keybindings', () => {
|
||||
const commandsWithoutKeybinding: ComfyCommandImpl[] = [
|
||||
...mockCommands,
|
||||
{
|
||||
id: 'No.Keybinding',
|
||||
label: 'No Keybinding',
|
||||
category: 'essentials',
|
||||
keybinding: null
|
||||
} as ComfyCommandImpl
|
||||
]
|
||||
|
||||
const wrapper = mount(ShortcutsList, {
|
||||
props: {
|
||||
commands: commandsWithoutKeybinding,
|
||||
subcategories: {
|
||||
...mockSubcategories,
|
||||
other: [commandsWithoutKeybinding[3]]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.text()).not.toContain('No Keybinding')
|
||||
})
|
||||
|
||||
it('should handle special key formatting', () => {
|
||||
const specialKeyCommand: ComfyCommandImpl = {
|
||||
id: 'Special.Keys',
|
||||
label: 'Special Keys',
|
||||
category: 'essentials',
|
||||
keybinding: {
|
||||
combo: {
|
||||
getKeySequences: () => ['Meta', 'ArrowUp', 'Enter', 'Escape', ' ']
|
||||
}
|
||||
}
|
||||
} as ComfyCommandImpl
|
||||
|
||||
const wrapper = mount(ShortcutsList, {
|
||||
props: {
|
||||
commands: [specialKeyCommand],
|
||||
subcategories: {
|
||||
special: [specialKeyCommand]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const text = wrapper.text()
|
||||
expect(text).toContain('Cmd') // Meta -> Cmd
|
||||
expect(text).toContain('↑') // ArrowUp -> ↑
|
||||
expect(text).toContain('↵') // Enter -> ↵
|
||||
expect(text).toContain('Esc') // Escape -> Esc
|
||||
expect(text).toContain('Space') // ' ' -> Space
|
||||
})
|
||||
|
||||
it('should use fallback subcategory titles', () => {
|
||||
const wrapper = mount(ShortcutsList, {
|
||||
props: {
|
||||
commands: mockCommands,
|
||||
subcategories: {
|
||||
unknown: [mockCommands[0]]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('unknown')
|
||||
})
|
||||
})
|
||||
166
tests-ui/tests/store/bottomPanelStore.test.ts
Normal file
166
tests-ui/tests/store/bottomPanelStore.test.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||
import type { BottomPanelExtension } from '@/types/extensionTypes'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/composables/bottomPanelTabs/useShortcutsTab', () => ({
|
||||
useShortcutsTab: () => [
|
||||
{
|
||||
id: 'shortcuts-essentials',
|
||||
title: 'Essentials',
|
||||
component: {},
|
||||
type: 'vue',
|
||||
targetPanel: 'shortcuts'
|
||||
},
|
||||
{
|
||||
id: 'shortcuts-view-controls',
|
||||
title: 'View Controls',
|
||||
component: {},
|
||||
type: 'vue',
|
||||
targetPanel: 'shortcuts'
|
||||
}
|
||||
]
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/bottomPanelTabs/useTerminalTabs', () => ({
|
||||
useLogsTerminalTab: () => ({
|
||||
id: 'logs',
|
||||
title: 'Logs',
|
||||
component: {},
|
||||
type: 'vue',
|
||||
targetPanel: 'terminal'
|
||||
}),
|
||||
useCommandTerminalTab: () => ({
|
||||
id: 'command',
|
||||
title: 'Command',
|
||||
component: {},
|
||||
type: 'vue',
|
||||
targetPanel: 'terminal'
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => ({
|
||||
registerCommand: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/envUtil', () => ({
|
||||
isElectron: () => false
|
||||
}))
|
||||
|
||||
describe('useBottomPanelStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('should initialize with empty panels', () => {
|
||||
const store = useBottomPanelStore()
|
||||
|
||||
expect(store.activePanel).toBeNull()
|
||||
expect(store.bottomPanelVisible).toBe(false)
|
||||
expect(store.bottomPanelTabs).toEqual([])
|
||||
expect(store.activeBottomPanelTab).toBeNull()
|
||||
})
|
||||
|
||||
it('should register bottom panel tabs', () => {
|
||||
const store = useBottomPanelStore()
|
||||
const tab: BottomPanelExtension = {
|
||||
id: 'test-tab',
|
||||
title: 'Test Tab',
|
||||
component: {},
|
||||
type: 'vue',
|
||||
targetPanel: 'terminal'
|
||||
}
|
||||
|
||||
store.registerBottomPanelTab(tab)
|
||||
|
||||
expect(store.panels.terminal.tabs.find((t) => t.id === 'test-tab')).toEqual(
|
||||
tab
|
||||
)
|
||||
expect(store.panels.terminal.activeTabId).toBe('test-tab')
|
||||
})
|
||||
|
||||
it('should toggle panel visibility', () => {
|
||||
const store = useBottomPanelStore()
|
||||
const tab: BottomPanelExtension = {
|
||||
id: 'test-tab',
|
||||
title: 'Test Tab',
|
||||
component: {},
|
||||
type: 'vue',
|
||||
targetPanel: 'shortcuts'
|
||||
}
|
||||
|
||||
store.registerBottomPanelTab(tab)
|
||||
|
||||
// Panel should be hidden initially
|
||||
expect(store.activePanel).toBeNull()
|
||||
|
||||
// Toggle should show panel
|
||||
store.togglePanel('shortcuts')
|
||||
expect(store.activePanel).toBe('shortcuts')
|
||||
expect(store.bottomPanelVisible).toBe(true)
|
||||
|
||||
// Toggle again should hide panel
|
||||
store.togglePanel('shortcuts')
|
||||
expect(store.activePanel).toBeNull()
|
||||
expect(store.bottomPanelVisible).toBe(false)
|
||||
})
|
||||
|
||||
it('should switch between panel types', () => {
|
||||
const store = useBottomPanelStore()
|
||||
|
||||
const terminalTab: BottomPanelExtension = {
|
||||
id: 'terminal-tab',
|
||||
title: 'Terminal',
|
||||
component: {},
|
||||
type: 'vue',
|
||||
targetPanel: 'terminal'
|
||||
}
|
||||
|
||||
const shortcutsTab: BottomPanelExtension = {
|
||||
id: 'shortcuts-tab',
|
||||
title: 'Shortcuts',
|
||||
component: {},
|
||||
type: 'vue',
|
||||
targetPanel: 'shortcuts'
|
||||
}
|
||||
|
||||
store.registerBottomPanelTab(terminalTab)
|
||||
store.registerBottomPanelTab(shortcutsTab)
|
||||
|
||||
// Show terminal panel
|
||||
store.togglePanel('terminal')
|
||||
expect(store.activePanel).toBe('terminal')
|
||||
expect(store.activeBottomPanelTab?.id).toBe('terminal-tab')
|
||||
|
||||
// Switch to shortcuts panel
|
||||
store.togglePanel('shortcuts')
|
||||
expect(store.activePanel).toBe('shortcuts')
|
||||
expect(store.activeBottomPanelTab?.id).toBe('shortcuts-tab')
|
||||
})
|
||||
|
||||
it('should toggle specific tabs', () => {
|
||||
const store = useBottomPanelStore()
|
||||
const tab: BottomPanelExtension = {
|
||||
id: 'specific-tab',
|
||||
title: 'Specific Tab',
|
||||
component: {},
|
||||
type: 'vue',
|
||||
targetPanel: 'shortcuts'
|
||||
}
|
||||
|
||||
store.registerBottomPanelTab(tab)
|
||||
|
||||
// Toggle specific tab should show it
|
||||
store.toggleBottomPanelTab('specific-tab')
|
||||
expect(store.activePanel).toBe('shortcuts')
|
||||
expect(store.panels.shortcuts.activeTabId).toBe('specific-tab')
|
||||
|
||||
// Toggle same tab again should hide panel
|
||||
store.toggleBottomPanelTab('specific-tab')
|
||||
expect(store.activePanel).toBeNull()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user