mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-22 21:38:52 +00:00
Compare commits
4 Commits
coderabbit
...
glary/esca
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7cfaeb33f8 | ||
|
|
1e381cccf8 | ||
|
|
16dc701f4c | ||
|
|
71092b2011 |
@@ -26,6 +26,10 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
/* Disable trackpad two-finger horizontal swipe back/forward navigation
|
||||
and other overscroll gestures. ComfyUI is a full-screen editor; the
|
||||
browser's overscroll behaviors only ever leave or break the workflow. */
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
body {
|
||||
display: grid;
|
||||
|
||||
20
src/base/wheelGestures.ts
Normal file
20
src/base/wheelGestures.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Wheel events whose browser default would break the editing experience.
|
||||
* On macOS trackpads:
|
||||
* - `ctrl/meta + wheel` (pinch-zoom) triggers page-level zoom, which
|
||||
* pushes fixed-position UI (e.g. ComfyActionbar) off-screen with no
|
||||
* recovery short of a page reload.
|
||||
* - Horizontal-dominant wheel (two-finger horizontal swipe) triggers
|
||||
* back/forward navigation, which leaves the workflow.
|
||||
*
|
||||
* Equal `|deltaX| == |deltaY|` (including idle 0/0 frames between meaningful
|
||||
* trackpad samples) intentionally falls on the false branch so native
|
||||
* vertical scroll wins on a tie.
|
||||
*
|
||||
* Components that intercept wheel events should suppress the default for
|
||||
* these gestures even when they otherwise let the browser scroll natively.
|
||||
*/
|
||||
export const isCanvasGestureWheel = (event: WheelEvent): boolean =>
|
||||
event.ctrlKey ||
|
||||
event.metaKey ||
|
||||
Math.abs(event.deltaX) > Math.abs(event.deltaY)
|
||||
@@ -152,6 +152,7 @@ import { useGlobalLitegraph } from '@/composables/useGlobalLitegraph'
|
||||
import { usePaste } from '@/composables/usePaste'
|
||||
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { useLiteGraphContextMenuTracking } from '@/platform/keybindings/raisedSurfaceLiteGraphBridge'
|
||||
import { useLitegraphSettings } from '@/platform/settings/composables/useLitegraphSettings'
|
||||
import { CORE_SETTINGS } from '@/platform/settings/constants/coreSettings'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
@@ -460,6 +461,7 @@ useNodeBadge()
|
||||
|
||||
useGlobalLitegraph()
|
||||
useContextMenuTranslation()
|
||||
useLiteGraphContextMenuTracking()
|
||||
useCopy()
|
||||
usePaste()
|
||||
useWorkflowAutoSave()
|
||||
|
||||
@@ -52,6 +52,7 @@ import type {
|
||||
MenuOption,
|
||||
SubMenuOption
|
||||
} from '@/composables/graph/useMoreOptionsMenu'
|
||||
import { useRaisedSurface } from '@/platform/keybindings/raisedSurfaceStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
|
||||
import ColorPickerMenu from './selectionToolbox/ColorPickerMenu.vue'
|
||||
@@ -69,6 +70,8 @@ const isOpen = ref(false)
|
||||
const { menuOptions, bump } = useMoreOptionsMenu()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
useRaisedSurface('context-menu', isOpen)
|
||||
|
||||
// World position (canvas coordinates) where menu was opened
|
||||
const worldPosition = ref({ x: 0, y: 0 })
|
||||
|
||||
|
||||
52
src/lib/litegraph/src/ContextMenu.test.ts
Normal file
52
src/lib/litegraph/src/ContextMenu.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { ContextMenu } from '@/lib/litegraph/src/ContextMenu'
|
||||
|
||||
describe('ContextMenu lifecycle events', () => {
|
||||
let listener: (event: Event) => void
|
||||
let calls: Event[]
|
||||
let menus: ContextMenu[]
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
calls = []
|
||||
menus = []
|
||||
listener = (event) => calls.push(event)
|
||||
document.addEventListener('litegraph:contextmenu', listener)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const menu of menus) menu.close()
|
||||
document.removeEventListener('litegraph:contextmenu', listener)
|
||||
})
|
||||
|
||||
it('dispatches an "open" event when a top-level menu is constructed', () => {
|
||||
menus.push(new ContextMenu(['Item A', 'Item B'], {}))
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
const detail = (calls[0] as CustomEvent).detail
|
||||
expect(detail.type).toBe('open')
|
||||
})
|
||||
|
||||
it('dispatches a "close" event when a top-level menu closes', () => {
|
||||
const menu = new ContextMenu(['Item A'], {})
|
||||
calls.length = 0
|
||||
|
||||
menu.close()
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
const detail = (calls[0] as CustomEvent).detail
|
||||
expect(detail.type).toBe('close')
|
||||
expect(detail.menu).toBe(menu)
|
||||
})
|
||||
|
||||
it('does not dispatch lifecycle events for submenus', () => {
|
||||
const parent = new ContextMenu(['Item A'], {})
|
||||
menus.push(parent)
|
||||
calls.length = 0
|
||||
|
||||
new ContextMenu(['Sub Item'], { parentMenu: parent })
|
||||
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -56,6 +56,7 @@ export class ContextMenu<TValue = unknown> {
|
||||
root: ContextMenuDivElement<TValue>
|
||||
current_submenu?: ContextMenu<TValue>
|
||||
lock?: boolean
|
||||
ownerDocument: Document
|
||||
|
||||
controller: AbortController = new AbortController()
|
||||
|
||||
@@ -105,7 +106,13 @@ export class ContextMenu<TValue = unknown> {
|
||||
options.event = undefined
|
||||
}
|
||||
|
||||
const root: ContextMenuDivElement<TValue> = document.createElement('div')
|
||||
const ownerDocument =
|
||||
(options.event?.target as Node | null | undefined)?.ownerDocument ??
|
||||
document
|
||||
this.ownerDocument = ownerDocument
|
||||
|
||||
const root: ContextMenuDivElement<TValue> =
|
||||
ownerDocument.createElement('div')
|
||||
let classes = 'litegraph litecontextmenu litemenubar-panel'
|
||||
if (options.className) classes += ` ${options.className}`
|
||||
root.className = classes
|
||||
@@ -117,7 +124,7 @@ export class ContextMenu<TValue = unknown> {
|
||||
const eventOptions = { capture: true, signal }
|
||||
|
||||
if (!this.parentMenu) {
|
||||
document.addEventListener(
|
||||
ownerDocument.addEventListener(
|
||||
'pointerdown',
|
||||
(e) => {
|
||||
if (e.target instanceof Node && !this.containsNode(e.target)) {
|
||||
@@ -126,6 +133,16 @@ export class ContextMenu<TValue = unknown> {
|
||||
},
|
||||
eventOptions
|
||||
)
|
||||
ownerDocument.addEventListener(
|
||||
'keydown',
|
||||
(e) => {
|
||||
if (e.key !== 'Escape' || e.ctrlKey || e.altKey || e.metaKey) return
|
||||
this.close()
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
},
|
||||
eventOptions
|
||||
)
|
||||
}
|
||||
|
||||
// this prevents the default context browser menu to open in case this menu was created when pressing right button
|
||||
@@ -179,13 +196,9 @@ export class ContextMenu<TValue = unknown> {
|
||||
}
|
||||
|
||||
// insert before checking position
|
||||
const ownerDocument = (options.event?.target as Node | null | undefined)
|
||||
?.ownerDocument
|
||||
const root_document = ownerDocument || document
|
||||
|
||||
if (root_document.fullscreenElement)
|
||||
root_document.fullscreenElement.append(root)
|
||||
else root_document.body.append(root)
|
||||
if (ownerDocument.fullscreenElement)
|
||||
ownerDocument.fullscreenElement.append(root)
|
||||
else ownerDocument.body.append(root)
|
||||
|
||||
// compute best position
|
||||
let left = options.left || 0
|
||||
@@ -200,7 +213,7 @@ export class ContextMenu<TValue = unknown> {
|
||||
left = rect.left + rect.width
|
||||
}
|
||||
|
||||
const body_rect = document.body.getBoundingClientRect()
|
||||
const body_rect = ownerDocument.body.getBoundingClientRect()
|
||||
const root_rect = root.getBoundingClientRect()
|
||||
if (body_rect.height == 0)
|
||||
console.error(
|
||||
@@ -219,6 +232,14 @@ export class ContextMenu<TValue = unknown> {
|
||||
if (LiteGraph.context_menu_scaling && options.scale) {
|
||||
root.style.transform = `scale(${Math.round(options.scale * 4) * 0.25})`
|
||||
}
|
||||
|
||||
if (!parent) {
|
||||
ownerDocument.dispatchEvent(
|
||||
new CustomEvent('litegraph:contextmenu', {
|
||||
detail: { type: 'open', menu: this }
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -402,6 +423,13 @@ export class ContextMenu<TValue = unknown> {
|
||||
}
|
||||
}
|
||||
this.current_submenu?.close(e, true)
|
||||
if (!this.parentMenu) {
|
||||
this.ownerDocument.dispatchEvent(
|
||||
new CustomEvent('litegraph:contextmenu', {
|
||||
detail: { type: 'close', menu: this }
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** @deprecated Likely unused, however code search was inconclusive (too many results to check by hand). */
|
||||
|
||||
@@ -7,6 +7,7 @@ import { KeyComboImpl } from '@/platform/keybindings/keyCombo'
|
||||
import { KeybindingImpl } from '@/platform/keybindings/keybinding'
|
||||
import { useKeybindingService } from '@/platform/keybindings/keybindingService'
|
||||
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
|
||||
import { useRaisedSurfaceStore } from '@/platform/keybindings/raisedSurfaceStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import type { DialogInstance } from '@/stores/dialogStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
@@ -108,6 +109,31 @@ describe('keybindingService - Escape key handling', () => {
|
||||
expect(mockCommandExecute).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should NOT execute Escape keybinding when a raised surface is open', async () => {
|
||||
const raisedSurfaceStore = useRaisedSurfaceStore()
|
||||
raisedSurfaceStore.open('context-menu')
|
||||
|
||||
keybindingService = useKeybindingService()
|
||||
|
||||
const event = createKeyboardEvent('Escape')
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
expect(mockCommandExecute).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should resume executing Escape keybinding after a raised surface closes', async () => {
|
||||
const raisedSurfaceStore = useRaisedSurfaceStore()
|
||||
const id = raisedSurfaceStore.open('context-menu')
|
||||
raisedSurfaceStore.close(id)
|
||||
|
||||
keybindingService = useKeybindingService()
|
||||
|
||||
const event = createKeyboardEvent('Escape')
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
expect(mockCommandExecute).toHaveBeenCalledWith('Comfy.Graph.ExitSubgraph')
|
||||
})
|
||||
|
||||
it('should execute Escape keybinding with modifiers regardless of dialog state', async () => {
|
||||
const dialogStore = useDialogStore()
|
||||
dialogStore.dialogStack.push(createTestDialogInstance('test-dialog'))
|
||||
|
||||
@@ -7,12 +7,14 @@ import { CORE_KEYBINDINGS } from './defaults'
|
||||
import { KeyComboImpl } from './keyCombo'
|
||||
import { KeybindingImpl } from './keybinding'
|
||||
import { useKeybindingStore } from './keybindingStore'
|
||||
import { useRaisedSurfaceStore } from './raisedSurfaceStore'
|
||||
|
||||
export function useKeybindingService() {
|
||||
const keybindingStore = useKeybindingStore()
|
||||
const commandStore = useCommandStore()
|
||||
const settingStore = useSettingStore()
|
||||
const dialogStore = useDialogStore()
|
||||
const raisedSurfaceStore = useRaisedSurfaceStore()
|
||||
|
||||
async function keybindHandler(event: KeyboardEvent) {
|
||||
const keyCombo = KeyComboImpl.fromEvent(event)
|
||||
@@ -50,7 +52,10 @@ export function useKeybindingService() {
|
||||
!event.altKey &&
|
||||
!event.metaKey
|
||||
) {
|
||||
if (dialogStore.dialogStack.length > 0) {
|
||||
if (
|
||||
raisedSurfaceStore.isAnyOpen ||
|
||||
dialogStore.dialogStack.length > 0
|
||||
) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
34
src/platform/keybindings/raisedSurfaceLiteGraphBridge.ts
Normal file
34
src/platform/keybindings/raisedSurfaceLiteGraphBridge.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { onScopeDispose } from 'vue'
|
||||
|
||||
import { useRaisedSurfaceStore } from './raisedSurfaceStore'
|
||||
|
||||
interface LiteGraphContextMenuEventDetail {
|
||||
type: 'open' | 'close'
|
||||
menu: object
|
||||
}
|
||||
|
||||
export function useLiteGraphContextMenuTracking(): void {
|
||||
const store = useRaisedSurfaceStore()
|
||||
const idsByMenu = new Map<object, symbol>()
|
||||
|
||||
const handler = (event: Event) => {
|
||||
const detail = (event as CustomEvent<LiteGraphContextMenuEventDetail>)
|
||||
.detail
|
||||
if (detail.type === 'open') {
|
||||
idsByMenu.set(detail.menu, store.open('context-menu'))
|
||||
return
|
||||
}
|
||||
const id = idsByMenu.get(detail.menu)
|
||||
if (id !== undefined) {
|
||||
store.close(id)
|
||||
idsByMenu.delete(detail.menu)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('litegraph:contextmenu', handler)
|
||||
onScopeDispose(() => {
|
||||
document.removeEventListener('litegraph:contextmenu', handler)
|
||||
for (const id of idsByMenu.values()) store.close(id)
|
||||
idsByMenu.clear()
|
||||
})
|
||||
}
|
||||
89
src/platform/keybindings/raisedSurfaceStore.test.ts
Normal file
89
src/platform/keybindings/raisedSurfaceStore.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { effectScope, ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { useRaisedSurface, useRaisedSurfaceStore } from './raisedSurfaceStore'
|
||||
|
||||
describe('raisedSurfaceStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('is empty by default', () => {
|
||||
const store = useRaisedSurfaceStore()
|
||||
expect(store.isAnyOpen).toBe(false)
|
||||
expect(store.stack).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('tracks open/close and reports isAnyOpen', () => {
|
||||
const store = useRaisedSurfaceStore()
|
||||
const id = store.open('context-menu')
|
||||
expect(store.isAnyOpen).toBe(true)
|
||||
expect(store.stack).toHaveLength(1)
|
||||
store.close(id)
|
||||
expect(store.isAnyOpen).toBe(false)
|
||||
expect(store.stack).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('supports concurrent surfaces and closes by id (LIFO not required)', () => {
|
||||
const store = useRaisedSurfaceStore()
|
||||
const a = store.open('context-menu')
|
||||
const b = store.open('popover')
|
||||
expect(store.stack).toHaveLength(2)
|
||||
store.close(a)
|
||||
expect(store.stack).toHaveLength(1)
|
||||
expect(store.stack[0].kind).toBe('popover')
|
||||
store.close(b)
|
||||
expect(store.isAnyOpen).toBe(false)
|
||||
})
|
||||
|
||||
it('close is a no-op for unknown ids', () => {
|
||||
const store = useRaisedSurfaceStore()
|
||||
store.open('modal')
|
||||
store.close(Symbol('stale'))
|
||||
expect(store.stack).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useRaisedSurface', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('opens when reactive flag turns true and closes when it turns false', () => {
|
||||
const store = useRaisedSurfaceStore()
|
||||
const isOpen = ref(false)
|
||||
const scope = effectScope()
|
||||
scope.run(() => useRaisedSurface('context-menu', isOpen))
|
||||
|
||||
expect(store.isAnyOpen).toBe(false)
|
||||
isOpen.value = true
|
||||
expect(store.isAnyOpen).toBe(true)
|
||||
isOpen.value = false
|
||||
expect(store.isAnyOpen).toBe(false)
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('does not double-register when flag is toggled true twice', () => {
|
||||
const store = useRaisedSurfaceStore()
|
||||
const isOpen = ref(false)
|
||||
const scope = effectScope()
|
||||
scope.run(() => useRaisedSurface('context-menu', isOpen))
|
||||
|
||||
isOpen.value = true
|
||||
isOpen.value = true
|
||||
expect(store.stack).toHaveLength(1)
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('releases the surface when the owning scope is disposed', () => {
|
||||
const store = useRaisedSurfaceStore()
|
||||
const isOpen = ref(true)
|
||||
const scope = effectScope()
|
||||
scope.run(() => useRaisedSurface('context-menu', isOpen))
|
||||
|
||||
expect(store.isAnyOpen).toBe(true)
|
||||
scope.stop()
|
||||
expect(store.isAnyOpen).toBe(false)
|
||||
})
|
||||
})
|
||||
72
src/platform/keybindings/raisedSurfaceStore.ts
Normal file
72
src/platform/keybindings/raisedSurfaceStore.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, onScopeDispose, ref, toValue, watch } from 'vue'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
|
||||
/**
|
||||
* Tracks open "raised surfaces" — popovers, context menus, and top-level
|
||||
* modals — that should suppress global keybindings while they are open.
|
||||
*
|
||||
* UX axiom: standard keybindings work in every context EXCEPT when a raised
|
||||
* surface is open. Consumers register/unregister surfaces here; the keybinding
|
||||
* service consults {@link isAnyOpen} as its single source of truth.
|
||||
*/
|
||||
type RaisedSurfaceKind = 'context-menu' | 'popover' | 'modal'
|
||||
|
||||
interface RaisedSurfaceEntry {
|
||||
id: symbol
|
||||
kind: RaisedSurfaceKind
|
||||
}
|
||||
|
||||
export const useRaisedSurfaceStore = defineStore('raisedSurface', () => {
|
||||
const stack = ref<RaisedSurfaceEntry[]>([])
|
||||
const isAnyOpen = computed(() => stack.value.length > 0)
|
||||
|
||||
function open(kind: RaisedSurfaceKind): symbol {
|
||||
const id = Symbol(kind)
|
||||
stack.value.push({ id, kind })
|
||||
return id
|
||||
}
|
||||
|
||||
function close(id: symbol): void {
|
||||
const index = stack.value.findIndex((entry) => entry.id === id)
|
||||
if (index !== -1) stack.value.splice(index, 1)
|
||||
}
|
||||
|
||||
return { stack, isAnyOpen, open, close }
|
||||
})
|
||||
|
||||
/**
|
||||
* Bind a surface's reactive open-state to the raised-surface registry.
|
||||
*
|
||||
* @example
|
||||
* const isOpen = ref(false)
|
||||
* useRaisedSurface('context-menu', isOpen)
|
||||
*/
|
||||
export function useRaisedSurface(
|
||||
kind: RaisedSurfaceKind,
|
||||
isOpen: MaybeRefOrGetter<boolean>
|
||||
): void {
|
||||
const store = useRaisedSurfaceStore()
|
||||
let id: symbol | null = null
|
||||
|
||||
function release() {
|
||||
if (id !== null) {
|
||||
store.close(id)
|
||||
id = null
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => toValue(isOpen),
|
||||
(open) => {
|
||||
if (open && id === null) {
|
||||
id = store.open(kind)
|
||||
} else if (!open) {
|
||||
release()
|
||||
}
|
||||
},
|
||||
{ immediate: true, flush: 'sync' }
|
||||
)
|
||||
|
||||
onScopeDispose(release)
|
||||
}
|
||||
@@ -46,10 +46,17 @@ function createMockPointerEvent(
|
||||
return mockEvent as PointerEvent
|
||||
}
|
||||
|
||||
function createMockWheelEvent(ctrlKey = false, metaKey = false): WheelEvent {
|
||||
function createMockWheelEvent(
|
||||
ctrlKey = false,
|
||||
metaKey = false,
|
||||
deltaX = 0,
|
||||
deltaY = 0
|
||||
): WheelEvent {
|
||||
const mockEvent: Partial<WheelEvent> = {
|
||||
ctrlKey,
|
||||
metaKey,
|
||||
deltaX,
|
||||
deltaY,
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn()
|
||||
}
|
||||
@@ -222,5 +229,107 @@ describe('useCanvasInteractions', () => {
|
||||
|
||||
document.body.removeChild(captureElement)
|
||||
})
|
||||
|
||||
/** Regression: trackpad pinch-zoom inside a focused textarea must not
|
||||
* fall through to browser page zoom in non-standard navigation modes. */
|
||||
it.for(['legacy', 'custom'])(
|
||||
'should forward ctrl+wheel to canvas when capture element IS focused in %s mode',
|
||||
(mode) => {
|
||||
const { get } = useSettingStore()
|
||||
vi.mocked(get).mockReturnValue(mode)
|
||||
|
||||
const captureElement = document.createElement('div')
|
||||
captureElement.setAttribute('data-capture-wheel', 'true')
|
||||
const textarea = document.createElement('textarea')
|
||||
captureElement.appendChild(textarea)
|
||||
document.body.appendChild(captureElement)
|
||||
textarea.focus()
|
||||
|
||||
const { handleWheel } = useCanvasInteractions()
|
||||
const mockEvent = createMockWheelEvent(true)
|
||||
Object.defineProperty(mockEvent, 'target', { value: textarea })
|
||||
|
||||
handleWheel(mockEvent)
|
||||
|
||||
expect(mockEvent.preventDefault).toHaveBeenCalled()
|
||||
expect(mockEvent.stopPropagation).toHaveBeenCalled()
|
||||
|
||||
document.body.removeChild(captureElement)
|
||||
}
|
||||
)
|
||||
|
||||
it('should forward meta+wheel to canvas when capture element IS focused', () => {
|
||||
const { get } = useSettingStore()
|
||||
vi.mocked(get).mockReturnValue('standard')
|
||||
|
||||
const captureElement = document.createElement('div')
|
||||
captureElement.setAttribute('data-capture-wheel', 'true')
|
||||
const textarea = document.createElement('textarea')
|
||||
captureElement.appendChild(textarea)
|
||||
document.body.appendChild(captureElement)
|
||||
textarea.focus()
|
||||
|
||||
const { handleWheel } = useCanvasInteractions()
|
||||
const mockEvent = createMockWheelEvent(false, true)
|
||||
Object.defineProperty(mockEvent, 'target', { value: textarea })
|
||||
|
||||
handleWheel(mockEvent)
|
||||
|
||||
expect(mockEvent.preventDefault).toHaveBeenCalled()
|
||||
expect(mockEvent.stopPropagation).toHaveBeenCalled()
|
||||
|
||||
document.body.removeChild(captureElement)
|
||||
})
|
||||
|
||||
/** Regression: trackpad two-finger horizontal swipes inside a focused
|
||||
* textarea must not fall through to browser back/forward navigation. */
|
||||
it.for(['standard', 'legacy', 'custom'])(
|
||||
'should forward horizontal-dominant wheel to canvas when capture element IS focused in %s mode',
|
||||
(mode) => {
|
||||
const { get } = useSettingStore()
|
||||
vi.mocked(get).mockReturnValue(mode)
|
||||
|
||||
const captureElement = document.createElement('div')
|
||||
captureElement.setAttribute('data-capture-wheel', 'true')
|
||||
const textarea = document.createElement('textarea')
|
||||
captureElement.appendChild(textarea)
|
||||
document.body.appendChild(captureElement)
|
||||
textarea.focus()
|
||||
|
||||
const { handleWheel } = useCanvasInteractions()
|
||||
const mockEvent = createMockWheelEvent(false, false, 30, 5)
|
||||
Object.defineProperty(mockEvent, 'target', { value: textarea })
|
||||
|
||||
handleWheel(mockEvent)
|
||||
|
||||
expect(mockEvent.preventDefault).toHaveBeenCalled()
|
||||
expect(mockEvent.stopPropagation).toHaveBeenCalled()
|
||||
|
||||
document.body.removeChild(captureElement)
|
||||
}
|
||||
)
|
||||
|
||||
it('should NOT forward vertical-dominant wheel when capture element IS focused', () => {
|
||||
const { get } = useSettingStore()
|
||||
vi.mocked(get).mockReturnValue('standard')
|
||||
|
||||
const captureElement = document.createElement('div')
|
||||
captureElement.setAttribute('data-capture-wheel', 'true')
|
||||
const textarea = document.createElement('textarea')
|
||||
captureElement.appendChild(textarea)
|
||||
document.body.appendChild(captureElement)
|
||||
textarea.focus()
|
||||
|
||||
const { handleWheel } = useCanvasInteractions()
|
||||
const mockEvent = createMockWheelEvent(false, false, 0, 30)
|
||||
Object.defineProperty(mockEvent, 'target', { value: textarea })
|
||||
|
||||
handleWheel(mockEvent)
|
||||
|
||||
expect(mockEvent.preventDefault).not.toHaveBeenCalled()
|
||||
expect(mockEvent.stopPropagation).not.toHaveBeenCalled()
|
||||
|
||||
document.body.removeChild(captureElement)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { isMiddlePointerInput } from '@/base/pointerUtils'
|
||||
import { isCanvasGestureWheel } from '@/base/wheelGestures'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { app } from '@/scripts/app'
|
||||
@@ -41,30 +42,34 @@ export function useCanvasInteractions() {
|
||||
return !!(captureElement && active && captureElement.contains(active))
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward to canvas when the event is not consumed by a focused widget,
|
||||
* or when it is a canvas gesture (which must override widget consumption
|
||||
* to prevent destructive browser defaults).
|
||||
*/
|
||||
const shouldForwardWheelEvent = (event: WheelEvent): boolean =>
|
||||
!wheelCapturedByFocusedElement(event) ||
|
||||
(isStandardNavMode.value && (event.ctrlKey || event.metaKey))
|
||||
!wheelCapturedByFocusedElement(event) || isCanvasGestureWheel(event)
|
||||
|
||||
/**
|
||||
* Handles wheel events from UI components that should be forwarded to canvas
|
||||
* when appropriate (e.g., Ctrl+wheel for zoom in standard mode)
|
||||
* when appropriate (e.g., Ctrl+wheel for zoom, two-finger pan in standard
|
||||
* mode; all wheel events in legacy mode).
|
||||
*/
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
if (!shouldForwardWheelEvent(event)) return
|
||||
|
||||
// In standard mode, Ctrl+wheel should go to canvas for zoom
|
||||
if (isStandardNavMode.value && (event.ctrlKey || event.metaKey)) {
|
||||
forwardEventToCanvas(event)
|
||||
// In standard mode, only canvas gestures (zoom/pan) are forwarded;
|
||||
// vertical wheel falls through so the document/widget scrolls normally.
|
||||
// The re-check is intentional and NOT redundant with shouldForwardWheelEvent:
|
||||
// that function also returns true for unfocused vertical wheel (its
|
||||
// `!wheelCapturedByFocusedElement` branch), which here must stay native.
|
||||
if (isStandardNavMode.value) {
|
||||
if (isCanvasGestureWheel(event)) forwardEventToCanvas(event)
|
||||
return
|
||||
}
|
||||
|
||||
// In legacy mode, all wheel events go to canvas for zoom
|
||||
if (!isStandardNavMode.value) {
|
||||
forwardEventToCanvas(event)
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise, let the component handle it normally
|
||||
// In legacy mode, all forwardable wheel events go to canvas for zoom/pan.
|
||||
forwardEventToCanvas(event)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -94,14 +94,59 @@ describe('FormDropdownMenu', () => {
|
||||
})
|
||||
|
||||
it('has data-capture-wheel="true" on the root element', () => {
|
||||
const { container } = render(FormDropdownMenu, {
|
||||
render(FormDropdownMenu, {
|
||||
props: defaultProps,
|
||||
global: globalConfig
|
||||
})
|
||||
|
||||
expect(
|
||||
// eslint-disable-next-line testing-library/no-node-access
|
||||
container.firstElementChild!.getAttribute('data-capture-wheel')
|
||||
screen
|
||||
.getByTestId('form-dropdown-menu')
|
||||
.getAttribute('data-capture-wheel')
|
||||
).toBe('true')
|
||||
})
|
||||
|
||||
/** Regression: PrimeVue Popover teleports the menu to document.body, so
|
||||
* trackpad pinch-zoom and horizontal swipes must be guarded on the menu
|
||||
* itself rather than relying on the LGraphNode wheel handler. */
|
||||
it.for([
|
||||
{ name: 'pinch-zoom', overrides: { ctrlKey: true, deltaY: -10 } },
|
||||
{ name: 'horizontal swipe', overrides: { deltaX: 30, deltaY: 5 } }
|
||||
])('suppresses browser default for $name', ({ overrides }) => {
|
||||
render(FormDropdownMenu, {
|
||||
props: defaultProps,
|
||||
global: globalConfig
|
||||
})
|
||||
|
||||
const root = screen.getByTestId('form-dropdown-menu')
|
||||
const event = new WheelEvent('wheel', {
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
Object.entries(overrides).forEach(([key, value]) => {
|
||||
Object.defineProperty(event, key, { value })
|
||||
})
|
||||
root.dispatchEvent(event)
|
||||
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
})
|
||||
|
||||
/** Vertical scrolling must remain native so the dropdown's own scroll
|
||||
* container can scroll its content. */
|
||||
it('does not suppress vertical scroll', () => {
|
||||
render(FormDropdownMenu, {
|
||||
props: defaultProps,
|
||||
global: globalConfig
|
||||
})
|
||||
|
||||
const root = screen.getByTestId('form-dropdown-menu')
|
||||
const event = new WheelEvent('wheel', {
|
||||
deltaY: 30,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
root.dispatchEvent(event)
|
||||
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { CSSProperties } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import VirtualGrid from '@/components/common/VirtualGrid.vue'
|
||||
import { isCanvasGestureWheel } from '@/base/wheelGestures'
|
||||
|
||||
import type {
|
||||
FilterOption,
|
||||
@@ -93,12 +94,25 @@ const virtualItems = computed<VirtualDropdownItem[]>(() =>
|
||||
key: String(item.id)
|
||||
}))
|
||||
)
|
||||
|
||||
/**
|
||||
* The dropdown content is teleported to `document.body` by PrimeVue Popover,
|
||||
* detaching it from the LGraphNode subtree where the canvas wheel guard lives.
|
||||
* Suppress only the destructive browser defaults (page zoom on pinch and
|
||||
* back/forward on horizontal swipe); regular vertical scrolling still
|
||||
* scrolls the dropdown's own content.
|
||||
*/
|
||||
const onWheel = (event: WheelEvent) => {
|
||||
if (isCanvasGestureWheel(event)) event.preventDefault()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex h-[640px] w-103 flex-col rounded-lg bg-component-node-background pt-4 outline -outline-offset-1 outline-node-component-border"
|
||||
data-capture-wheel="true"
|
||||
data-testid="form-dropdown-menu"
|
||||
@wheel="onWheel"
|
||||
>
|
||||
<FormDropdownMenuFilter
|
||||
v-if="filterOptions.length > 0"
|
||||
|
||||
Reference in New Issue
Block a user