feat(ui): add mouse wheel cycling for select dropdowns, range sliders, and number inputs
Build Windows Installer / build (push) Successful in 10m49s

This commit is contained in:
m4xx1m committed 2026-11-12 20:26:31 +03:00
1 parent 478dbff654
commit 375cff78cb
4 files changed
+208 -1

No files matched your search

+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc --noEmit && vite build", "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": { "devDependencies": {
"typescript": "^5.7.2", "typescript": "^5.7.2",
+3
View File
@@ -1,6 +1,7 @@
import './style.css'; import './style.css';
import { dict, type Lang } from './i18n.ts'; import { dict, type Lang } from './i18n.ts';
import { clampTrimRange, formatBytes, formatTimecode, parseTimecode } from './timecode.ts'; import { clampTrimRange, formatBytes, formatTimecode, parseTimecode } from './timecode.ts';
import { bindWheelControls } from './wheel.ts';
import type { import type {
AppInfo, AppInfo,
ConvertRequest, ConvertRequest,
@@ -1283,6 +1284,8 @@ function bindEvents() {
el<'button'>('playOutBtn').addEventListener('click', () => { el<'button'>('playOutBtn').addEventListener('click', () => {
if (state.donePath) void api?.OpenOutputFile(state.donePath); if (state.donePath) void api?.OpenOutputFile(state.donePath);
}); });
bindWheelControls();
} }
function setupWailsEvents() { function setupWailsEvents() {
+39
View File
@@ -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);
});
});
+165
View File
@@ -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<HTMLSelectElement>('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<HTMLInputElement>('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<HTMLInputElement>('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 }));
}
}
);
});
}