mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-05 12:44:23 +00:00
Compare commits
3 Commits
bl/posthog
...
rizumu/scr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b452309c0a | ||
|
|
96890a0a49 | ||
|
|
bb0293a4e8 |
@@ -1,52 +1,55 @@
|
||||
<template>
|
||||
<div
|
||||
ref="container"
|
||||
class="flex h-7 rounded-lg bg-component-node-widget-background text-xs text-component-node-foreground"
|
||||
class="relative flex h-7 rounded-lg bg-component-node-widget-background text-xs text-component-node-foreground"
|
||||
>
|
||||
<slot name="background" />
|
||||
<Button
|
||||
v-if="!hideButtons"
|
||||
:aria-label="t('g.decrement')"
|
||||
data-testid="decrement"
|
||||
class="aspect-8/7 h-full rounded-r-none hover:bg-base-foreground/20 disabled:opacity-30"
|
||||
:class="
|
||||
cn(
|
||||
'aspect-8/7 h-full rounded-r-none hover:bg-base-foreground/20 disabled:opacity-30',
|
||||
dragging && 'opacity-0!'
|
||||
)
|
||||
"
|
||||
variant="muted-textonly"
|
||||
:disabled="!canDecrement"
|
||||
tabindex="-1"
|
||||
@click="modelValue = clamp(modelValue - step)"
|
||||
@click="scrub.setValue(clamp(modelValue - step))"
|
||||
>
|
||||
<i class="pi pi-minus" />
|
||||
</Button>
|
||||
<div class="relative my-0.25 min-w-[4ch] flex-1 py-1.5">
|
||||
<div class="relative my-px min-w-[4ch] flex-1 py-1.5">
|
||||
<input
|
||||
ref="inputField"
|
||||
v-bind="inputAttrs"
|
||||
:value="displayValue ?? modelValue"
|
||||
:disabled
|
||||
:class="
|
||||
cn(
|
||||
'absolute inset-0 truncate border-0 bg-transparent p-1 text-sm focus:outline-0'
|
||||
)
|
||||
"
|
||||
class="absolute inset-0 truncate border-0 bg-transparent p-1 text-sm focus:outline-0"
|
||||
inputmode="decimal"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
@blur="handleBlur"
|
||||
@keyup.enter="handleBlur"
|
||||
@keydown.up.prevent="updateValueBy(step)"
|
||||
@keydown.down.prevent="updateValueBy(-step)"
|
||||
@keydown.page-up.prevent="updateValueBy(10 * step)"
|
||||
@keydown.page-down.prevent="updateValueBy(-10 * step)"
|
||||
@keydown.up.prevent="scrub.setValue(clamp(modelValue + step))"
|
||||
@keydown.down.prevent="scrub.setValue(clamp(modelValue - step))"
|
||||
@keydown.page-up.prevent="scrub.setValue(clamp(modelValue + 10 * step))"
|
||||
@keydown.page-down.prevent="
|
||||
scrub.setValue(clamp(modelValue - 10 * step))
|
||||
"
|
||||
/>
|
||||
<div
|
||||
ref="swipeElement"
|
||||
:class="
|
||||
cn(
|
||||
'absolute inset-0 z-10 cursor-ew-resize touch-pan-y',
|
||||
'absolute inset-0 z-10 touch-pan-y',
|
||||
dragging ? 'cursor-grabbing' : 'cursor-ew-resize',
|
||||
textEdit && 'pointer-events-none hidden'
|
||||
)
|
||||
"
|
||||
@pointerup="handlePointerUp"
|
||||
/>
|
||||
</div>
|
||||
<slot />
|
||||
@@ -54,25 +57,73 @@
|
||||
v-if="!hideButtons"
|
||||
:aria-label="t('g.increment')"
|
||||
data-testid="increment"
|
||||
class="aspect-8/7 h-full rounded-l-none hover:bg-base-foreground/20 disabled:opacity-30"
|
||||
:class="
|
||||
cn(
|
||||
'aspect-8/7 h-full rounded-l-none hover:bg-base-foreground/20 disabled:opacity-30',
|
||||
dragging && 'opacity-0!'
|
||||
)
|
||||
"
|
||||
variant="muted-textonly"
|
||||
:disabled="!canIncrement"
|
||||
tabindex="-1"
|
||||
@click="modelValue = clamp(modelValue + step)"
|
||||
@click="scrub.setValue(clamp(modelValue + step))"
|
||||
>
|
||||
<i class="pi pi-plus" />
|
||||
</Button>
|
||||
<BallRuler
|
||||
v-if="dragging"
|
||||
:state="scrub.state"
|
||||
:width="containerWidth"
|
||||
:min
|
||||
:max
|
||||
:has-bar="barVisible"
|
||||
/>
|
||||
<div
|
||||
v-if="dragging"
|
||||
class="pointer-events-none absolute inset-0 text-component-node-foreground"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<i
|
||||
class="absolute top-0 left-1/2 icon-[lucide--chevron-up] size-3 -translate-x-1/2 opacity-25"
|
||||
/>
|
||||
<i
|
||||
class="absolute bottom-0 left-1/2 icon-[lucide--chevron-down] size-3 -translate-x-1/2 opacity-25"
|
||||
/>
|
||||
<i
|
||||
class="absolute top-1/2 left-0 icon-[lucide--chevron-left] size-3 -translate-y-1/2 opacity-25"
|
||||
/>
|
||||
<i
|
||||
class="absolute top-1/2 right-0 icon-[lucide--chevron-right] size-3 -translate-y-1/2 opacity-25"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onClickOutside, usePointerSwipe, whenever } from '@vueuse/core'
|
||||
import { computed, ref, useTemplateRef } from 'vue'
|
||||
import { onClickOutside, useElementSize } from '@vueuse/core'
|
||||
import { clamp as _clamp } from 'es-toolkit'
|
||||
import { computed, ref, useTemplateRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import BallRuler from './scrubableNumberInput/BallRuler.vue'
|
||||
import { useDragGesture } from './scrubableNumberInput/useDragGesture'
|
||||
import { useScrubValue } from './scrubableNumberInput/useScrubValue'
|
||||
|
||||
// ---- Tunable sensitivity envelope -------------------------------------------
|
||||
// They translate the Y-axis sensitivity range into
|
||||
// concrete drag-distance promises so the bounds feel meaningful:
|
||||
// - DRAG_PX_FOR_FULL_RANGE: at *max* sensitivity, dragging this many screen
|
||||
// pixels traverses the entire [min, max] range. Smaller = faster, easier
|
||||
// to overshoot. Larger = more conservative, harder to overshoot.
|
||||
// - DRAG_PX_PER_STEP_AT_FLOOR: at *min* sensitivity, dragging this many
|
||||
// screen pixels advances by exactly one `step`. Larger = finer control
|
||||
// when dialing slow. Smaller = floor isn't as fine.
|
||||
const DRAG_PX_FOR_FULL_RANGE = 250
|
||||
const DRAG_PX_PER_STEP_AT_FLOOR = 25
|
||||
|
||||
const {
|
||||
min = -Number.MAX_VALUE,
|
||||
max = Number.MAX_VALUE,
|
||||
@@ -97,17 +148,90 @@ const modelValue = defineModel<number>({ default: 0 })
|
||||
|
||||
const container = useTemplateRef<HTMLDivElement>('container')
|
||||
const inputField = useTemplateRef<HTMLInputElement>('inputField')
|
||||
const swipeElement = useTemplateRef('swipeElement')
|
||||
const swipeElement = useTemplateRef<HTMLDivElement>('swipeElement')
|
||||
const textEdit = ref(false)
|
||||
|
||||
// useElementSize is backed by ResizeObserver.contentRect, which reports in
|
||||
// logical CSS pixels and ignores ancestor transforms (canvas zoom). That's
|
||||
// exactly the unit the SVG and the bar fill's `width: %` both render in, so
|
||||
// no zoom compensation is needed anywhere.
|
||||
const { width: containerWidth } = useElementSize(container)
|
||||
|
||||
const hasFiniteRange = computed(
|
||||
() => Number.isFinite(min) && Number.isFinite(max) && max > min
|
||||
)
|
||||
const barVisible = computed(
|
||||
() => hasFiniteRange.value && containerWidth.value > 0
|
||||
)
|
||||
|
||||
function clamp(value: number): number {
|
||||
return _clamp(value, min, max)
|
||||
}
|
||||
function quantize(value: number): number {
|
||||
return step > 0 ? Math.round(value / step) * step : value
|
||||
}
|
||||
function validate(value: number): number {
|
||||
return clamp(quantize(value))
|
||||
}
|
||||
|
||||
const stepSize = computed(() => (step > 0 ? step : 1))
|
||||
|
||||
const scrub = useScrubValue({
|
||||
initial: modelValue.value,
|
||||
// Step-based sensitivity: at speedMult=1, one step per screen pixel of
|
||||
// drag. Intentionally independent of canvas zoom and input-field width —
|
||||
// pointer-lock hides the cursor, so there's no "drag full bar = full range"
|
||||
// affordance to preserve.
|
||||
baseSpeed: stepSize,
|
||||
// Floor: at minimum sensitivity, advancing one step takes
|
||||
// DRAG_PX_PER_STEP_AT_FLOOR pixels. Derived: step * speedMult = step / px
|
||||
// ⇒ speedMult = 1 / px.
|
||||
minSpeed: 1 / DRAG_PX_PER_STEP_AT_FLOOR,
|
||||
// Ceiling: at maximum sensitivity, traversing the entire range takes
|
||||
// DRAG_PX_FOR_FULL_RANGE pixels. Derived: step * speedMult * px = range
|
||||
// ⇒ speedMult = range / (step * px). Falls back to a generous constant
|
||||
// when the range is unbounded (no calibration target).
|
||||
maxSpeed: computed(() =>
|
||||
hasFiniteRange.value
|
||||
? (max - min) / (stepSize.value * DRAG_PX_FOR_FULL_RANGE)
|
||||
: 1000
|
||||
),
|
||||
validate: (v) => (hasFiniteRange.value ? validate(v) : v),
|
||||
onChange: (v) => {
|
||||
modelValue.value = v
|
||||
}
|
||||
})
|
||||
|
||||
watch(modelValue, (v) => {
|
||||
if (v !== scrub.state.value) scrub.setValue(v)
|
||||
})
|
||||
|
||||
const { dragging } = useDragGesture(swipeElement, {
|
||||
disabled: computed(() => disabled || textEdit.value),
|
||||
lockPointer: true,
|
||||
// A plain click should focus the input — not hide the cursor. So pointer
|
||||
// lock and onDragStart only fire once the user has *committed* to a scrub:
|
||||
// either by crossing the movement threshold, or by holding still past the
|
||||
// long-press delay (matches Tweeq's default).
|
||||
dragDelaySeconds: 0.5,
|
||||
// Drag always modifies from the *current* value — no jump-to-click. The
|
||||
// gesture deltas via interpretGesture are inherently relative.
|
||||
onDragStart: () => scrub.reset(),
|
||||
onDrag: (dx, dy) => scrub.apply(dx, dy),
|
||||
onDragEnd: () => {
|
||||
if (step > 0) scrub.setValue(validate(scrub.state.value))
|
||||
},
|
||||
onClick: () => {
|
||||
textEdit.value = true
|
||||
inputField.value?.focus()
|
||||
inputField.value?.select()
|
||||
}
|
||||
})
|
||||
|
||||
onClickOutside(container, () => {
|
||||
if (textEdit.value) textEdit.value = false
|
||||
})
|
||||
|
||||
function clamp(value: number): number {
|
||||
return Math.min(max, Math.max(min, value))
|
||||
}
|
||||
|
||||
const canDecrement = computed(() => modelValue.value > min && !disabled)
|
||||
const canIncrement = computed(() => modelValue.value < max && !disabled)
|
||||
|
||||
@@ -120,34 +244,10 @@ function handleBlur(e: Event) {
|
||||
? undefined
|
||||
: Number(raw)
|
||||
if (parsed != null && !isNaN(parsed)) {
|
||||
modelValue.value = clamp(parsed)
|
||||
scrub.setValue(clamp(parsed))
|
||||
} else {
|
||||
target.value = displayValue ?? String(modelValue.value)
|
||||
}
|
||||
textEdit.value = false
|
||||
}
|
||||
|
||||
let dragDelta = 0
|
||||
function handlePointerUp() {
|
||||
if (isSwiping.value) return
|
||||
|
||||
textEdit.value = true
|
||||
inputField.value?.focus()
|
||||
inputField.value?.select()
|
||||
}
|
||||
|
||||
const { distanceX, isSwiping } = usePointerSwipe(swipeElement, {
|
||||
onSwipeEnd: () => (dragDelta = 0)
|
||||
})
|
||||
|
||||
whenever(distanceX, () => {
|
||||
if (disabled) return
|
||||
const delta = ((distanceX.value - dragDelta) / 10) | 0
|
||||
dragDelta += delta * 10
|
||||
modelValue.value = clamp(modelValue.value - delta * step)
|
||||
})
|
||||
|
||||
function updateValueBy(delta: number) {
|
||||
modelValue.value = Math.min(max, Math.max(min, modelValue.value + delta))
|
||||
}
|
||||
</script>
|
||||
|
||||
70
src/components/common/scrubableNumberInput/BallRuler.vue
Normal file
70
src/components/common/scrubableNumberInput/BallRuler.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { ScrubState } from './useScrubValue'
|
||||
|
||||
const {
|
||||
state,
|
||||
width,
|
||||
min,
|
||||
max,
|
||||
hasBar = false
|
||||
} = defineProps<{
|
||||
state: Readonly<ScrubState>
|
||||
/** Container width in *logical* CSS pixels (post-zoom dimensions divided by zoom). */
|
||||
width: number
|
||||
min: number
|
||||
max: number
|
||||
/** Anchor mode: bar mode pins a ball to the handle; free mode pins to center at value=0. */
|
||||
hasBar?: boolean
|
||||
}>()
|
||||
|
||||
function mod(a: number, n: number): number {
|
||||
return ((a % n) + n) % n
|
||||
}
|
||||
|
||||
function smoothstep(a: number, b: number, x: number): number {
|
||||
const t = Math.max(0, Math.min(1, (x - a) / (b - a)))
|
||||
return t * t * (3 - 2 * t)
|
||||
}
|
||||
|
||||
const dashOffset = computed(() =>
|
||||
hasBar
|
||||
? ((state.value - min) / (max - min)) * width
|
||||
: width / 2 - state.value / state.speedMult
|
||||
)
|
||||
|
||||
const layers = computed(() =>
|
||||
Array.from({ length: 3 }, (_, i) => {
|
||||
const precision = mod(-Math.log10(state.speedMult) + i, 3)
|
||||
return {
|
||||
precision,
|
||||
gap: Math.pow(10, precision),
|
||||
opacity: Math.pow(smoothstep(1, 2, precision), 0.5)
|
||||
}
|
||||
}).filter((layer) => layer.opacity >= 0.01)
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
class="pointer-events-none absolute inset-0 size-full overflow-hidden rounded-lg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<line
|
||||
v-for="layer in layers"
|
||||
:key="layer.precision"
|
||||
:x1="0"
|
||||
:x2="width"
|
||||
y1="50%"
|
||||
y2="50%"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
:stroke="`color-mix(in srgb, var(--p-primary-color), var(--p-text-muted-color) ${state.weight * 100}%)`"
|
||||
:stroke-width="4 - state.weight"
|
||||
:stroke-dasharray="`0 ${layer.gap}`"
|
||||
:stroke-dashoffset="-dashOffset"
|
||||
:opacity="layer.opacity"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { GestureState } from './interpretGesture'
|
||||
import { interpretGesture } from './interpretGesture'
|
||||
|
||||
function baseState(overrides: Partial<GestureState> = {}): GestureState {
|
||||
return {
|
||||
dirAvg: [1, 0],
|
||||
speedMult: 1,
|
||||
baseSpeed: 1,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('interpretGesture', () => {
|
||||
it('pure horizontal drag → all delta becomes value change, sensitivity unchanged', () => {
|
||||
const r = interpretGesture(baseState(), 10, 0)
|
||||
expect(r.weight).toBe(1)
|
||||
expect(r.valueDelta).toBe(10)
|
||||
expect(r.speedMultNext).toBe(1)
|
||||
})
|
||||
|
||||
it('pure vertical drag → no value change, sensitivity scales by 0.98^dy', () => {
|
||||
const r = interpretGesture(baseState({ dirAvg: [0, 1] }), 0, 10)
|
||||
expect(r.weight).toBe(0)
|
||||
expect(r.valueDelta).toBe(0)
|
||||
expect(r.speedMultNext).toBeCloseTo(Math.pow(0.98, 10), 10)
|
||||
})
|
||||
|
||||
it('zero delta is a no-op for value and sensitivity', () => {
|
||||
const r = interpretGesture(baseState(), 0, 0)
|
||||
expect(r.valueDelta).toBe(0)
|
||||
expect(r.speedMultNext).toBe(1)
|
||||
})
|
||||
|
||||
it('respects modifierSpeed', () => {
|
||||
const r = interpretGesture(baseState({ modifierSpeed: 10 }), 5, 0)
|
||||
expect(r.valueDelta).toBe(50)
|
||||
})
|
||||
|
||||
it('clamps speedMult to [minSpeed, maxSpeed]', () => {
|
||||
const veryFast = interpretGesture(
|
||||
baseState({ dirAvg: [0, 1], speedMult: 0.5, minSpeed: 0.1, maxSpeed: 1 }),
|
||||
0,
|
||||
-1000
|
||||
)
|
||||
expect(veryFast.speedMultNext).toBe(1)
|
||||
|
||||
const verySlow = interpretGesture(
|
||||
baseState({ dirAvg: [0, 1], speedMult: 0.5, minSpeed: 0.1, maxSpeed: 1 }),
|
||||
0,
|
||||
1000
|
||||
)
|
||||
expect(verySlow.speedMultNext).toBe(0.1)
|
||||
})
|
||||
|
||||
it('diagonal drag blends value and sensitivity proportionally', () => {
|
||||
// Slightly-vertical-leaning dirAvg with small deltas → normalized x
|
||||
// component lands in the smoothstep transition zone (0.4..0.6).
|
||||
const r = interpretGesture(baseState({ dirAvg: [0.5, 0.866] }), 1, 2)
|
||||
expect(r.weight).toBeGreaterThan(0)
|
||||
expect(r.weight).toBeLessThan(1)
|
||||
expect(r.valueDelta).toBeGreaterThan(0)
|
||||
expect(r.valueDelta).toBeLessThan(1)
|
||||
expect(r.speedMultNext).toBeLessThan(1)
|
||||
})
|
||||
|
||||
it('dirAvgNext is normalized (unit length)', () => {
|
||||
const r = interpretGesture(baseState(), 7, 13)
|
||||
expect(Math.hypot(...r.dirAvgNext)).toBeCloseTo(1, 10)
|
||||
})
|
||||
|
||||
it('is deterministic — same inputs produce same outputs', () => {
|
||||
const a = interpretGesture(baseState({ speedMult: 0.3 }), 4, -2)
|
||||
const b = interpretGesture(baseState({ speedMult: 0.3 }), 4, -2)
|
||||
expect(a).toEqual(b)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Pure algorithmic core of the Tweeq-style drag scrub. No Vue, no DOM.
|
||||
*
|
||||
* Given a smoothed direction average and the current sensitivity multiplier,
|
||||
* decides how much of an incoming pointer delta becomes a value change
|
||||
* (X-axis intent) vs. a sensitivity change (Y-axis intent), with a smooth
|
||||
* crossfade between the two so diagonal motion never feels jerky.
|
||||
*/
|
||||
|
||||
export interface GestureState {
|
||||
/** EMA of |delta|, normalized to unit length. */
|
||||
dirAvg: [number, number]
|
||||
/** Current sensitivity multiplier. */
|
||||
speedMult: number
|
||||
/** Value-units per pixel at speedMult = 1. */
|
||||
baseSpeed: number
|
||||
/** Lower clamp on speedMult. Default 1e-4. */
|
||||
minSpeed?: number
|
||||
/** Upper clamp on speedMult. Default 1 (bounded) or 1000 (unbounded). */
|
||||
maxSpeed?: number
|
||||
/** Extra multiplier from external sources (modifier keys). Default 1. */
|
||||
modifierSpeed?: number
|
||||
}
|
||||
|
||||
interface GestureUpdate {
|
||||
dirAvgNext: [number, number]
|
||||
/** How "horizontal" the gesture is, ∈ [0, 1]. */
|
||||
weight: number
|
||||
/** Amount to add to the value this tick. */
|
||||
valueDelta: number
|
||||
/** New sensitivity multiplier (already clamped). */
|
||||
speedMultNext: number
|
||||
}
|
||||
|
||||
export function interpretGesture(
|
||||
state: GestureState,
|
||||
dx: number,
|
||||
dy: number
|
||||
): GestureUpdate {
|
||||
const absX = Math.abs(dx)
|
||||
const absY = Math.abs(dy)
|
||||
const dirAvgNext = normalize([
|
||||
state.dirAvg[0] * 0.9 + absX * 0.1,
|
||||
state.dirAvg[1] * 0.9 + absY * 0.1
|
||||
])
|
||||
|
||||
const weight = smoothstep(0.4, 0.6, Math.abs(dirAvgNext[0]))
|
||||
|
||||
const modifierSpeed = state.modifierSpeed ?? 1
|
||||
const valueDelta =
|
||||
dx * state.baseSpeed * state.speedMult * modifierSpeed * weight
|
||||
|
||||
const speedMultRaw = state.speedMult * Math.pow(0.98, dy)
|
||||
const speedMultNext = clamp(
|
||||
speedMultRaw * (1 - weight) + state.speedMult * weight,
|
||||
state.minSpeed ?? 1e-4,
|
||||
state.maxSpeed ?? 1
|
||||
)
|
||||
|
||||
return { dirAvgNext, weight, valueDelta, speedMultNext }
|
||||
}
|
||||
|
||||
function smoothstep(a: number, b: number, x: number): number {
|
||||
const t = Math.max(0, Math.min(1, (x - a) / (b - a)))
|
||||
return t * t * (3 - 2 * t)
|
||||
}
|
||||
|
||||
function normalize([x, y]: readonly [number, number]): [number, number] {
|
||||
const m = Math.hypot(x, y) || 1
|
||||
return [x / m, y / m]
|
||||
}
|
||||
|
||||
function clamp(v: number, a: number, b: number): number {
|
||||
return Math.min(b, Math.max(a, v))
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import type { Mock } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { EffectScope } from 'vue'
|
||||
import { effectScope, ref } from 'vue'
|
||||
|
||||
import { useDragGesture } from './useDragGesture'
|
||||
|
||||
type DragOptions = NonNullable<Parameters<typeof useDragGesture>[1]>
|
||||
|
||||
// Explicitly-typed mocks so the callbacks stay assignable to DragOptions and
|
||||
// the merged setup object needs no cast (which would otherwise mask typos).
|
||||
interface Callbacks {
|
||||
onClick: Mock<(event: PointerEvent) => void>
|
||||
onDragStart: Mock<(event: PointerEvent) => void>
|
||||
onDrag: Mock<(dx: number, dy: number, event: PointerEvent) => void>
|
||||
onDragEnd: Mock<(event: PointerEvent) => void>
|
||||
}
|
||||
|
||||
function makeEvent(type: string, init: PointerEventInit = {}): PointerEvent {
|
||||
return new PointerEvent(type, {
|
||||
button: 0,
|
||||
isPrimary: true,
|
||||
pointerId: 1,
|
||||
pointerType: 'mouse',
|
||||
...init
|
||||
})
|
||||
}
|
||||
|
||||
let scope: EffectScope | null = null
|
||||
let el: HTMLElement
|
||||
let cb: Callbacks
|
||||
|
||||
function setLocked(target: Element | null) {
|
||||
;(
|
||||
document as unknown as { pointerLockElement: Element | null }
|
||||
).pointerLockElement = target
|
||||
}
|
||||
|
||||
function mount(options: Partial<DragOptions> = {}) {
|
||||
el = document.createElement('div')
|
||||
el.setPointerCapture = vi.fn()
|
||||
el.releasePointerCapture = vi.fn()
|
||||
// VueUse usePointerLock resolves the lock by awaiting pointerlockchange.
|
||||
;(el as unknown as { requestPointerLock: () => void }).requestPointerLock =
|
||||
vi.fn(() => {
|
||||
setLocked(el)
|
||||
document.dispatchEvent(new Event('pointerlockchange'))
|
||||
})
|
||||
document.body.appendChild(el)
|
||||
|
||||
cb = {
|
||||
onClick: vi.fn<(event: PointerEvent) => void>(),
|
||||
onDragStart: vi.fn<(event: PointerEvent) => void>(),
|
||||
onDrag: vi.fn<(dx: number, dy: number, event: PointerEvent) => void>(),
|
||||
onDragEnd: vi.fn<(event: PointerEvent) => void>()
|
||||
}
|
||||
scope = effectScope()
|
||||
scope.run(() => useDragGesture(ref(el), { ...cb, ...options }))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// usePointerLock checks `'pointerLockElement' in document` once at setup,
|
||||
// so the property must exist before mount(). Default to unlocked.
|
||||
Object.defineProperty(document, 'pointerLockElement', {
|
||||
value: null,
|
||||
writable: true,
|
||||
configurable: true
|
||||
})
|
||||
;(document as unknown as { exitPointerLock: () => void }).exitPointerLock =
|
||||
vi.fn(() => {
|
||||
setLocked(null)
|
||||
document.dispatchEvent(new Event('pointerlockchange'))
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
scope?.stop()
|
||||
scope = null
|
||||
el?.remove()
|
||||
vi.restoreAllMocks()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('useDragGesture', () => {
|
||||
it('treats a press with no movement as a click, not a drag', () => {
|
||||
mount()
|
||||
el.dispatchEvent(makeEvent('pointerdown', { clientX: 10, clientY: 10 }))
|
||||
el.dispatchEvent(makeEvent('pointerup', { clientX: 10, clientY: 10 }))
|
||||
|
||||
expect(cb.onClick).toHaveBeenCalledTimes(1)
|
||||
expect(cb.onDragStart).not.toHaveBeenCalled()
|
||||
expect(cb.onDrag).not.toHaveBeenCalled()
|
||||
expect(cb.onDragEnd).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('starts a drag once movement crosses the threshold and ends it on up', () => {
|
||||
mount()
|
||||
el.dispatchEvent(makeEvent('pointerdown', { clientX: 0, clientY: 0 }))
|
||||
// Below the 1px mouse threshold — still just a potential click.
|
||||
el.dispatchEvent(makeEvent('pointermove', { clientX: 0, clientY: 0 }))
|
||||
expect(cb.onDragStart).not.toHaveBeenCalled()
|
||||
|
||||
el.dispatchEvent(makeEvent('pointermove', { clientX: 10, clientY: 2 }))
|
||||
expect(cb.onDragStart).toHaveBeenCalledTimes(1)
|
||||
expect(cb.onDrag).toHaveBeenCalledTimes(1)
|
||||
|
||||
el.dispatchEvent(makeEvent('pointerup', { clientX: 10, clientY: 2 }))
|
||||
expect(cb.onDragEnd).toHaveBeenCalledTimes(1)
|
||||
expect(cb.onClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores non-primary buttons, wrong pointer types, and disabled state', () => {
|
||||
mount({ disabled: true })
|
||||
el.dispatchEvent(makeEvent('pointerdown', { clientX: 0, clientY: 0 }))
|
||||
el.dispatchEvent(makeEvent('pointerup', { clientX: 0, clientY: 0 }))
|
||||
expect(cb.onClick).not.toHaveBeenCalled()
|
||||
|
||||
scope?.stop()
|
||||
mount({ pointerType: ['mouse'] })
|
||||
el.dispatchEvent(
|
||||
makeEvent('pointerdown', { clientX: 0, clientY: 0, button: 1 })
|
||||
)
|
||||
el.dispatchEvent(makeEvent('pointerup', { clientX: 0, clientY: 0 }))
|
||||
expect(cb.onClick).not.toHaveBeenCalled()
|
||||
|
||||
el.dispatchEvent(
|
||||
makeEvent('pointerdown', { clientX: 0, clientY: 0, pointerType: 'touch' })
|
||||
)
|
||||
el.dispatchEvent(makeEvent('pointerup', { clientX: 0, clientY: 0 }))
|
||||
expect(cb.onClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fires the drag via the long-press timer without any movement', () => {
|
||||
vi.useFakeTimers()
|
||||
mount({ dragDelaySeconds: 0.5 })
|
||||
el.dispatchEvent(makeEvent('pointerdown', { clientX: 0, clientY: 0 }))
|
||||
expect(cb.onDragStart).not.toHaveBeenCalled()
|
||||
|
||||
vi.advanceTimersByTime(500)
|
||||
expect(cb.onDragStart).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('requests pointer lock on drag start and releases it on pointer up', () => {
|
||||
mount({ lockPointer: true })
|
||||
el.dispatchEvent(makeEvent('pointerdown', { clientX: 0, clientY: 0 }))
|
||||
el.dispatchEvent(makeEvent('pointermove', { clientX: 20, clientY: 0 }))
|
||||
|
||||
expect(
|
||||
(el as unknown as { requestPointerLock: ReturnType<typeof vi.fn> })
|
||||
.requestPointerLock
|
||||
).toHaveBeenCalledTimes(1)
|
||||
|
||||
el.dispatchEvent(makeEvent('pointerup', { clientX: 20, clientY: 0 }))
|
||||
expect(
|
||||
(document as unknown as { exitPointerLock: ReturnType<typeof vi.fn> })
|
||||
.exitPointerLock
|
||||
).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('uses clientX/Y deltas when not pointer-locked (touch path)', () => {
|
||||
mount()
|
||||
el.dispatchEvent(
|
||||
makeEvent('pointerdown', {
|
||||
clientX: 100,
|
||||
clientY: 100,
|
||||
pointerType: 'touch'
|
||||
})
|
||||
)
|
||||
// Cross the 5px touch threshold; movementX/Y are absent (touch).
|
||||
el.dispatchEvent(
|
||||
makeEvent('pointermove', {
|
||||
clientX: 130,
|
||||
clientY: 100,
|
||||
pointerType: 'touch'
|
||||
})
|
||||
)
|
||||
const [dx, dy] = cb.onDrag.mock.calls.at(-1)!
|
||||
expect(dx).toBe(30)
|
||||
expect(dy).toBe(0)
|
||||
})
|
||||
|
||||
it('uses movementX/Y when pointer is locked (cursor pinned)', () => {
|
||||
mount({ lockPointer: true })
|
||||
el.dispatchEvent(makeEvent('pointerdown', { clientX: 50, clientY: 50 }))
|
||||
el.dispatchEvent(makeEvent('pointermove', { clientX: 60, clientY: 50 }))
|
||||
cb.onDrag.mockClear()
|
||||
|
||||
// Lock active: clientX/Y is now pinned (unchanged), movement carries delta.
|
||||
setLocked(el)
|
||||
el.dispatchEvent(
|
||||
makeEvent('pointermove', {
|
||||
clientX: 60,
|
||||
clientY: 50,
|
||||
movementX: 8,
|
||||
movementY: -3
|
||||
})
|
||||
)
|
||||
const [dx, dy] = cb.onDrag.mock.calls.at(-1)!
|
||||
expect(dx).toBe(8)
|
||||
expect(dy).toBe(-3)
|
||||
})
|
||||
|
||||
it('ends the drag and releases pointer capture on pointercancel', () => {
|
||||
mount()
|
||||
el.dispatchEvent(makeEvent('pointerdown', { clientX: 0, clientY: 0 }))
|
||||
el.dispatchEvent(makeEvent('pointermove', { clientX: 10, clientY: 0 }))
|
||||
el.dispatchEvent(makeEvent('pointercancel', { clientX: 10, clientY: 0 }))
|
||||
|
||||
expect(cb.onDragEnd).toHaveBeenCalledTimes(1)
|
||||
expect(el.releasePointerCapture).toHaveBeenCalledWith(1)
|
||||
})
|
||||
|
||||
it('ends the drag and releases pointer capture on pointerleave', () => {
|
||||
mount()
|
||||
el.dispatchEvent(makeEvent('pointerdown', { clientX: 0, clientY: 0 }))
|
||||
el.dispatchEvent(makeEvent('pointermove', { clientX: 10, clientY: 0 }))
|
||||
el.dispatchEvent(makeEvent('pointerleave', { clientX: 10, clientY: 0 }))
|
||||
|
||||
expect(cb.onDragEnd).toHaveBeenCalledTimes(1)
|
||||
expect(el.releasePointerCapture).toHaveBeenCalledWith(1)
|
||||
})
|
||||
|
||||
it('clears the long-press timer on pointer release so it cannot fire late', () => {
|
||||
vi.useFakeTimers()
|
||||
mount({ dragDelaySeconds: 0.5 })
|
||||
el.dispatchEvent(makeEvent('pointerdown', { clientX: 0, clientY: 0 }))
|
||||
el.dispatchEvent(makeEvent('pointerup', { clientX: 0, clientY: 0 }))
|
||||
|
||||
vi.advanceTimersByTime(500)
|
||||
expect(cb.onDragStart).not.toHaveBeenCalled()
|
||||
expect(cb.onClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
155
src/components/common/scrubableNumberInput/useDragGesture.ts
Normal file
155
src/components/common/scrubableNumberInput/useDragGesture.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { useEventListener, usePointerLock } from '@vueuse/core'
|
||||
import type { MaybeRef } from 'vue'
|
||||
import { readonly, ref, unref } from 'vue'
|
||||
|
||||
type DragPointerType = 'mouse' | 'pen' | 'touch'
|
||||
|
||||
interface DragGestureOptions {
|
||||
/** Engage pointerLock for the duration of the drag. */
|
||||
lockPointer?: MaybeRef<boolean>
|
||||
disabled?: MaybeRef<boolean>
|
||||
/** Hold-to-drag delay in seconds. Set to 0 for instant drag. Default 0. */
|
||||
dragDelaySeconds?: number
|
||||
/** Pointer types accepted. Default mouse + pen + touch. */
|
||||
pointerType?: DragPointerType[]
|
||||
onDragStart?: (event: PointerEvent) => void
|
||||
/** dx/dy are in physical pixels with browser-zoom compensated out. */
|
||||
onDrag?: (dx: number, dy: number, event: PointerEvent) => void
|
||||
onDragEnd?: (event: PointerEvent) => void
|
||||
/** Fires when the pointer is released without ever crossing the drag threshold. */
|
||||
onClick?: (event: PointerEvent) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* DOM-only pointer wrangling. Hides ~100 lines of finicky pointer-event
|
||||
* choreography behind a 4-callback interface:
|
||||
* - pointer capture so the drag survives leaving the element
|
||||
* - optional pointer lock (for unbounded scrubbing)
|
||||
* - drag-vs-click discrimination (timer + distance threshold)
|
||||
* - pointercancel / pointerleave fallbacks so onDragEnd always fires
|
||||
* - primary-button / pointer-type filtering
|
||||
* - browser zoom compensation (event.movementX scaled by 1/zoom)
|
||||
* - movementX/Y under pointer lock, clientX/Y deltas otherwise (touch)
|
||||
*
|
||||
* Consumers receive dx/dy and don't need to know any of the above exists.
|
||||
*/
|
||||
export function useDragGesture(
|
||||
target: MaybeRef<HTMLElement | null | undefined>,
|
||||
options: DragGestureOptions = {}
|
||||
): { dragging: Readonly<ReturnType<typeof ref<boolean>>> } {
|
||||
const dragging = ref(false)
|
||||
const allowedTypes = options.pointerType ?? ['mouse', 'pen', 'touch']
|
||||
const dragDelay = options.dragDelaySeconds ?? 0
|
||||
|
||||
const { lock, unlock } = usePointerLock(target)
|
||||
|
||||
let pointerId: number | null = null
|
||||
let pointerDownAt: [number, number] | null = null
|
||||
let lastClient: [number, number] = [0, 0]
|
||||
let dragDelayTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let pointerLocked = false
|
||||
|
||||
function teardown() {
|
||||
if (dragDelayTimer !== undefined) {
|
||||
clearTimeout(dragDelayTimer)
|
||||
dragDelayTimer = undefined
|
||||
}
|
||||
pointerDownAt = null
|
||||
pointerId = null
|
||||
}
|
||||
|
||||
function fireStart(event: PointerEvent) {
|
||||
dragging.value = true
|
||||
if (unref(options.lockPointer) && !pointerLocked) {
|
||||
pointerLocked = true
|
||||
void lock(event).catch(() => {
|
||||
pointerLocked = false
|
||||
})
|
||||
}
|
||||
options.onDragStart?.(event)
|
||||
}
|
||||
|
||||
function onPointerDown(event: PointerEvent) {
|
||||
if (unref(options.disabled)) return
|
||||
if (event.button !== 0 || !event.isPrimary) return
|
||||
if (!allowedTypes.includes(event.pointerType as DragPointerType)) return
|
||||
|
||||
pointerId = event.pointerId
|
||||
pointerDownAt = [event.clientX, event.clientY]
|
||||
lastClient = [event.clientX, event.clientY]
|
||||
const el = unref(target)
|
||||
el?.setPointerCapture(pointerId)
|
||||
|
||||
// Drag commitment is decided later — either by the movement-distance
|
||||
// threshold in onPointerMove, or by this long-press timer expiring while
|
||||
// the pointer is still down. Until then it's just a potential click.
|
||||
if (dragDelay === 0) return
|
||||
dragDelayTimer = setTimeout(() => fireStart(event), dragDelay * 1000)
|
||||
}
|
||||
|
||||
function onPointerMove(event: PointerEvent) {
|
||||
if (pointerId !== event.pointerId || pointerDownAt === null) return
|
||||
|
||||
if (!dragging.value) {
|
||||
// Lock engages inside fireStart, not yet — so clientX/Y is still valid
|
||||
// for the drag-vs-click distance threshold.
|
||||
const minDist = event.pointerType === 'mouse' ? 1 : 5
|
||||
const moved = Math.hypot(
|
||||
event.clientX - pointerDownAt[0],
|
||||
event.clientY - pointerDownAt[1]
|
||||
)
|
||||
if (moved < minDist) return
|
||||
if (dragDelayTimer !== undefined) {
|
||||
clearTimeout(dragDelayTimer)
|
||||
dragDelayTimer = undefined
|
||||
}
|
||||
fireStart(event)
|
||||
}
|
||||
|
||||
// Compensate for browser zoom (Cmd +/-). event.movementX/Y report in
|
||||
// device-pixel-like units that don't honor the browser zoom level; the
|
||||
// ratio outerWidth/innerWidth backs that out.
|
||||
const browserZoom = window.outerWidth / window.innerWidth || 1
|
||||
|
||||
// Under an active pointer lock the cursor is pinned, so clientX/Y stop
|
||||
// changing and movementX/Y is the only usable signal. Without a lock —
|
||||
// notably on touch, where movementX/Y is frequently 0 or undefined — fall
|
||||
// back to deltas between successive clientX/Y instead.
|
||||
const locked = document.pointerLockElement === unref(target)
|
||||
const dx =
|
||||
(locked ? event.movementX || 0 : event.clientX - lastClient[0]) /
|
||||
browserZoom
|
||||
const dy =
|
||||
(locked ? event.movementY || 0 : event.clientY - lastClient[1]) /
|
||||
browserZoom
|
||||
lastClient = [event.clientX, event.clientY]
|
||||
options.onDrag?.(dx, dy, event)
|
||||
}
|
||||
|
||||
function onPointerUp(event: PointerEvent) {
|
||||
if (pointerId !== event.pointerId) return
|
||||
const el = unref(target)
|
||||
el?.releasePointerCapture(event.pointerId)
|
||||
|
||||
const wasDragging = dragging.value
|
||||
if (pointerLocked) {
|
||||
void unlock()
|
||||
pointerLocked = false
|
||||
}
|
||||
if (wasDragging) {
|
||||
options.onDragEnd?.(event)
|
||||
} else {
|
||||
options.onClick?.(event)
|
||||
}
|
||||
dragging.value = false
|
||||
teardown()
|
||||
}
|
||||
|
||||
useEventListener(target, 'pointerdown', onPointerDown)
|
||||
useEventListener(target, 'pointermove', onPointerMove)
|
||||
useEventListener(target, 'pointerup', onPointerUp)
|
||||
useEventListener(target, 'pointercancel', onPointerUp)
|
||||
useEventListener(target, 'pointerleave', onPointerUp)
|
||||
|
||||
return { dragging: readonly(dragging) }
|
||||
}
|
||||
119
src/components/common/scrubableNumberInput/useScrubValue.ts
Normal file
119
src/components/common/scrubableNumberInput/useScrubValue.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import type { MaybeRef } from 'vue'
|
||||
import { computed, reactive, readonly, unref, watch } from 'vue'
|
||||
|
||||
import type { GestureState } from './interpretGesture'
|
||||
import { interpretGesture } from './interpretGesture'
|
||||
|
||||
interface ScrubOptions {
|
||||
/** Initial value. Pulled once during setup; later sync via setValue(). */
|
||||
initial: number
|
||||
/** Value-units per pixel at speedMult = 1. Reactive. */
|
||||
baseSpeed: MaybeRef<number>
|
||||
/** Min sensitivity multiplier. */
|
||||
minSpeed?: MaybeRef<number>
|
||||
/** Max sensitivity multiplier. */
|
||||
maxSpeed?: MaybeRef<number>
|
||||
/** External multiplier (e.g. modifier keys). */
|
||||
modifierSpeed?: MaybeRef<number>
|
||||
/** Optional post-mutation validator (clamp/quantize/snap composition). */
|
||||
validate?: (value: number) => number
|
||||
/** Notified whenever value changes from inside (apply / setValue). */
|
||||
onChange?: (value: number) => void
|
||||
}
|
||||
|
||||
export interface ScrubState {
|
||||
readonly value: number
|
||||
readonly speedMult: number
|
||||
/** Horizontality weight from the latest apply(). Useful for visuals. */
|
||||
readonly weight: number
|
||||
}
|
||||
|
||||
interface ScrubInstance {
|
||||
state: Readonly<ScrubState>
|
||||
/** Feed one pointer-tick into the scrub algorithm. */
|
||||
apply(dx: number, dy: number): void
|
||||
/** Reset gesture-internal state (speedMult, direction, weight). Does NOT touch value. */
|
||||
reset(): void
|
||||
/** Replace the current value (e.g. external model update, jump-to-click). */
|
||||
setValue(value: number): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns all mutable scrub state for a single input. Hides direction EMA,
|
||||
* accumulator, and validator chain from callers — apply/reset/setValue are
|
||||
* the only mutators.
|
||||
*
|
||||
* The raw accumulator (`internal.raw`) is intentionally NOT quantized. Tiny
|
||||
* per-frame deltas under one step would otherwise be rounded to zero and
|
||||
* the value would never move. By accumulating raw and quantizing only on
|
||||
* the way out, sub-step motion accrues until it crosses a step boundary.
|
||||
*/
|
||||
export function useScrubValue(opts: ScrubOptions): ScrubInstance {
|
||||
const internal = reactive({
|
||||
raw: opts.initial,
|
||||
speedMult: 1,
|
||||
weight: 1,
|
||||
dirAvg: [1, 0] as [number, number]
|
||||
})
|
||||
|
||||
const validated = computed(() =>
|
||||
opts.validate ? opts.validate(internal.raw) : internal.raw
|
||||
)
|
||||
|
||||
let lastEmitted = validated.value
|
||||
watch(validated, (v) => {
|
||||
if (v === lastEmitted) return
|
||||
lastEmitted = v
|
||||
opts.onChange?.(v)
|
||||
})
|
||||
|
||||
function apply(dx: number, dy: number) {
|
||||
const gestureState: GestureState = {
|
||||
dirAvg: internal.dirAvg,
|
||||
speedMult: internal.speedMult,
|
||||
baseSpeed: unref(opts.baseSpeed),
|
||||
minSpeed: unref(opts.minSpeed),
|
||||
maxSpeed: unref(opts.maxSpeed),
|
||||
modifierSpeed: unref(opts.modifierSpeed)
|
||||
}
|
||||
const { dirAvgNext, weight, valueDelta, speedMultNext } = interpretGesture(
|
||||
gestureState,
|
||||
dx,
|
||||
dy
|
||||
)
|
||||
internal.dirAvg = dirAvgNext
|
||||
internal.weight = weight
|
||||
internal.speedMult = speedMultNext
|
||||
internal.raw += valueDelta
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset transient gesture state — direction EMA and weight — that carries
|
||||
* no meaning between drags. Intentionally does NOT touch `speedMult`: the
|
||||
* Y-axis sensitivity the user dialed in during one drag persists into the
|
||||
* next, so a slip-up release doesn't force them to re-calibrate.
|
||||
*/
|
||||
function reset() {
|
||||
internal.weight = 1
|
||||
internal.dirAvg = [1, 0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the raw accumulator. Use to snap to a clean value after a drag
|
||||
* (orchestrator calls setValue(state.value) at drag end to discard any
|
||||
* sub-step residual), or to sync with an external model change.
|
||||
*/
|
||||
function setValue(value: number) {
|
||||
internal.raw = value
|
||||
}
|
||||
|
||||
const state = readonly(
|
||||
reactive({
|
||||
value: validated,
|
||||
speedMult: computed(() => internal.speedMult),
|
||||
weight: computed(() => internal.weight)
|
||||
})
|
||||
) as Readonly<ScrubState>
|
||||
|
||||
return { state, apply, reset, setValue }
|
||||
}
|
||||
Reference in New Issue
Block a user