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:
Will Miao
2026-01-15 07:03:05 +08:00
parent 9ed5319ad2
commit cde6151c71
3 changed files with 205 additions and 67 deletions

View File

@@ -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.

View File

@@ -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 {

View File

@@ -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 {