mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-05-06 08:26:45 -03:00
- 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
258 lines
6.1 KiB
Vue
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>
|