mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-21 21:22:11 -03:00
fix: make sliders compatible with Vue DOM render mode
Add data-capture-wheel attribute to SingleSlider and DualRangeSlider components to prevent wheel events from being intercepted by the canvas in ComfyUI's new Vue DOM render mode. This allows mouse wheel to work for adjusting slider values while still enabling workflow zoom on non-interactive widget areas. Also update event handling to use pointer events with proper stop propagation and pointer capture for reliable drag operations in both rendering modes. Update development guide with Section 8 documenting Vue DOM render mode event handling patterns and best practices.
This commit is contained in:
@@ -332,7 +332,115 @@ This allows users to pan the workflow canvas even when their mouse cursor is hov
|
||||
|
||||
---
|
||||
|
||||
## 8. Complete Example: Text Counter
|
||||
## 8. Event Handling in Vue DOM Render Mode
|
||||
|
||||
ComfyUI frontend supports two rendering modes for nodes:
|
||||
- **Legacy Canvas Mode**: Traditional rendering where widgets are rendered on top of the canvas using absolute positioning
|
||||
- **Vue DOM Render Mode**: New rendering mode where nodes and widgets are rendered as Vue components
|
||||
|
||||
In Vue DOM render mode, event handling works differently. The frontend uses `useCanvasInteractions` composable to manage event forwarding to the canvas. This can cause custom event handlers in your widgets (e.g., mouse wheel for sliders, custom drag operations) to be intercepted by the canvas.
|
||||
|
||||
### 8.1 Wheel Event Handling
|
||||
|
||||
By default in Vue DOM render mode, wheel events on widgets may be forwarded to the canvas for workflow zoom, overriding your custom wheel handlers (e.g., adjusting slider values with mouse wheel).
|
||||
|
||||
To fix this, use the `data-capture-wheel="true"` attribute on elements that should capture wheel events:
|
||||
|
||||
```vue
|
||||
<!-- Vue component template -->
|
||||
<div class="my-slider" data-capture-wheel="true" @wheel="onWheel">
|
||||
<!-- Slider content -->
|
||||
</div>
|
||||
|
||||
<script setup lang="ts">
|
||||
const onWheel = (event: WheelEvent) => {
|
||||
event.preventDefault()
|
||||
// Custom wheel handling logic here
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
**How it works:**
|
||||
- ComfyUI's `useCanvasInteractions.ts` checks `target?.closest('[data-capture-wheel="true"]')` before forwarding wheel events
|
||||
- If an element (or its ancestor) has this attribute, wheel events are not forwarded to canvas
|
||||
- Your custom `@wheel` handler will work as expected
|
||||
|
||||
**Granular control:**
|
||||
- Apply `data-capture-wheel="true"` to specific interactive elements (e.g., sliders, scrollable areas)
|
||||
- Widget container without this attribute will allow workflow zoom when wheel is used elsewhere
|
||||
- This allows users to both: adjust widget values with wheel, and zoom workflow with wheel in widget's non-interactive areas
|
||||
|
||||
**Example from DualRangeSlider.vue:**
|
||||
```vue
|
||||
<template>
|
||||
<div
|
||||
class="dual-range-slider"
|
||||
:class="{ disabled, 'is-dragging': dragging !== null }"
|
||||
data-capture-wheel="true"
|
||||
@wheel="onWheel"
|
||||
>
|
||||
<!-- Slider tracks and handles -->
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### 8.2 Pointer Event Handling
|
||||
|
||||
In Vue DOM render mode, pointer events (click, drag, etc.) may also be captured by the canvas system. For custom drag operations:
|
||||
|
||||
1. **Use event modifiers to stop propagation:**
|
||||
```vue
|
||||
<div
|
||||
@pointerdown.stop="startDrag"
|
||||
@pointermove.stop="onDrag"
|
||||
@pointerup.stop="stopDrag"
|
||||
>
|
||||
```
|
||||
|
||||
2. **Use pointer capture for reliable drag tracking:**
|
||||
```javascript
|
||||
const startDrag = (event: PointerEvent) => {
|
||||
const target = event.currentTarget as HTMLElement
|
||||
target.setPointerCapture(event.pointerId)
|
||||
// ... drag initialization
|
||||
}
|
||||
|
||||
const stopDrag = (event: PointerEvent) => {
|
||||
const target = event.currentTarget as HTMLElement
|
||||
target.releasePointerCapture(event.pointerId)
|
||||
// ... drag cleanup
|
||||
}
|
||||
```
|
||||
|
||||
3. **Use `touch-action: none` CSS for touch devices:**
|
||||
```css
|
||||
.my-draggable {
|
||||
touch-action: none;
|
||||
}
|
||||
```
|
||||
|
||||
### 8.3 Compatibility Checklist
|
||||
|
||||
Ensure your widget works in both rendering modes:
|
||||
|
||||
| Feature | Canvas Mode | Vue DOM Mode | Solution |
|
||||
|---------|-------------|--------------|----------|
|
||||
| Mouse wheel on sliders | Works by default | Needs `data-capture-wheel` | Add `data-capture-wheel="true"` to slider elements |
|
||||
| Custom drag operations | Works with `stopPropagation()` | Needs `stopPropagation()` | Use `.stop` modifier and pointer capture |
|
||||
| Middle mouse panning | Manual forwarding required | Manual forwarding required | Use `forwardMiddleMouseToCanvas()` |
|
||||
| Workflow zoom on widget edges | Works by default | Works by default | No action needed (works by default) |
|
||||
|
||||
### 8.4 Testing Recommendations
|
||||
|
||||
Test your widget in both rendering modes:
|
||||
1. Toggle between Canvas Mode and Vue DOM Mode in ComfyUI settings
|
||||
2. Verify custom interactions (wheel, drag, etc.) work in both modes
|
||||
3. Verify canvas interactions (zoom, pan) still work when cursor is over non-interactive widget areas
|
||||
4. Test with touch devices if applicable
|
||||
|
||||
---
|
||||
|
||||
## 9. Complete Example: Text Counter
|
||||
|
||||
This example implements a simple widget that displays the character count of another text widget in the same node.
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="dual-range-slider" :class="{ disabled, 'is-dragging': dragging !== null, 'has-segments': scaleMode === 'segmented' && effectiveSegments.length > 0 }" @wheel="onWheel">
|
||||
<div class="dual-range-slider" :class="{ disabled, 'is-dragging': dragging !== null, 'has-segments': scaleMode === 'segmented' && effectiveSegments.length > 0 }" data-capture-wheel="true" @wheel="onWheel">
|
||||
<div class="slider-track" ref="trackEl">
|
||||
<!-- Background track -->
|
||||
<div class="slider-track__bg"></div>
|
||||
@@ -38,8 +38,10 @@
|
||||
<div
|
||||
class="slider-handle slider-handle--min"
|
||||
:style="{ left: minPercent + '%' }"
|
||||
@mousedown="startDrag('min', $event)"
|
||||
@touchstart="startDrag('min', $event)"
|
||||
@pointerdown.stop="startDrag('min', $event)"
|
||||
@pointermove.stop="onDrag"
|
||||
@pointerup.stop="stopDrag"
|
||||
@pointercancel.stop="stopDrag"
|
||||
>
|
||||
<div class="slider-handle__thumb"></div>
|
||||
<div class="slider-handle__value">{{ formatValue(valueMin) }}</div>
|
||||
@@ -48,8 +50,10 @@
|
||||
<div
|
||||
class="slider-handle slider-handle--max"
|
||||
:style="{ left: maxPercent + '%' }"
|
||||
@mousedown="startDrag('max', $event)"
|
||||
@touchstart="startDrag('max', $event)"
|
||||
@pointerdown.stop="startDrag('max', $event)"
|
||||
@pointermove.stop="onDrag"
|
||||
@pointerup.stop="stopDrag"
|
||||
@pointercancel.stop="stopDrag"
|
||||
>
|
||||
<div class="slider-handle__thumb"></div>
|
||||
<div class="slider-handle__value">{{ formatValue(valueMax) }}</div>
|
||||
@@ -58,7 +62,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onUnmounted } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
type ScaleMode = 'linear' | 'segmented'
|
||||
|
||||
@@ -92,6 +96,7 @@ const emit = defineEmits<{
|
||||
|
||||
const trackEl = ref<HTMLElement | null>(null)
|
||||
const dragging = ref<'min' | 'max' | null>(null)
|
||||
const activePointerId = ref<number | null>(null)
|
||||
|
||||
const effectiveSegments = computed<Segment[]>(() => {
|
||||
if (props.scaleMode === 'segmented' && props.segments.length > 0) {
|
||||
@@ -202,26 +207,34 @@ const snapToStep = (value: number, segmentMultiplier?: number): number => {
|
||||
return Math.max(props.min, Math.min(props.max, props.min + steps * effectiveStep))
|
||||
}
|
||||
|
||||
const startDrag = (handle: 'min' | 'max', event: MouseEvent | TouchEvent) => {
|
||||
const startDrag = (handle: 'min' | 'max', event: PointerEvent) => {
|
||||
if (props.disabled) return
|
||||
|
||||
event.preventDefault()
|
||||
dragging.value = handle
|
||||
event.stopPropagation()
|
||||
|
||||
document.addEventListener('mousemove', onDrag)
|
||||
document.addEventListener('mouseup', stopDrag)
|
||||
document.addEventListener('touchmove', onDrag, { passive: false })
|
||||
document.addEventListener('touchend', stopDrag)
|
||||
dragging.value = handle
|
||||
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: MouseEvent | TouchEvent) => {
|
||||
const onDrag = (event: PointerEvent) => {
|
||||
if (!dragging.value) return
|
||||
event.stopPropagation()
|
||||
updateValue(event)
|
||||
}
|
||||
|
||||
const updateValue = (event: PointerEvent) => {
|
||||
if (!trackEl.value || !dragging.value) return
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
const clientX = 'touches' in event ? event.touches[0].clientX : event.clientX
|
||||
const rect = trackEl.value.getBoundingClientRect()
|
||||
const percent = Math.max(0, Math.min(100, (clientX - rect.left) / rect.width * 100))
|
||||
const percent = Math.max(0, Math.min(100, (event.clientX - rect.left) / rect.width * 100))
|
||||
|
||||
const rawValue = percentToValue(percent)
|
||||
const multiplier = getSegmentStepMultiplier(rawValue)
|
||||
@@ -305,17 +318,21 @@ const onWheel = (event: WheelEvent) => {
|
||||
}
|
||||
}
|
||||
|
||||
const stopDrag = () => {
|
||||
dragging.value = null
|
||||
document.removeEventListener('mousemove', onDrag)
|
||||
document.removeEventListener('mouseup', stopDrag)
|
||||
document.removeEventListener('touchmove', onDrag)
|
||||
document.removeEventListener('touchend', stopDrag)
|
||||
}
|
||||
const stopDrag = (event?: PointerEvent) => {
|
||||
if (!dragging.value) return
|
||||
|
||||
onUnmounted(() => {
|
||||
stopDrag()
|
||||
})
|
||||
if (event) {
|
||||
event.stopPropagation()
|
||||
// Release pointer capture
|
||||
const target = event.currentTarget as HTMLElement
|
||||
if (activePointerId.value !== null) {
|
||||
target.releasePointerCapture(activePointerId.value)
|
||||
}
|
||||
}
|
||||
|
||||
dragging.value = null
|
||||
activePointerId.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -324,6 +341,8 @@ onUnmounted(() => {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
user-select: none;
|
||||
cursor: default !important;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.dual-range-slider.disabled {
|
||||
@@ -332,7 +351,7 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.dual-range-slider.is-dragging {
|
||||
cursor: grabbing;
|
||||
cursor: ew-resize !important;
|
||||
}
|
||||
|
||||
.slider-track {
|
||||
@@ -343,6 +362,7 @@ onUnmounted(() => {
|
||||
height: 4px;
|
||||
background: var(--comfy-input-bg, #333);
|
||||
border-radius: 2px;
|
||||
cursor: default !important;
|
||||
}
|
||||
|
||||
.slider-track__bg {
|
||||
@@ -395,12 +415,9 @@ onUnmounted(() => {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
transform: translateX(-50%);
|
||||
cursor: grab;
|
||||
cursor: ew-resize !important;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.slider-handle:active {
|
||||
cursor: grabbing;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.slider-handle__thumb {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="single-slider" :class="{ disabled, 'is-dragging': dragging }" @wheel="onWheel">
|
||||
<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
|
||||
@@ -19,8 +19,10 @@
|
||||
<div
|
||||
class="slider-handle"
|
||||
:style="{ left: percent + '%' }"
|
||||
@mousedown="startDrag($event)"
|
||||
@touchstart="startDrag($event)"
|
||||
@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>
|
||||
@@ -29,7 +31,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onUnmounted } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
min: number
|
||||
@@ -48,6 +50,7 @@ const emit = defineEmits<{
|
||||
|
||||
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
|
||||
@@ -82,28 +85,34 @@ const snapToStep = (value: number): number => {
|
||||
return Math.max(props.min, Math.min(props.max, props.min + steps * props.step))
|
||||
}
|
||||
|
||||
const startDrag = (event: MouseEvent | TouchEvent) => {
|
||||
const startDrag = (event: PointerEvent) => {
|
||||
if (props.disabled) return
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
dragging.value = true
|
||||
activePointerId.value = event.pointerId
|
||||
|
||||
document.addEventListener('mousemove', onDrag)
|
||||
document.addEventListener('mouseup', stopDrag)
|
||||
document.addEventListener('touchmove', onDrag, { passive: false })
|
||||
document.addEventListener('touchend', stopDrag)
|
||||
// Capture pointer to receive all subsequent events regardless of stopPropagation
|
||||
const target = event.currentTarget as HTMLElement
|
||||
target.setPointerCapture(event.pointerId)
|
||||
|
||||
onDrag(event)
|
||||
// Process initial position
|
||||
updateValue(event)
|
||||
}
|
||||
|
||||
const onDrag = (event: MouseEvent | TouchEvent) => {
|
||||
const onDrag = (event: PointerEvent) => {
|
||||
if (!dragging.value) return
|
||||
event.stopPropagation()
|
||||
updateValue(event)
|
||||
}
|
||||
|
||||
const updateValue = (event: PointerEvent) => {
|
||||
if (!trackEl.value || !dragging.value) return
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
const clientX = 'touches' in event ? event.touches[0].clientX : event.clientX
|
||||
const rect = trackEl.value.getBoundingClientRect()
|
||||
const percent = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width))
|
||||
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)
|
||||
|
||||
@@ -127,17 +136,21 @@ const onWheel = (event: WheelEvent) => {
|
||||
emit('update:value', newValue)
|
||||
}
|
||||
|
||||
const stopDrag = () => {
|
||||
dragging.value = false
|
||||
document.removeEventListener('mousemove', onDrag)
|
||||
document.removeEventListener('mouseup', stopDrag)
|
||||
document.removeEventListener('touchmove', onDrag)
|
||||
document.removeEventListener('touchend', stopDrag)
|
||||
}
|
||||
const stopDrag = (event?: PointerEvent) => {
|
||||
if (!dragging.value) return
|
||||
|
||||
onUnmounted(() => {
|
||||
stopDrag()
|
||||
})
|
||||
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>
|
||||
@@ -146,6 +159,8 @@ onUnmounted(() => {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
user-select: none;
|
||||
cursor: default !important;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.single-slider.disabled {
|
||||
@@ -154,7 +169,7 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.single-slider.is-dragging {
|
||||
cursor: grabbing;
|
||||
cursor: ew-resize !important;
|
||||
}
|
||||
|
||||
.slider-track {
|
||||
@@ -165,6 +180,7 @@ onUnmounted(() => {
|
||||
height: 4px;
|
||||
background: var(--comfy-input-bg, #333);
|
||||
border-radius: 2px;
|
||||
cursor: default !important;
|
||||
}
|
||||
|
||||
.slider-track__bg {
|
||||
@@ -196,12 +212,9 @@ onUnmounted(() => {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
transform: translateX(-50%);
|
||||
cursor: grab;
|
||||
cursor: ew-resize !important;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.slider-handle:active {
|
||||
cursor: grabbing;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.slider-handle__thumb {
|
||||
|
||||
Reference in New Issue
Block a user