Compare commits

...

4 Commits

Author SHA1 Message Date
Kelly Yang
9d775af7e0 Merge branch 'main' into fix/cmd-a-select-all-nodes 2026-05-11 15:09:37 -07:00
Kelly Yang
7d2aaebe57 test: add select element coverage for usePreventFocusLoss 2026-05-08 14:17:06 -07:00
Kelly Yang
05bfe05cfc fix: restore tab drag, suppress canvas keybindings when overlay is open
- Add excludeSelector param to usePreventFocusLoss so WorkflowTabs can
  exempt .workflow-tab elements from mousedown prevention, restoring
  Atlaskit native drag-to-reorder
- Add hasOpenOverlay() check in keybindHandler to silence canvas
  keybindings (e.g. Cmd+A SelectAll) while a LiteGraph context menu or
  Reka UI popover is visible
- Add SELECT to isTextInputElement to match FOCUS_ACCEPTING_SELECTOR,
  preventing accidental preventDefault on select elements outside canvas
2026-05-08 13:24:56 -07:00
Kelly Yang
efae49093b fix: Cmd+A selects all nodes from tab bar and sidebar; prevent text selection in popovers
- Add usePreventFocusLoss composable: on mousedown in passive UI regions, calls
  preventDefault() for non-text-input targets so keyboard focus stays on canvas
- Apply to WorkflowTabs and SideToolbar so clicking empty areas or tab buttons
  no longer steals focus from the canvas keybinding context
- In keybindingService, call preventDefault() when a canvas keybinding is
  matched but the target is outside the canvas and not a text input — suppresses
  browser text-select on Ctrl/Cmd+A in popovers and menus

Fixes #11284
2026-05-04 19:11:34 -07:00
6 changed files with 313 additions and 8 deletions

View File

@@ -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>()

View File

@@ -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)

View 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)
})
})
})

View 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()
}
})
}

View File

@@ -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'
)
})
})
})

View File

@@ -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
}
}