Compare commits

...

14 Commits

Author SHA1 Message Date
Rizumu Ayaka
d030aad978 Merge remote-tracking branch 'origin/main' into rizumu/feat/add-Input-device-setting-for-canvas-navigation
# Conflicts:
#	src/renderer/core/canvas/useCanvasInteractions.ts
2026-05-18 21:29:10 +08:00
github-actions
72c839b7ce [automated] Update test expectations 2026-05-14 10:14:22 +00:00
Rizumu Ayaka
59bb499385 fix: restore shift+mouse-wheel horizontal pan
The pan branch in processMouseWheel was simplified to trackpad-only,
which silently dropped the legacy "Shift + mouse wheel → horizontal pan"
affordance. Mice only emit deltaY, so the new code path could never move
offset[0] for a mouse user regardless of WheelInputMode.

Add an explicit pre-check that redirects deltaY onto offset[0] when a
mouse fires a wheel event with Shift held and no zoom modifier, so the
shortcut works the same way it did before the refactor. The existing
standard-shift-wheel-pan-* regression test now actually exercises pan.

Addresses review feedback on PR #11956.
2026-05-14 18:06:06 +08:00
Rizumu Ayaka
75ab8873ce test: cover legacy settings migration and detection composable
Adds unit tests for migrateLegacyNavigationSettings (legacy/standard/custom
branches, no-op for default, no-overwrite on explicit choice, idempotency)
and a small reactivity check for useInputDeviceDetection.
2026-05-11 19:22:52 +08:00
Rizumu Ayaka
06f2929e1f test: replace deprecated NavigationMode with LeftMouseClickBehavior
The NavigationMode setting is now deprecated and no longer cascades to
LeftMouseClickBehavior. Tests that previously set NavigationMode to
'standard' or 'legacy' to drive click behavior now set the underlying
LeftMouseClickBehavior setting directly.
2026-05-11 18:45:54 +08:00
github-actions
a7d0ebc19a [automated] Update test expectations 2026-05-11 10:44:43 +00:00
Rizumu Ayaka
5cb1c3a298 test: pin LeftMouseClickBehavior to 'panning' in test fixture
The PR changed the new-install default from 'panning' to 'select'.
Pre-existing tests assume the canvas pans on empty left-drag, so the
fixture pins the legacy value to preserve baseline compatibility.
Tests that exercise 'select' behavior can override per-test.
2026-05-11 17:55:32 +08:00
Rizumu Ayaka
6479e6b906 Revert "[automated] Update test expectations"
This reverts commit 7a25c623c5.
2026-05-11 17:53:45 +08:00
github-actions
7a25c623c5 [automated] Update test expectations 2026-05-11 08:50:56 +00:00
Rizumu Ayaka
ce02d4e3c7 fix: always pan canvas for single-finger touch
Pointer events unify mouse and touch input, so the new 'select'
default for LeftMouseClickBehavior was leaking onto mobile where a
single-finger drag should always pan the viewport.
2026-05-11 14:01:01 +08:00
Rizumu Ayaka
5537cddb57 Merge branch 'main' into rizumu/feat/add-Input-device-setting-for-canvas-navigation 2026-05-11 13:02:51 +08:00
Rizumu Ayaka
bb732bb158 test: update tests for WheelInputMode migration
- Add wheelInputMode property to LiteGraphGlobal snapshot
- Migrate canvasSettings e2e tests from deprecated MouseWheelScroll
  to the new Comfy.Graph.WheelInputMode setting
2026-05-06 17:19:24 +08:00
Rizumu Ayaka
ef0cd18616 refactor: address review feedback on Input device PR
- Clean up onDetectedDeviceChange callback when canvas is replaced
- Migrate legacy NavigationMode/MouseWheelScroll on first run
- Drop unreachable !isTrackpad branch in trackpad pan path
- Rename isStandardNavMode to isTrackpadWheelMode for clarity
2026-05-05 23:16:33 +08:00
Rizumu Ayaka
7f8903e9be feat: add Input device setting for canvas navigation 2026-05-05 17:55:39 +08:00
26 changed files with 418 additions and 151 deletions

View File

@@ -114,7 +114,7 @@ function scrollToSection(id: string) {
<section class="px-4 pt-8 pb-24 lg:px-20 lg:pt-24 lg:pb-40">
<div class="lg:flex lg:gap-16">
<!-- Desktop sticky nav -->
<aside class="scrollbar-none hidden lg:block lg:w-48 lg:shrink-0">
<aside class="hidden scrollbar-none lg:block lg:w-48 lg:shrink-0">
<div class="sticky top-32">
<CategoryNav
:categories="categories"

View File

@@ -56,16 +56,16 @@ const isPartnerNode = directory === 'partner_nodes'
>
<div class="flex max-w-2xl flex-1 flex-col gap-6">
<p
class="text-sm font-medium uppercase tracking-widest text-primary-comfy-yellow"
class="text-primary-comfy-yellow text-sm font-medium tracking-widest uppercase"
>
{{ eyebrow }}
</p>
<h1 class="text-4xl font-bold text-primary-comfy-canvas lg:text-6xl">
<h1 class="text-primary-comfy-canvas text-4xl font-bold lg:text-6xl">
{{ displayName }} in ComfyUI
</h1>
<p class="text-sm text-primary-comfy-canvas/60">
<p class="text-primary-comfy-canvas/60 text-sm">
{{
t('models.hero.workflowCount').replace(
'{count}',
@@ -122,7 +122,7 @@ const isPartnerNode = directory === 'partner_nodes'
</BrandButton>
</div>
<div v-if="blogUrl" class="text-sm text-primary-comfy-canvas/60">
<div v-if="blogUrl" class="text-primary-comfy-canvas/60 text-sm">
<a
:href="blogUrl"
target="_blank"

View File

@@ -135,7 +135,7 @@ const activePlanIndex = ref(0)
</div>
<!-- Mobile plan tabs -->
<div class="scrollbar-none mb-6 flex gap-2 overflow-x-auto lg:hidden">
<div class="mb-6 flex scrollbar-none gap-2 overflow-x-auto lg:hidden">
<button
v-for="(plan, index) in plans"
:key="plan.id"

View File

@@ -523,6 +523,10 @@ export const comfyPageFixture = base.extend<{
'Comfy.Graph.CanvasInfo': false,
'Comfy.Graph.CanvasMenu': false,
'Comfy.Canvas.SelectionToolbox': false,
// Pin to the legacy panning behavior so existing baselines that
// assume empty-drag pans the canvas remain valid. Individual tests
// can opt into 'select' explicitly.
'Comfy.Canvas.LeftMouseClickBehavior': 'panning',
// Hide all badges by default.
'Comfy.NodeBadge.NodeIdBadgeMode': NodeBadgeMode.None,
'Comfy.NodeBadge.NodeSourceBadgeMode': NodeBadgeMode.None,

View File

@@ -106,8 +106,8 @@ test.describe('Canvas settings', { tag: '@canvas' }, () => {
test.describe('Comfy.Graph.LiveSelection', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.Canvas.NavigationMode',
'standard'
'Comfy.Canvas.LeftMouseClickBehavior',
'select'
)
})
@@ -145,14 +145,11 @@ test.describe('Canvas settings', { tag: '@canvas' }, () => {
})
})
test.describe('Comfy.Canvas.MouseWheelScroll', () => {
test.describe('Comfy.Graph.WheelInputMode', () => {
const WHEEL_POS = { x: 400, y: 400 }
test('wheel zooms when set to zoom', async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.Canvas.MouseWheelScroll',
'zoom'
)
test('wheel zooms when input device is mouse', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Graph.WheelInputMode', 'mouse')
const initialScale = await comfyPage.canvasOps.getScale()
await comfyPage.page.mouse.move(WHEEL_POS.x, WHEEL_POS.y)
@@ -166,10 +163,10 @@ test.describe('Canvas settings', { tag: '@canvas' }, () => {
)
})
test('wheel pans when set to panning', async ({ comfyPage }) => {
test('wheel pans when input device is trackpad', async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.Canvas.MouseWheelScroll',
'panning'
'Comfy.Graph.WheelInputMode',
'trackpad'
)
const initialScale = await comfyPage.canvasOps.getScale()
const initialOffset = await comfyPage.canvasOps.getOffset()

View File

@@ -1141,8 +1141,8 @@ test.describe('Canvas Navigation', { tag: '@screenshot' }, () => {
test.describe('Legacy Mode', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.Canvas.NavigationMode',
'legacy'
'Comfy.Canvas.LeftMouseClickBehavior',
'panning'
)
})
@@ -1201,8 +1201,8 @@ test.describe('Canvas Navigation', { tag: '@screenshot' }, () => {
test.describe('Standard Mode', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.Canvas.NavigationMode',
'standard'
'Comfy.Canvas.LeftMouseClickBehavior',
'select'
)
})
@@ -1385,8 +1385,8 @@ test.describe('Canvas Navigation', { tag: '@screenshot' }, () => {
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Canvas.NavigationMode',
'legacy'
'Comfy.Canvas.LeftMouseClickBehavior',
'panning'
)
await comfyPage.page.keyboard.down('Alt')
@@ -1415,8 +1415,8 @@ test.describe('Canvas Navigation', { tag: '@screenshot' }, () => {
}
await comfyPage.settings.setSetting(
'Comfy.Canvas.NavigationMode',
'legacy'
'Comfy.Canvas.LeftMouseClickBehavior',
'panning'
)
await comfyPage.page.mouse.move(50, 50)
await comfyPage.page.mouse.down()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 98 KiB

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

@@ -107,6 +107,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
@@ -313,6 +316,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'
@@ -325,6 +329,9 @@ export class CanvasPointer {
this.hasReceivedWheelEvent = true
}
if (previousDevice !== this.detectedDevice) {
this.onDetectedDeviceChange?.(this.detectedDevice)
}
return this.detectedDevice === 'trackpad'
}

View File

@@ -2683,8 +2683,17 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
!pointer.onDrag &&
this.allow_dragcanvas
) {
// allow dragging canvas based on leftMouseClickBehavior or read-only mode
if (LiteGraph.leftMouseClickBehavior === 'panning' || this.read_only) {
/**
* Always pan for single-finger touch — selection-by-drag is a desktop
* idiom that does not translate to mobile, where the natural gesture
* for moving the viewport is dragging with one finger.
*/
const isTouch = e.pointerType === 'touch'
if (
LiteGraph.leftMouseClickBehavior === 'panning' ||
this.read_only ||
isTouch
) {
pointer.onClick = () => this.processSelect(null, e)
pointer.finally = () => (this.dragging_canvas = false)
this.dragging_canvas = true
@@ -3908,20 +3917,40 @@ 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') {
// Zoom mode or modifier key pressed - use wheel for zoom
const factor = 0.18
/**
* Mouse + Shift: redirect vertical wheel to horizontal pan. Mice only
* produce deltaY, so this is the only way to pan horizontally with a
* wheel input, regardless of WheelInputMode. Skip when a zoom modifier
* (Ctrl/Meta) is held — that combination should still zoom.
*/
const isMouseShiftPan =
!isTrackpad && e.shiftKey && !isZoomModifier && e.deltaX === 0
if (isMouseShiftPan) {
this.ds.offset[0] -= e.deltaY * (1 + factor) * (1 / scale)
} else if (isZoomModifier || !isTrackpad) {
// Wheel-to-zoom: mouse default and Ctrl/Meta forces it on either device
if (isTrackpad) {
// Trackpad gesture - use smooth scaling
// Trackpad pinch — smooth scaling
scale *= 1 + e.deltaY * (1 - this.zoom_speed) * 0.18
this.ds.changeScale(scale, [e.clientX, e.clientY], false)
} else {
// Mouse wheel - use stepped scaling
// Mouse wheel stepped scaling
if (e.deltaY < 0) {
scale *= this.zoom_speed
} else if (e.deltaY > 0) {
@@ -3930,15 +3959,9 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
this.ds.changeScale(scale, [e.clientX, e.clientY])
}
} else {
// Trackpads and mice work on significantly different scales
const factor = isTrackpad ? 0.18 : 0.008_333
if (!isTrackpad && e.shiftKey && e.deltaX === 0) {
this.ds.offset[0] -= e.deltaY * (1 + factor) * (1 / scale)
} else {
this.ds.offset[0] -= e.deltaX * (1 + factor) * (1 / scale)
this.ds.offset[1] -= e.deltaY * (1 + factor) * (1 / scale)
}
// Trackpad two-finger pan
this.ds.offset[0] -= e.deltaX * (1 + factor) * (1 / scale)
this.ds.offset[1] -= e.deltaY * (1 + factor) * (1 / scale)
}
this.graph.change()

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

@@ -197,5 +197,6 @@ LiteGraphGlobal {
"use_uuids": false,
"uuidv4": [Function],
"vueNodesMode": false,
"wheelInputMode": "auto",
}
`;

View File

@@ -3502,6 +3502,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,27 @@
import { describe, expect, it } from 'vitest'
import { useInputDeviceDetection } from '@/platform/settings/composables/useInputDeviceDetection'
describe('useInputDeviceDetection', () => {
it('exposes a default detected device of "mouse"', () => {
const { detectedInputDevice } = useInputDeviceDetection()
expect(
detectedInputDevice.value === 'mouse' ||
detectedInputDevice.value === 'trackpad'
).toBe(true)
})
it('returns the same singleton ref across calls', () => {
const first = useInputDeviceDetection().detectedInputDevice
const second = useInputDeviceDetection().detectedInputDevice
expect(first).toBe(second)
})
it('propagates writes through the shared ref', () => {
const { detectedInputDevice } = useInputDeviceDetection()
const previous = detectedInputDevice.value
detectedInputDevice.value = 'trackpad'
expect(useInputDeviceDetection().detectedInputDevice.value).toBe('trackpad')
detectedInputDevice.value = previous
})
})

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

@@ -0,0 +1,114 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { migrateLegacyNavigationSettings } from '@/platform/settings/composables/useLitegraphSettings'
type SettingStoreLike = {
get: ReturnType<typeof vi.fn>
set: ReturnType<typeof vi.fn>
}
function createSettingStoreMock(
values: Partial<Record<string, unknown>>
): SettingStoreLike {
return {
get: vi.fn((key: string) => values[key]),
set: vi.fn(async (key: string, value: unknown) => {
values[key] = value
})
}
}
describe('migrateLegacyNavigationSettings', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns early when NavigationMode is already at the default', async () => {
const store = createSettingStoreMock({
'Comfy.Canvas.NavigationMode': 'legacy'
})
await migrateLegacyNavigationSettings(store as never)
expect(store.set).not.toHaveBeenCalled()
})
it("maps NavigationMode='standard' to WheelInputMode='trackpad'", async () => {
const store = createSettingStoreMock({
'Comfy.Canvas.NavigationMode': 'standard',
'Comfy.Graph.WheelInputMode': 'auto'
})
await migrateLegacyNavigationSettings(store as never)
expect(store.set).toHaveBeenCalledWith(
'Comfy.Graph.WheelInputMode',
'trackpad'
)
expect(store.set).toHaveBeenCalledWith(
'Comfy.Canvas.NavigationMode',
'legacy'
)
})
it("maps custom MouseWheelScroll='panning' to WheelInputMode='trackpad'", async () => {
const store = createSettingStoreMock({
'Comfy.Canvas.NavigationMode': 'custom',
'Comfy.Canvas.MouseWheelScroll': 'panning',
'Comfy.Graph.WheelInputMode': 'auto'
})
await migrateLegacyNavigationSettings(store as never)
expect(store.set).toHaveBeenCalledWith(
'Comfy.Graph.WheelInputMode',
'trackpad'
)
})
it("maps custom MouseWheelScroll='zoom' to WheelInputMode='mouse'", async () => {
const store = createSettingStoreMock({
'Comfy.Canvas.NavigationMode': 'custom',
'Comfy.Canvas.MouseWheelScroll': 'zoom',
'Comfy.Graph.WheelInputMode': 'auto'
})
await migrateLegacyNavigationSettings(store as never)
expect(store.set).toHaveBeenCalledWith(
'Comfy.Graph.WheelInputMode',
'mouse'
)
})
it('does not overwrite an explicit WheelInputMode choice', async () => {
const store = createSettingStoreMock({
'Comfy.Canvas.NavigationMode': 'standard',
'Comfy.Graph.WheelInputMode': 'mouse'
})
await migrateLegacyNavigationSettings(store as never)
expect(store.set).not.toHaveBeenCalledWith(
'Comfy.Graph.WheelInputMode',
expect.anything()
)
expect(store.set).toHaveBeenCalledWith(
'Comfy.Canvas.NavigationMode',
'legacy'
)
})
it('resets NavigationMode so it is idempotent on subsequent runs', async () => {
const store = createSettingStoreMock({
'Comfy.Canvas.NavigationMode': 'standard',
'Comfy.Graph.WheelInputMode': 'auto'
})
await migrateLegacyNavigationSettings(store as never)
store.set.mockClear()
await migrateLegacyNavigationSettings(store as never)
expect(store.set).not.toHaveBeenCalled()
})
})

View File

@@ -5,10 +5,39 @@ 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'
/**
* One-time translation of the legacy `Comfy.Canvas.NavigationMode` +
* `Comfy.Canvas.MouseWheelScroll` pair into the new `Comfy.Graph.WheelInputMode`
* preference, preserving explicit choices made by users on previous versions.
*
* Idempotency is achieved by resetting NavigationMode to its default after
* migration: subsequent boots see `legacy` and exit early.
*/
export async function migrateLegacyNavigationSettings(
settingStore: ReturnType<typeof useSettingStore>
) {
const navMode = settingStore.get('Comfy.Canvas.NavigationMode')
if (navMode === 'legacy') return
let migrated: 'mouse' | 'trackpad' | undefined
if (navMode === 'standard') {
migrated = 'trackpad'
} else if (navMode === 'custom') {
const wheelScroll = settingStore.get('Comfy.Canvas.MouseWheelScroll')
migrated = wheelScroll === 'panning' ? 'trackpad' : 'mouse'
}
if (migrated && settingStore.get('Comfy.Graph.WheelInputMode') === 'auto') {
await settingStore.set('Comfy.Graph.WheelInputMode', migrated)
}
await settingStore.set('Comfy.Canvas.NavigationMode', 'legacy')
}
/**
* Watch for changes in the setting store and update the LiteGraph settings accordingly.
*/
@@ -16,6 +45,8 @@ export const useLitegraphSettings = () => {
const settingStore = useSettingStore()
const canvasStore = useCanvasStore()
void migrateLegacyNavigationSettings(settingStore)
watchEffect(() => {
const canvasInfoEnabled = settingStore.get('Comfy.Graph.CanvasInfo')
if (canvasStore.canvas) {
@@ -141,16 +172,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 +180,29 @@ 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.
* The cleanup detaches the handler from the previous pointer so a stale
* canvas instance can no longer mutate the shared ref after replacement.
*/
watchEffect((onCleanup) => {
const { canvas } = canvasStore
if (!canvas) return
const { pointer } = canvas
const { detectedInputDevice } = useInputDeviceDetection()
detectedInputDevice.value = pointer.detectedDevice
pointer.onDetectedDeviceChange = (device) => {
detectedInputDevice.value = device
}
onCleanup(() => {
pointer.onDetectedDeviceChange = undefined
})
})
watchEffect(() => {

View File

@@ -4,7 +4,7 @@ import {
SUPPORTED_LOCALE_OPTIONS
} from '@/locales/localeConfig'
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'
@@ -163,101 +163,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

@@ -126,7 +126,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()
@@ -141,7 +141,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()
@@ -153,7 +153,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()
@@ -164,7 +164,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')
@@ -186,7 +186,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')
@@ -209,7 +209,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,8 +15,8 @@ export function useCanvasInteractions() {
const canvasStore = useCanvasStore()
const { getCanvas } = canvasStore
const isStandardNavMode = computed(
() => settingStore.get('Comfy.Canvas.NavigationMode') === 'standard'
const isTrackpadWheelMode = computed(
() => settingStore.get('Comfy.Graph.WheelInputMode') === 'trackpad'
)
/**
@@ -58,17 +58,18 @@ export function useCanvasInteractions() {
const handleWheel = (event: WheelEvent) => {
if (!shouldForwardWheelEvent(event)) return
// 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) {
// Trackpad mode: only canvas gestures (zoom / horizontal pan) are
// forwarded; plain vertical wheel falls through so the document or
// widget scrolls natively. 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 (isTrackpadWheelMode.value) {
if (isCanvasGestureWheel(event)) forwardEventToCanvas(event)
return
}
// In legacy mode, all forwardable wheel events go to canvas for zoom/pan.
// Mouse mode: all forwardable wheel events go to canvas for zoom/pan.
forwardEventToCanvas(event)
}

View File

@@ -326,6 +326,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(),