mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-17 09:18:26 +00:00
Compare commits
14 Commits
split/allo
...
rizumu/fea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d030aad978 | ||
|
|
72c839b7ce | ||
|
|
59bb499385 | ||
|
|
75ab8873ce | ||
|
|
06f2929e1f | ||
|
|
a7d0ebc19a | ||
|
|
5cb1c3a298 | ||
|
|
6479e6b906 | ||
|
|
7a25c623c5 | ||
|
|
ce02d4e3c7 | ||
|
|
5537cddb57 | ||
|
|
bb732bb158 | ||
|
|
ef0cd18616 | ||
|
|
7f8903e9be |
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 |
@@ -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
|
||||
}
|
||||
|
||||
@@ -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="
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -197,5 +197,6 @@ LiteGraphGlobal {
|
||||
"use_uuids": false,
|
||||
"uuidv4": [Function],
|
||||
"vueNodesMode": false,
|
||||
"wheelInputMode": "auto",
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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]
|
||||
) => {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
11
src/platform/settings/composables/useInputDeviceDetection.ts
Normal file
11
src/platform/settings/composables/useInputDeviceDetection.ts
Normal 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 }
|
||||
}
|
||||
114
src/platform/settings/composables/useLitegraphSettings.test.ts
Normal file
114
src/platform/settings/composables/useLitegraphSettings.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user