diff --git a/src/components/common/ScrubableNumberInput.vue b/src/components/common/ScrubableNumberInput.vue index 2c5550457f..192c84b7f9 100644 --- a/src/components/common/ScrubableNumberInput.vue +++ b/src/components/common/ScrubableNumberInput.vue @@ -1,52 +1,55 @@ diff --git a/src/components/common/scrubableNumberInput/BallRuler.vue b/src/components/common/scrubableNumberInput/BallRuler.vue new file mode 100644 index 0000000000..c6effc79bc --- /dev/null +++ b/src/components/common/scrubableNumberInput/BallRuler.vue @@ -0,0 +1,70 @@ + + + diff --git a/src/components/common/scrubableNumberInput/interpretGesture.test.ts b/src/components/common/scrubableNumberInput/interpretGesture.test.ts new file mode 100644 index 0000000000..561d83f7b2 --- /dev/null +++ b/src/components/common/scrubableNumberInput/interpretGesture.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest' + +import type { GestureState } from './interpretGesture' +import { interpretGesture } from './interpretGesture' + +function baseState(overrides: Partial = {}): 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) + }) +}) diff --git a/src/components/common/scrubableNumberInput/interpretGesture.ts b/src/components/common/scrubableNumberInput/interpretGesture.ts new file mode 100644 index 0000000000..348b430226 --- /dev/null +++ b/src/components/common/scrubableNumberInput/interpretGesture.ts @@ -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)) +} diff --git a/src/components/common/scrubableNumberInput/useDragGesture.ts b/src/components/common/scrubableNumberInput/useDragGesture.ts new file mode 100644 index 0000000000..2495118b0c --- /dev/null +++ b/src/components/common/scrubableNumberInput/useDragGesture.ts @@ -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 + disabled?: MaybeRef + /** 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, + options: DragGestureOptions = {} +): { dragging: Readonly>> } { + 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 | 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) } +} diff --git a/src/components/common/scrubableNumberInput/useScrubValue.ts b/src/components/common/scrubableNumberInput/useScrubValue.ts new file mode 100644 index 0000000000..d1e5082b27 --- /dev/null +++ b/src/components/common/scrubableNumberInput/useScrubValue.ts @@ -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 + /** Min sensitivity multiplier. */ + minSpeed?: MaybeRef + /** Max sensitivity multiplier. */ + maxSpeed?: MaybeRef + /** External multiplier (e.g. modifier keys). */ + modifierSpeed?: MaybeRef + /** 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 + /** 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 + + return { state, apply, reset, setValue } +}