From 375cff78cb6fefa9d4cd84d0920166795379f5f7 Mon Sep 17 00:00:00 2001 From: m4xx1m Date: Thu, 12 Nov 2026 20:26:31 +0300 Subject: [PATCH] feat(ui): add mouse wheel cycling for select dropdowns, range sliders, and number inputs --- frontend/package.json | 2 +- frontend/src/main.ts | 3 + frontend/src/wheel.test.ts | 39 +++++++++ frontend/src/wheel.ts | 165 +++++++++++++++++++++++++++++++++++++ 4 files changed, 208 insertions(+), 1 deletion(-) create mode 100644 frontend/src/wheel.test.ts create mode 100644 frontend/src/wheel.ts diff --git a/frontend/package.json b/frontend/package.json index bf32bfd..9bd31cc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "vite", "build": "tsc --noEmit && vite build", - "test": "node --experimental-strip-types --test src/timecode.test.ts" + "test": "node --experimental-strip-types --test src/timecode.test.ts src/wheel.test.ts" }, "devDependencies": { "typescript": "^5.7.2", diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 15c04e0..f528acd 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -1,6 +1,7 @@ import './style.css'; import { dict, type Lang } from './i18n.ts'; import { clampTrimRange, formatBytes, formatTimecode, parseTimecode } from './timecode.ts'; +import { bindWheelControls } from './wheel.ts'; import type { AppInfo, ConvertRequest, @@ -1283,6 +1284,8 @@ function bindEvents() { el<'button'>('playOutBtn').addEventListener('click', () => { if (state.donePath) void api?.OpenOutputFile(state.donePath); }); + + bindWheelControls(); } function setupWailsEvents() { diff --git a/frontend/src/wheel.test.ts b/frontend/src/wheel.test.ts new file mode 100644 index 0000000..054ae21 --- /dev/null +++ b/frontend/src/wheel.test.ts @@ -0,0 +1,39 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { WheelStepper, nextEnabledOptionIndex, stepNumericValue } from './wheel.ts'; + +describe('wheel helpers', () => { + it('triggers immediately on mouse wheel notch and accumulates touchpad micro-deltas', () => { + const stepper = new WheelStepper(40, 250); + assert.equal(stepper.feed({ deltaY: 100, nowMs: 1000 }), 1); + assert.equal(stepper.feed({ deltaY: -100, nowMs: 1050 }), -1); + + // Touchpad micro-events accumulate until threshold + assert.equal(stepper.feed({ deltaY: 15, nowMs: 1100 }), 0); + assert.equal(stepper.feed({ deltaY: 15, nowMs: 1120 }), 0); + assert.equal(stepper.feed({ deltaY: 15, nowMs: 1140 }), 1); + + // Reversing direction clears prior partial delta + assert.equal(stepper.feed({ deltaY: 20, nowMs: 1200 }), 0); + assert.equal(stepper.feed({ deltaY: -25, nowMs: 1220 }), 0); + assert.equal(stepper.feed({ deltaY: -20, nowMs: 1240 }), -1); + + // Idle reset discards stale delta + assert.equal(stepper.feed({ deltaY: 30, nowMs: 1300 }), 0); + assert.equal(stepper.feed({ deltaY: 20, nowMs: 1700 }), 0); + }); + + it('skips disabled options and clamps at bounds', () => { + const flags = [false, true, false, false]; + assert.equal(nextEnabledOptionIndex(0, flags, 1), 2); + assert.equal(nextEnabledOptionIndex(2, flags, -1), 0); + assert.equal(nextEnabledOptionIndex(0, flags, -1), 0); + assert.equal(nextEnabledOptionIndex(3, flags, 1), 3); + }); + + it('steps numeric values cleanly without float drift', () => { + assert.equal(stepNumericValue(1.1, 0.05, 0.25, 8, 1), 1.15); + assert.equal(stepNumericValue(0.25, 0.05, 0.25, 8, -1), 0.25); + assert.equal(stepNumericValue(51, 1, 0, 51, 1), 51); + }); +}); diff --git a/frontend/src/wheel.ts b/frontend/src/wheel.ts new file mode 100644 index 0000000..3c482cb --- /dev/null +++ b/frontend/src/wheel.ts @@ -0,0 +1,165 @@ +/** + * Pure wheel-delta accumulator and control-stepping helpers for mouse-wheel + * switching on select, range, and number inputs. + */ + +export interface WheelSample { + deltaY: number; + deltaX?: number; + deltaMode?: number; + nowMs?: number; +} + +export class WheelStepper { + private accum = 0; + private lastMs = 0; + private readonly threshold: number; + private readonly resetMs: number; + + constructor(threshold = 40, resetMs = 250) { + this.threshold = threshold; + this.resetMs = resetMs; + } + + /** + * Feeds a wheel event and returns +1 (down/next), -1 (up/prev), or 0 (not enough delta yet). + */ + feed(sample: WheelSample): 1 | -1 | 0 { + const now = sample.nowMs ?? Date.now(); + if (this.lastMs > 0 && now - this.lastMs > this.resetMs) { + this.accum = 0; + } + this.lastMs = now; + + const dx = sample.deltaX ?? 0; + let delta = Math.abs(sample.deltaY) >= Math.abs(dx) ? sample.deltaY : dx; + if (delta === 0) return 0; + + if (sample.deltaMode === 1) delta *= 33; + else if (sample.deltaMode === 2) delta *= 100; + + if (this.accum !== 0 && Math.sign(delta) !== Math.sign(this.accum)) { + this.accum = 0; + } + + this.accum += delta; + if (Math.abs(this.accum) < this.threshold) { + return 0; + } + + const dir: 1 | -1 = this.accum > 0 ? 1 : -1; + this.accum = 0; + return dir; + } +} + +export function nextEnabledOptionIndex( + currentIndex: number, + disabledFlags: readonly boolean[], + dir: 1 | -1 +): number { + let idx = currentIndex + dir; + while (idx >= 0 && idx < disabledFlags.length && disabledFlags[idx]) { + idx += dir; + } + if (idx < 0 || idx >= disabledFlags.length) { + return currentIndex; + } + return idx; +} + +export function stepNumericValue( + current: number, + step: number, + min: number, + max: number, + dir: 1 | -1 +): number { + const safeStep = Number.isFinite(step) && step > 0 ? step : 1; + const raw = current + dir * safeStep; + const clamped = Math.max(min, Math.min(max, raw)); + // Round to avoid binary floating-point drift on fractional steps (e.g. 0.05) + return Number(clamped.toFixed(6)); +} + +function attachWheelStep( + el: HTMLElement, + isDisabled: () => boolean, + onStep: (dir: 1 | -1) => void +) { + const stepper = new WheelStepper(); + el.addEventListener( + 'wheel', + (e: WheelEvent) => { + if (isDisabled()) return; + e.preventDefault(); + e.stopPropagation(); + + const dir = stepper.feed({ + deltaY: e.deltaY, + deltaX: e.deltaX, + deltaMode: e.deltaMode, + }); + if (dir !== 0) { + onStep(dir); + } + }, + { passive: false } + ); +} + +export function bindWheelControls(root: ParentNode = document) { + root.querySelectorAll('select').forEach((sel) => { + attachWheelStep( + sel, + () => sel.disabled || sel.options.length <= 1, + (dir) => { + const disabled = Array.from(sel.options, (opt) => opt.disabled); + const nextIdx = nextEnabledOptionIndex(sel.selectedIndex, disabled, dir); + if (nextIdx !== sel.selectedIndex) { + sel.selectedIndex = nextIdx; + sel.dispatchEvent(new Event('change', { bubbles: true })); + } + } + ); + }); + + root.querySelectorAll('input[type="range"]').forEach((rng) => { + attachWheelStep( + rng, + () => rng.disabled, + (dir) => { + const step = Number(rng.step) || 1; + const min = rng.min !== '' ? Number(rng.min) : 0; + const max = rng.max !== '' ? Number(rng.max) : 100; + const cur = Number(rng.value); + // Wheel up (dir === -1) increases slider value; wheel down (dir === 1) decreases it. + const next = stepNumericValue(cur, step, min, max, dir === -1 ? 1 : -1); + if (next !== cur) { + rng.value = String(next); + rng.dispatchEvent(new Event('input', { bubbles: true })); + rng.dispatchEvent(new Event('change', { bubbles: true })); + } + } + ); + }); + + root.querySelectorAll('input[type="number"]').forEach((inp) => { + attachWheelStep( + inp, + () => inp.disabled || inp.readOnly, + (dir) => { + const step = Number(inp.step) || 1; + const min = inp.min !== '' ? Number(inp.min) : -Infinity; + const max = inp.max !== '' ? Number(inp.max) : Infinity; + const cur = Number(inp.value) || 0; + const next = stepNumericValue(cur, step, min, max, dir === -1 ? 1 : -1); + if (next !== cur) { + inp.value = String(next); + inp.dispatchEvent(new Event('input', { bubbles: true })); + inp.dispatchEvent(new Event('change', { bubbles: true })); + } + } + ); + }); +}