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": {
|
"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",
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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