mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-22 13:32:11 +00:00
Compare commits
1 Commits
glary/remo
...
rizumu/scr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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))
|
||||
}
|
||||
141
src/components/common/scrubableNumberInput/useDragGesture.ts
Normal file
141
src/components/common/scrubableNumberInput/useDragGesture.ts
Normal 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) }
|
||||
}
|
||||
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