mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-22 21:38:52 +00:00
Compare commits
4 Commits
jaewon/m1-
...
fix/cmd-a-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d775af7e0 | ||
|
|
7d2aaebe57 | ||
|
|
05bfe05cfc | ||
|
|
efae49093b |
@@ -76,6 +76,7 @@ import SidebarShortcutsToggleButton from '@/components/sidebar/SidebarShortcutsT
|
||||
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { usePreventFocusLoss } from '@/composables/usePreventFocusLoss'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
|
||||
@@ -103,6 +104,8 @@ const userStore = useUserStore()
|
||||
const commandStore = useCommandStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const sideToolbarRef = ref<HTMLElement>()
|
||||
usePreventFocusLoss(sideToolbarRef)
|
||||
|
||||
const topToolbarRef = ref<HTMLElement>()
|
||||
const bottomToolbarRef = ref<HTMLElement>()
|
||||
|
||||
|
||||
@@ -118,6 +118,7 @@ import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
|
||||
import { usePreventFocusLoss } from '@/composables/usePreventFocusLoss'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { buildFeedbackTypeformUrl } from '@/platform/support/config'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
@@ -161,6 +162,8 @@ function openFeedback() {
|
||||
}
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
usePreventFocusLoss(containerRef, '.workflow-tab')
|
||||
|
||||
const showOverflowArrows = ref(false)
|
||||
const leftArrowEnabled = ref(false)
|
||||
const rightArrowEnabled = ref(false)
|
||||
|
||||
136
src/composables/usePreventFocusLoss.test.ts
Normal file
136
src/composables/usePreventFocusLoss.test.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { effectScope, ref } from 'vue'
|
||||
|
||||
import { usePreventFocusLoss } from './usePreventFocusLoss'
|
||||
|
||||
function setup(container: HTMLElement, excludeSelector?: string) {
|
||||
const scope = effectScope()
|
||||
const containerRef = ref<HTMLElement | null>(container)
|
||||
scope.run(() => usePreventFocusLoss(containerRef, excludeSelector))
|
||||
document.body.appendChild(container)
|
||||
return () => {
|
||||
scope.stop()
|
||||
container.remove()
|
||||
}
|
||||
}
|
||||
|
||||
function fireMousedown(el: Element): MouseEvent {
|
||||
const event = new MouseEvent('mousedown', { bubbles: true, cancelable: true })
|
||||
el.dispatchEvent(event)
|
||||
return event
|
||||
}
|
||||
|
||||
describe('usePreventFocusLoss', () => {
|
||||
let teardown: () => void
|
||||
|
||||
afterEach(() => teardown?.())
|
||||
|
||||
it('prevents default on mousedown for a plain div (stops focus theft)', () => {
|
||||
const container = document.createElement('div')
|
||||
const inner = document.createElement('div')
|
||||
container.appendChild(inner)
|
||||
teardown = setup(container)
|
||||
|
||||
const event = fireMousedown(inner)
|
||||
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
})
|
||||
|
||||
it('prevents default when clicking a button (click still fires, focus stays on canvas)', () => {
|
||||
const container = document.createElement('div')
|
||||
const btn = document.createElement('button')
|
||||
container.appendChild(btn)
|
||||
teardown = setup(container)
|
||||
|
||||
const event = fireMousedown(btn)
|
||||
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
})
|
||||
|
||||
it('does not prevent default when clicking an input', () => {
|
||||
const container = document.createElement('div')
|
||||
const input = document.createElement('input')
|
||||
container.appendChild(input)
|
||||
teardown = setup(container)
|
||||
|
||||
const event = fireMousedown(input)
|
||||
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
})
|
||||
|
||||
it('does not prevent default when clicking a textarea', () => {
|
||||
const container = document.createElement('div')
|
||||
const textarea = document.createElement('textarea')
|
||||
container.appendChild(textarea)
|
||||
teardown = setup(container)
|
||||
|
||||
const event = fireMousedown(textarea)
|
||||
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
})
|
||||
|
||||
it('does not prevent default when clicking a contenteditable element', () => {
|
||||
const container = document.createElement('div')
|
||||
const editable = document.createElement('div')
|
||||
editable.contentEditable = 'true'
|
||||
container.appendChild(editable)
|
||||
teardown = setup(container)
|
||||
|
||||
const event = fireMousedown(editable)
|
||||
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
})
|
||||
|
||||
it('does not prevent default when clicking a select', () => {
|
||||
const container = document.createElement('div')
|
||||
const select = document.createElement('select')
|
||||
container.appendChild(select)
|
||||
teardown = setup(container)
|
||||
|
||||
const event = fireMousedown(select)
|
||||
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
})
|
||||
|
||||
describe('excludeSelector', () => {
|
||||
it('does not prevent default when target matches the exclude selector', () => {
|
||||
const container = document.createElement('div')
|
||||
const tab = document.createElement('div')
|
||||
tab.className = 'workflow-tab'
|
||||
container.appendChild(tab)
|
||||
teardown = setup(container, '.workflow-tab')
|
||||
|
||||
const event = fireMousedown(tab)
|
||||
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
})
|
||||
|
||||
it('still prevents default for elements outside the exclude selector', () => {
|
||||
const container = document.createElement('div')
|
||||
const tab = document.createElement('div')
|
||||
tab.className = 'workflow-tab'
|
||||
const btn = document.createElement('button')
|
||||
container.appendChild(tab)
|
||||
container.appendChild(btn)
|
||||
teardown = setup(container, '.workflow-tab')
|
||||
|
||||
const event = fireMousedown(btn)
|
||||
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
})
|
||||
|
||||
it('does not prevent default when target is a descendant of an excluded element', () => {
|
||||
const container = document.createElement('div')
|
||||
const tab = document.createElement('div')
|
||||
tab.className = 'workflow-tab'
|
||||
const inner = document.createElement('span')
|
||||
tab.appendChild(inner)
|
||||
container.appendChild(tab)
|
||||
teardown = setup(container, '.workflow-tab')
|
||||
|
||||
const event = fireMousedown(inner)
|
||||
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
27
src/composables/usePreventFocusLoss.ts
Normal file
27
src/composables/usePreventFocusLoss.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
|
||||
const FOCUS_ACCEPTING_SELECTOR =
|
||||
'input, textarea, select, [contenteditable="true"]'
|
||||
|
||||
/**
|
||||
* Prevents non-interactive areas of a container from stealing keyboard focus
|
||||
* away from the canvas. Call this on "passive" UI regions (tab bar, sidebar
|
||||
* icon strip) so that canvas keybindings remain active after the user clicks
|
||||
* within those regions.
|
||||
*
|
||||
* Focus is still allowed to move when the user clicks a genuine text-entry
|
||||
* element (input, textarea, contenteditable).
|
||||
*/
|
||||
export function usePreventFocusLoss(
|
||||
el: Ref<HTMLElement | null | undefined>,
|
||||
excludeSelector?: string
|
||||
) {
|
||||
useEventListener(el, 'mousedown', (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement
|
||||
if (excludeSelector && target.closest(excludeSelector)) return
|
||||
if (!target.closest(FOCUS_ACCEPTING_SELECTOR)) {
|
||||
event.preventDefault()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -218,4 +218,121 @@ describe('keybindingService - Canvas Keybindings', () => {
|
||||
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
|
||||
outsideDiv.remove()
|
||||
})
|
||||
|
||||
it('should execute SelectAll for Cmd+A (metaKey) on canvas', async () => {
|
||||
const event = createTestKeyboardEvent('a', {
|
||||
metaKey: true,
|
||||
target: canvasChild
|
||||
})
|
||||
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
expect(vi.mocked(useCommandStore().execute)).toHaveBeenCalledWith(
|
||||
'Comfy.Canvas.SelectAll'
|
||||
)
|
||||
})
|
||||
|
||||
describe('Ctrl/Cmd+A outside canvas container', () => {
|
||||
it('prevents browser text selection when target is a non-input element (e.g. sidebar)', async () => {
|
||||
const sidebarDiv = document.createElement('div')
|
||||
document.body.appendChild(sidebarDiv)
|
||||
|
||||
const event = createTestKeyboardEvent('a', {
|
||||
ctrlKey: true,
|
||||
target: sidebarDiv
|
||||
})
|
||||
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
|
||||
expect(event.preventDefault).toHaveBeenCalled()
|
||||
sidebarDiv.remove()
|
||||
})
|
||||
|
||||
it('does not prevent default when target is an input element (allows native text selection)', async () => {
|
||||
const inputEl = document.createElement('input')
|
||||
document.body.appendChild(inputEl)
|
||||
|
||||
const event = createTestKeyboardEvent('a', {
|
||||
ctrlKey: true,
|
||||
target: inputEl
|
||||
})
|
||||
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
|
||||
expect(event.preventDefault).not.toHaveBeenCalled()
|
||||
inputEl.remove()
|
||||
})
|
||||
|
||||
it('does not prevent default when target is a select element (allows native selection)', async () => {
|
||||
const selectEl = document.createElement('select')
|
||||
document.body.appendChild(selectEl)
|
||||
|
||||
const event = createTestKeyboardEvent('a', {
|
||||
ctrlKey: true,
|
||||
target: selectEl
|
||||
})
|
||||
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
|
||||
expect(event.preventDefault).not.toHaveBeenCalled()
|
||||
selectEl.remove()
|
||||
})
|
||||
})
|
||||
|
||||
describe('canvas keybindings suppressed when an overlay is open', () => {
|
||||
it('does not execute SelectAll when a LiteGraph context menu is visible', async () => {
|
||||
const contextMenu = document.createElement('div')
|
||||
contextMenu.className = 'litecontextmenu'
|
||||
document.body.appendChild(contextMenu)
|
||||
|
||||
const event = createTestKeyboardEvent('a', {
|
||||
ctrlKey: true,
|
||||
target: canvasChild
|
||||
})
|
||||
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
|
||||
expect(event.preventDefault).toHaveBeenCalled()
|
||||
contextMenu.remove()
|
||||
})
|
||||
|
||||
it('does not execute SelectAll when a Reka UI popover is visible', async () => {
|
||||
const popover = document.createElement('div')
|
||||
popover.setAttribute('data-reka-popper-content-wrapper', '')
|
||||
document.body.appendChild(popover)
|
||||
|
||||
const event = createTestKeyboardEvent('a', {
|
||||
ctrlKey: true,
|
||||
target: canvasChild
|
||||
})
|
||||
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
|
||||
expect(event.preventDefault).toHaveBeenCalled()
|
||||
popover.remove()
|
||||
})
|
||||
|
||||
it('resumes executing canvas keybindings once the overlay is removed', async () => {
|
||||
const contextMenu = document.createElement('div')
|
||||
contextMenu.className = 'litecontextmenu'
|
||||
document.body.appendChild(contextMenu)
|
||||
contextMenu.remove()
|
||||
|
||||
const event = createTestKeyboardEvent('a', {
|
||||
ctrlKey: true,
|
||||
target: canvasChild
|
||||
})
|
||||
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
expect(vi.mocked(useCommandStore().execute)).toHaveBeenCalledWith(
|
||||
'Comfy.Canvas.SelectAll'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,6 +14,23 @@ export function useKeybindingService() {
|
||||
const settingStore = useSettingStore()
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
function isTextInputElement(el: HTMLElement): boolean {
|
||||
return (
|
||||
el.tagName === 'TEXTAREA' ||
|
||||
el.tagName === 'INPUT' ||
|
||||
el.tagName === 'SELECT' ||
|
||||
el.contentEditable === 'true' ||
|
||||
(el.tagName === 'SPAN' && el.classList.contains('property_value'))
|
||||
)
|
||||
}
|
||||
|
||||
function hasOpenOverlay(): boolean {
|
||||
return (
|
||||
!!document.querySelector('.litecontextmenu') ||
|
||||
!!document.querySelector('[data-reka-popper-content-wrapper]')
|
||||
)
|
||||
}
|
||||
|
||||
async function keybindHandler(event: KeyboardEvent) {
|
||||
const keyCombo = KeyComboImpl.fromEvent(event)
|
||||
if (keyCombo.isModifier) {
|
||||
@@ -21,14 +38,7 @@ export function useKeybindingService() {
|
||||
}
|
||||
|
||||
const target = event.composedPath()[0] as HTMLElement
|
||||
if (
|
||||
keyCombo.isReservedByTextInput &&
|
||||
(target.tagName === 'TEXTAREA' ||
|
||||
target.tagName === 'INPUT' ||
|
||||
target.contentEditable === 'true' ||
|
||||
(target.tagName === 'SPAN' &&
|
||||
target.classList.contains('property_value')))
|
||||
) {
|
||||
if (keyCombo.isReservedByTextInput && isTextInputElement(target)) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -41,6 +51,15 @@ export function useKeybindingService() {
|
||||
if (targetElementId) {
|
||||
const container = document.getElementById(targetElementId)
|
||||
if (!container?.contains(target)) {
|
||||
// Prevent browser default (e.g. text selection on Ctrl/Cmd+A) when
|
||||
// a matching keybinding exists but the target is outside the canvas.
|
||||
if (!isTextInputElement(target)) {
|
||||
event.preventDefault()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (hasOpenOverlay()) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user