feat(ui): add mouse wheel cycling for select dropdowns, range sliders, and number inputs
Build Windows Installer / build (push) Successful in 10m49s
Build Windows Installer / build (push) Successful in 10m49s
This commit is contained in:
4 files changed
+208
-1
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 }));
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user