feat: add Input device setting for canvas navigation

This commit is contained in:
Rizumu Ayaka
2026-05-05 17:55:39 +08:00
parent f6ddd26cef
commit 7f8903e9be
15 changed files with 180 additions and 104 deletions

View File

@@ -68,19 +68,19 @@ function getFormAttrs(item: FormItem) {
}
switch (item.type) {
case 'combo':
case 'radio':
attrs['options'] =
case 'radio': {
const resolvedOptions =
typeof item.options === 'function'
? // @ts-expect-error: Audit and deprecate usage of legacy options type:
// (value) => [string | {text: string, value: string}]
item.options(formValue.value)
? item.options(formValue.value)
: item.options
attrs['options'] = resolvedOptions
if (typeof item.options?.[0] !== 'string') {
if (typeof resolvedOptions?.[0] !== 'string') {
attrs['optionLabel'] = 'text'
attrs['optionValue'] = 'value'
}
break
}
}
return attrs
}

View File

@@ -9,6 +9,7 @@ import Slider from '@/components/ui/slider/Slider.vue'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LinkRenderType } from '@/lib/litegraph/src/types/globalEnums'
import { LinkMarkerShape } from '@/lib/litegraph/src/types/globalEnums'
import { useInputDeviceDetection } from '@/platform/settings/composables/useInputDeviceDetection'
import { useSettingStore } from '@/platform/settings/settingStore'
import { WidgetInputBaseClass } from '@/renderer/extensions/vueNodes/widgets/components/layout'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
@@ -40,6 +41,33 @@ const nodes2Enabled = computed({
})
// CANVAS settings
const inputDevice = computed({
get: () => settingStore.get('Comfy.Graph.WheelInputMode'),
set: (value) => settingStore.set('Comfy.Graph.WheelInputMode', value)
})
const { detectedInputDevice } = useInputDeviceDetection()
const inputDeviceOptions = computed(() => [
{
value: 'auto',
label:
detectedInputDevice.value === 'trackpad'
? t(
'settings.Comfy_Graph_WheelInputMode.options.Auto-detect (Trackpad)'
)
: t('settings.Comfy_Graph_WheelInputMode.options.Auto-detect (Mouse)')
},
{
value: 'mouse',
label: t('settings.Comfy_Graph_WheelInputMode.options.Mouse')
},
{
value: 'trackpad',
label: t('settings.Comfy_Graph_WheelInputMode.options.Trackpad')
}
])
const gridSpacing = computed({
get: () => settingStore.get('Comfy.SnapToGrid.GridSize'),
set: (value) => settingStore.set('Comfy.SnapToGrid.GridSize', value)
@@ -128,6 +156,24 @@ function openFullSettings() {
{{ t('rightSidePanel.globalSettings.canvas') }}
</template>
<div class="space-y-4 px-4 py-3">
<LayoutField :label="t('rightSidePanel.globalSettings.inputDevice')">
<Select
v-model="inputDevice"
:options="inputDeviceOptions"
:aria-label="t('rightSidePanel.globalSettings.inputDevice')"
:class="cn(WidgetInputBaseClass, 'w-full text-xs')"
size="small"
:pt="{
option: 'text-xs',
dropdown: 'w-8',
label: cn('min-w-[4ch] truncate', $slots.default && 'mr-5'),
overlay: 'w-fit min-w-full'
}"
data-capture-wheel="true"
option-label="label"
option-value="value"
/>
</LayoutField>
<LayoutField :label="t('rightSidePanel.globalSettings.gridSpacing')">
<div
:class="

View File

@@ -96,6 +96,9 @@ export class CanvasPointer {
/** Currently detected input device type */
detectedDevice: 'mouse' | 'trackpad' = 'mouse'
/** Fired when {@link detectedDevice} flips between mouse and trackpad. */
onDetectedDeviceChange?: (device: 'mouse' | 'trackpad') => void
/** Timestamp of last wheel event for cooldown tracking */
lastWheelEventTime: number = 0
@@ -302,6 +305,7 @@ export class CanvasPointer {
const now = performance.now()
const timeSinceLastEvent = Math.max(0, now - this.lastWheelEventTime)
this.lastWheelEventTime = now
const previousDevice = this.detectedDevice
if (this._isHighResWheelEvent(e, now)) {
this.detectedDevice = 'mouse'
@@ -314,6 +318,9 @@ export class CanvasPointer {
this.hasReceivedWheelEvent = true
}
if (previousDevice !== this.detectedDevice) {
this.onDetectedDeviceChange?.(this.detectedDevice)
}
return this.detectedDevice === 'trackpad'
}

View File

@@ -3895,13 +3895,25 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
let { scale } = this.ds
// Detect if this is a trackpad gesture or mouse wheel
const isTrackpad = this.pointer.isTrackpadGesture(e)
/**
* Resolve trackpad vs mouse mode. Honor the user's manual override when
* set; otherwise fall back to the heuristic-based auto-detection.
*/
const isTrackpad =
LiteGraph.wheelInputMode === 'mouse'
? false
: LiteGraph.wheelInputMode === 'trackpad'
? true
: this.pointer.isTrackpadGesture(e)
const isCtrlOrMacMeta =
e.ctrlKey || (e.metaKey && navigator.platform.includes('Mac'))
const isZoomModifier = isCtrlOrMacMeta && !e.altKey && !e.shiftKey
if (isZoomModifier || LiteGraph.mouseWheelScroll === 'zoom') {
/**
* Wheel-to-zoom is the default for mouse, wheel-to-pan for trackpad.
* The Ctrl/Meta modifier always forces zoom regardless of device.
*/
if (isZoomModifier || !isTrackpad) {
// Zoom mode or modifier key pressed - use wheel for zoom
if (isTrackpad) {
// Trackpad gesture - use smooth scaling

View File

@@ -321,6 +321,15 @@ export class LiteGraphGlobal {
mouseWheelScroll: 'panning' | 'zoom' = 'panning'
/**
* Override for the auto-detection of trackpad vs mouse in wheel events.
* "auto" preserves the existing heuristic-based detection.
* "mouse" / "trackpad" force the corresponding formula on every event,
* which avoids misclassification when the user switches devices mid-session.
* @default "auto"
*/
wheelInputMode: 'auto' | 'mouse' | 'trackpad' = 'auto'
/**
* If `true`, widget labels and values will both be truncated (proportionally to size),
* until they fit within the widget.

View File

@@ -3494,6 +3494,7 @@
"showInfoBadges": "Show info badges",
"showToolbox": "Show toolbox on selection",
"nodes2": "Nodes 2.0",
"inputDevice": "Input device",
"gridSpacing": "Grid spacing",
"snapNodesToGrid": "Snap nodes to grid",
"linkShape": "Link shape",

View File

@@ -128,6 +128,16 @@
"name": "Live selection",
"tooltip": "When enabled, nodes are selected/deselected in real-time as you drag the selection rectangle, similar to other design tools."
},
"Comfy_Graph_WheelInputMode": {
"name": "Input device",
"tooltip": "Forces the zoom formula for a specific input device. Use this if auto-detection misclassifies your device when switching between mouse and trackpad.",
"options": {
"Auto-detect (Mouse)": "Auto-detect (Mouse)",
"Auto-detect (Trackpad)": "Auto-detect (Trackpad)",
"Mouse": "Mouse",
"Trackpad": "Trackpad"
}
},
"Comfy_Graph_ZoomSpeed": {
"name": "Canvas zoom speed"
},

View File

@@ -40,12 +40,16 @@ const props = defineProps<{
}>()
const { t } = useI18n()
const settingStore = useSettingStore()
const settingValue = computed(() => settingStore.get(props.setting.id))
function translateOptions(options: (SettingOption | string)[]) {
function translateOptions(
options:
| (SettingOption | string)[]
| ((value?: unknown) => (SettingOption | string)[])
): { text: string; value: string | number | undefined }[] {
if (typeof options === 'function') {
// @ts-expect-error: Audit and deprecate usage of legacy options type:
// (value) => [string | {text: string, value: string}]
return translateOptions(options(props.setting.value ?? ''))
return translateOptions(options(settingValue.value))
}
return options.map((option) => {
@@ -75,9 +79,6 @@ const formItem = computed(() => {
: undefined
}
})
const settingStore = useSettingStore()
const settingValue = computed(() => settingStore.get(props.setting.id))
const updateSettingValue = async <K extends keyof Settings>(
newValue: Settings[K]
) => {

View File

@@ -0,0 +1,11 @@
import { ref } from 'vue'
/**
* Reactive snapshot of the device the canvas pointer auto-detection currently
* believes is in use. Updated by a callback wired in useLitegraphSettings.
*/
const detectedInputDevice = ref<'mouse' | 'trackpad'>('mouse')
export function useInputDeviceDetection() {
return { detectedInputDevice }
}

View File

@@ -5,6 +5,7 @@ import {
LGraphNode,
LiteGraph
} from '@/lib/litegraph/src/litegraph'
import { useInputDeviceDetection } from '@/platform/settings/composables/useInputDeviceDetection'
import { useSettingStore } from '@/platform/settings/settingStore'
// eslint-disable-next-line import-x/no-restricted-paths
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
@@ -141,16 +142,6 @@ export const useLitegraphSettings = () => {
)
})
watchEffect(() => {
const navigationMode = settingStore.get('Comfy.Canvas.NavigationMode') as
| 'standard'
| 'legacy'
| 'custom'
LiteGraph.canvasNavigationMode = navigationMode
LiteGraph.macTrackpadGestures = navigationMode === 'standard'
})
watchEffect(() => {
const leftMouseBehavior = settingStore.get(
'Comfy.Canvas.LeftMouseClickBehavior'
@@ -159,10 +150,23 @@ export const useLitegraphSettings = () => {
})
watchEffect(() => {
const mouseWheelScroll = settingStore.get(
'Comfy.Canvas.MouseWheelScroll'
) as 'panning' | 'zoom'
LiteGraph.mouseWheelScroll = mouseWheelScroll
LiteGraph.wheelInputMode = settingStore.get(
'Comfy.Graph.WheelInputMode'
) as 'auto' | 'mouse' | 'trackpad'
})
/**
* Mirror the canvas pointer's auto-detected device onto a reactive ref so
* settings UI can show the current detection inside the "Auto" option.
*/
watchEffect(() => {
const { canvas } = canvasStore
if (!canvas) return
const { detectedInputDevice } = useInputDeviceDetection()
detectedInputDevice.value = canvas.pointer.detectedDevice
canvas.pointer.onDetectedDeviceChange = (device) => {
detectedInputDevice.value = device
}
})
watchEffect(() => {

View File

@@ -1,6 +1,6 @@
import { LinkMarkerShape, LiteGraph } from '@/lib/litegraph/src/litegraph'
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useInputDeviceDetection } from '@/platform/settings/composables/useInputDeviceDetection'
import type { SettingParams } from '@/platform/settings/types'
import type { ColorPalettes } from '@/schemas/colorPaletteSchema'
import type { Keybinding } from '@/platform/keybindings/types'
@@ -159,101 +159,73 @@ export const CORE_SETTINGS: SettingParams[] = [
type: 'boolean',
defaultValue: false
},
{
id: 'Comfy.Graph.WheelInputMode',
category: ['LiteGraph', 'Canvas Navigation', 'InputDevice'],
name: 'Input device',
tooltip:
'Forces the zoom formula for a specific input device. Use this if auto-detection misclassifies your device when switching between mouse and trackpad.',
type: 'combo',
defaultValue: 'auto',
sortOrder: 200,
/**
* Reactively label the auto option with the currently detected device.
* The function is called inside SettingItem's `formItem` computed, so any
* read of `detectedInputDevice.value` is tracked and refreshes the dropdown.
*/
options: () => {
const { detectedInputDevice } = useInputDeviceDetection()
const autoLabel =
detectedInputDevice.value === 'trackpad'
? 'Auto-detect (Trackpad)'
: 'Auto-detect (Mouse)'
return [
{ value: 'auto', text: autoLabel },
{ value: 'mouse', text: 'Mouse' },
{ value: 'trackpad', text: 'Trackpad' }
]
},
versionAdded: '1.45.0'
},
{
id: 'Comfy.Canvas.NavigationMode',
category: ['LiteGraph', 'Canvas Navigation', 'NavigationMode'],
name: 'Navigation Mode',
defaultValue: 'legacy',
type: 'combo',
sortOrder: 100,
type: 'hidden',
deprecated: true,
options: [
{ value: 'standard', text: 'Standard (New)' },
{ value: 'legacy', text: 'Drag Navigation' },
{ value: 'custom', text: 'Custom' }
],
versionAdded: '1.25.0',
defaultsByInstallVersion: {
'1.25.0': 'legacy'
},
onChange: async (val: unknown, old?: unknown) => {
const newValue = val as string
const oldValue = old as string | undefined
if (!oldValue) return
const settingStore = useSettingStore()
if (newValue === 'standard') {
await settingStore.setMany({
'Comfy.Canvas.LeftMouseClickBehavior': 'select',
'Comfy.Canvas.MouseWheelScroll': 'panning'
})
} else if (newValue === 'legacy') {
await settingStore.setMany({
'Comfy.Canvas.LeftMouseClickBehavior': 'panning',
'Comfy.Canvas.MouseWheelScroll': 'zoom'
})
}
}
versionAdded: '1.25.0'
},
{
id: 'Comfy.Canvas.LeftMouseClickBehavior',
category: ['LiteGraph', 'Canvas Navigation', 'LeftMouseClickBehavior'],
name: 'Left Mouse Click Behavior',
defaultValue: 'panning',
defaultValue: 'select',
type: 'radio',
sortOrder: 50,
sortOrder: 100,
options: [
{ value: 'panning', text: 'Panning' },
{ value: 'select', text: 'Select' }
],
versionAdded: '1.27.4',
onChange: async (val: unknown) => {
const newValue = val as string
const settingStore = useSettingStore()
const navigationMode = settingStore.get('Comfy.Canvas.NavigationMode')
if (navigationMode !== 'custom') {
if (
(newValue === 'select' && navigationMode === 'standard') ||
(newValue === 'panning' && navigationMode === 'legacy')
) {
return
}
// only set to custom if it doesn't match the preset modes
await settingStore.set('Comfy.Canvas.NavigationMode', 'custom')
}
}
versionAdded: '1.27.4'
},
{
id: 'Comfy.Canvas.MouseWheelScroll',
category: ['LiteGraph', 'Canvas Navigation', 'MouseWheelScroll'],
name: 'Mouse Wheel Scroll',
defaultValue: 'zoom',
type: 'radio',
type: 'hidden',
deprecated: true,
options: [
{ value: 'panning', text: 'Panning' },
{ value: 'zoom', text: 'Zoom in/out' }
],
versionAdded: '1.27.4',
onChange: async (val: unknown) => {
const newValue = val as string
const settingStore = useSettingStore()
const navigationMode = settingStore.get('Comfy.Canvas.NavigationMode')
if (navigationMode !== 'custom') {
if (
(newValue === 'panning' && navigationMode === 'standard') ||
(newValue === 'zoom' && navigationMode === 'legacy')
) {
return
}
// only set to custom if it doesn't match the preset modes
await settingStore.set('Comfy.Canvas.NavigationMode', 'custom')
}
}
versionAdded: '1.27.4'
},
{
id: 'Comfy.Graph.CanvasInfo',

View File

@@ -57,7 +57,9 @@ export interface FormItem {
type: SettingInputType | SettingCustomRenderer
tooltip?: string
attrs?: Record<string, unknown>
options?: Array<string | SettingOption>
options?:
| Array<string | SettingOption>
| ((value?: unknown) => Array<string | SettingOption>)
}
export interface ISettingGroup {

View File

@@ -119,7 +119,7 @@ describe('useCanvasInteractions', () => {
describe('handleWheel', () => {
it('should forward ctrl+wheel events to canvas in standard nav mode', () => {
const { get } = useSettingStore()
vi.mocked(get).mockReturnValue('standard')
vi.mocked(get).mockReturnValue('trackpad')
const { handleWheel } = useCanvasInteractions()
@@ -134,7 +134,7 @@ describe('useCanvasInteractions', () => {
it('should forward all wheel events to canvas in legacy nav mode', () => {
const { get } = useSettingStore()
vi.mocked(get).mockReturnValue('legacy')
vi.mocked(get).mockReturnValue('mouse')
const { handleWheel } = useCanvasInteractions()
const mockEvent = createMockWheelEvent()
@@ -146,7 +146,7 @@ describe('useCanvasInteractions', () => {
it('should not prevent default for regular wheel events in standard nav mode', () => {
const { get } = useSettingStore()
vi.mocked(get).mockReturnValue('standard')
vi.mocked(get).mockReturnValue('trackpad')
const { handleWheel } = useCanvasInteractions()
const mockEvent = createMockWheelEvent()
@@ -157,7 +157,7 @@ describe('useCanvasInteractions', () => {
})
it('should forward wheel events to canvas when capture element is NOT focused', () => {
const { get } = useSettingStore()
vi.mocked(get).mockReturnValue('legacy')
vi.mocked(get).mockReturnValue('mouse')
const captureElement = document.createElement('div')
captureElement.setAttribute('data-capture-wheel', 'true')
@@ -179,7 +179,7 @@ describe('useCanvasInteractions', () => {
it('should NOT forward wheel events when capture element IS focused', () => {
const { get } = useSettingStore()
vi.mocked(get).mockReturnValue('legacy')
vi.mocked(get).mockReturnValue('mouse')
const captureElement = document.createElement('div')
captureElement.setAttribute('data-capture-wheel', 'true')
@@ -202,7 +202,7 @@ describe('useCanvasInteractions', () => {
it('should forward ctrl+wheel to canvas when capture element IS focused in standard mode', () => {
const { get } = useSettingStore()
vi.mocked(get).mockReturnValue('standard')
vi.mocked(get).mockReturnValue('trackpad')
const captureElement = document.createElement('div')
captureElement.setAttribute('data-capture-wheel', 'true')

View File

@@ -15,7 +15,7 @@ export function useCanvasInteractions() {
const { getCanvas } = canvasStore
const isStandardNavMode = computed(
() => settingStore.get('Comfy.Canvas.NavigationMode') === 'standard'
() => settingStore.get('Comfy.Graph.WheelInputMode') === 'trackpad'
)
/**

View File

@@ -319,6 +319,7 @@ const zSettings = z.object({
'Comfy.Graph.DeduplicateSubgraphNodeIds': z.boolean(),
'Comfy.Graph.LiveSelection': z.boolean(),
'Comfy.Graph.LinkMarkers': z.nativeEnum(LinkMarkerShape),
'Comfy.Graph.WheelInputMode': z.enum(['auto', 'mouse', 'trackpad']),
'Comfy.Graph.ZoomSpeed': z.number(),
'Comfy.Group.DoubleClickTitleToEdit': z.boolean(),
'Comfy.GroupSelectedNodes.Padding': z.number(),