Files
ComfyUI-Lora-Manager/vue-widgets/src/components/shared/SingleSlider.vue
Will Miao 89b1675ec7 fix: wheel zoom behavior for LoRA Manager widgets
- Add forwardWheelToCanvas() utility for vanilla JS widgets
- Implement wheel event handling in Vue widgets (LoraCyclerWidget, LoraRandomizerWidget, LoraPoolWidget)
- Update SingleSlider and DualRangeSlider to stop event propagation after value adjustment
- Ensure consistent behavior: slider adjusts value only, other areas trigger canvas zoom
- Support pinch-to-zoom (Ctrl+wheel) and horizontal scroll forwarding
2026-03-28 22:42:26 +08:00

258 lines
6.1 KiB
Vue

<template>
<div class="single-slider" :class="{ disabled, 'is-dragging': dragging }" data-capture-wheel="true" @wheel="onWheel">
<div class="slider-track" ref="trackEl">
<div class="slider-track__bg"></div>
<div
class="slider-track__active"
:style="{ width: percent + '%' }"
></div>
<div
v-if="defaultRange"
class="slider-track__default"
:style="{
left: defaultMinPercent + '%',
width: (defaultMaxPercent - defaultMinPercent) + '%'
}"
></div>
</div>
<div
class="slider-handle"
:style="{ left: percent + '%' }"
@pointerdown.stop="startDrag"
@pointermove.stop="onDrag"
@pointerup.stop="stopDrag"
@pointercancel.stop="stopDrag"
>
<div class="slider-handle__thumb"></div>
<div class="slider-handle__value">{{ formatValue(value) }}</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
const props = withDefaults(defineProps<{
min: number
max: number
value: number
step: number
defaultRange?: { min: number; max: number }
disabled?: boolean
}>(), {
disabled: false
})
const emit = defineEmits<{
'update:value': [value: number]
}>()
const trackEl = ref<HTMLElement | null>(null)
const dragging = ref(false)
const activePointerId = ref<number | null>(null)
const percent = computed(() => {
const range = props.max - props.min
return ((props.value - props.min) / range) * 100
})
const defaultMinPercent = computed(() => {
if (!props.defaultRange) return 0
const range = props.max - props.min
return ((props.defaultRange.min - props.min) / range) * 100
})
const defaultMaxPercent = computed(() => {
if (!props.defaultRange) return 100
const range = props.max - props.min
return ((props.defaultRange.max - props.min) / range) * 100
})
const formatValue = (val: number): string => {
if (Number.isInteger(val)) return val.toString()
return val.toFixed(stepToDecimals(props.step))
}
const stepToDecimals = (step: number): number => {
const str = step.toString()
const decimalIndex = str.indexOf('.')
return decimalIndex === -1 ? 0 : str.length - decimalIndex - 1
}
const snapToStep = (value: number): number => {
const steps = Math.round((value - props.min) / props.step)
const rawValue = Math.max(props.min, Math.min(props.max, props.min + steps * props.step))
// Fix floating point precision issues, limit to 2 decimal places
return Math.round(rawValue * 100) / 100
}
const startDrag = (event: PointerEvent) => {
if (props.disabled) return
event.preventDefault()
event.stopPropagation()
dragging.value = true
activePointerId.value = event.pointerId
// Capture pointer to receive all subsequent events regardless of stopPropagation
const target = event.currentTarget as HTMLElement
target.setPointerCapture(event.pointerId)
// Process initial position
updateValue(event)
}
const onDrag = (event: PointerEvent) => {
if (!dragging.value) return
event.stopPropagation()
updateValue(event)
}
const updateValue = (event: PointerEvent) => {
if (!trackEl.value || !dragging.value) return
const rect = trackEl.value.getBoundingClientRect()
const percent = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width))
const rawValue = props.min + percent * (props.max - props.min)
const value = snapToStep(rawValue)
emit('update:value', value)
}
const onWheel = (event: WheelEvent) => {
if (props.disabled) return
const rect = trackEl.value?.getBoundingClientRect()
if (!rect) return
const rootRect = (event.currentTarget as HTMLElement).getBoundingClientRect()
if (event.clientX < rootRect.left || event.clientX > rootRect.right ||
event.clientY < rootRect.top || event.clientY > rootRect.bottom) return
// Adjust slider value when wheeling over the slider area
const delta = event.deltaY > 0 ? -1 : 1
const newValue = snapToStep(props.value + delta * props.step)
emit('update:value', newValue)
// Stop propagation to prevent canvas zoom
event.stopPropagation()
}
const stopDrag = (event?: PointerEvent) => {
if (!dragging.value) return
if (event) {
event.stopPropagation()
// Release pointer capture
const target = event.currentTarget as HTMLElement
if (activePointerId.value !== null) {
target.releasePointerCapture(activePointerId.value)
}
}
dragging.value = false
activePointerId.value = null
}
</script>
<style scoped>
.single-slider {
position: relative;
width: 100%;
height: 24px;
user-select: none;
cursor: default !important;
touch-action: none;
}
.single-slider.disabled {
opacity: 0.4;
pointer-events: none;
}
.single-slider.is-dragging {
cursor: ew-resize !important;
}
.slider-track {
position: absolute;
top: 12px;
left: 0;
right: 0;
height: 4px;
background: var(--comfy-input-bg, #333);
border-radius: 4px;
cursor: default !important;
}
.slider-track__bg {
position: absolute;
inset: 0;
background: rgba(66, 153, 225, 0.15);
border-radius: 2px;
}
.slider-track__active {
position: absolute;
top: 0;
bottom: 0;
left: 0;
background: rgba(66, 153, 225, 0.6);
border-radius: 2px;
transition: width 0.05s linear;
}
.slider-track__default {
position: absolute;
top: 0;
bottom: 0;
background: rgba(66, 153, 225, 0.1);
border-radius: 2px;
}
.slider-handle {
position: absolute;
top: 0;
transform: translateX(-50%);
cursor: ew-resize !important;
z-index: 2;
touch-action: none;
}
.slider-handle__thumb {
width: 14px;
height: 14px;
background: var(--fg-color, #fff);
border-radius: 50%;
position: absolute;
top: 7px;
left: 0;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
transition: transform 0.15s ease;
}
.slider-handle:hover .slider-handle__thumb {
transform: scale(1.1);
}
.slider-handle:active .slider-handle__thumb {
transform: scale(1.15);
}
.slider-handle__value {
position: absolute;
top: -6px;
left: 50%;
transform: translateX(-50%);
font-size: 12px;
font-family: 'SF Mono', 'Roboto Mono', monospace;
color: var(--fg-color, #fff);
opacity: 0.8;
white-space: nowrap;
pointer-events: none;
line-height: 14px;
}
</style>