Compare commits

...

1 Commits

Author SHA1 Message Date
Rizumu Ayaka
bb0293a4e8 feat: Tweeq-style drag scrubbing for ScrubableNumberInput
X-axis drags value, Y-axis adjusts sensitivity with an SVG dot-ruler
overlay showing the active precision. Pointer-lock hides the cursor
during scrub and returns it to the press position on release.
2026-05-22 19:00:58 +08:00
6 changed files with 632 additions and 49 deletions

View File

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

View 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>

View File

@@ -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)
})
})

View File

@@ -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))
}

View File

@@ -0,0 +1,141 @@
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)
*
* 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 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]
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
const dx = (event.movementX || 0) / browserZoom
const dy = (event.movementY || 0) / browserZoom
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) }
}

View 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 }
}