forked from akai/readest
787bbf2103
* feat(reader): add custom hardware-button page turning (#4139) Lets users bind hardware remote keys (media keys, D-pad/arrow keys) to previous/next page via a learn-mode capture UI in reader settings — an accessibility feature for page-turner remotes. - New global hardwarePageTurner system setting (enabled + key bindings). - hardwareKeys.ts: key normalization, matching, and page-turn resolution. - deviceStore: reference-counted media-key interception + learn mode. - usePagination: flips pages from bound media keys (native bridge) and D-pad/keyboard keys (DOM keydown), scoped to the active book and suppressed while the toolbar is visible. - Page Turner settings section on all platforms; web/desktop bind keys via DOM keydown only, native media-key interception stays mobile-only. - Android: intercept media + learn-mode keys in dispatchKeyEvent. - iOS: forward media keys via MPRemoteCommandCenter. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(i18n): add and translate hardware page turner strings (#4139) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(reader): refine hardware page turner (#4139) - Handle book-iframe key events (iframe-keydown messages) so custom bindings work as soon as a book is open, not only after the settings panel has been shown. - Add Previous/Next Section bindings alongside the page bindings. - Rename the hardwareKeys util to keybinding. - Wire the Page Turner section into the settings Reset action. - Drop the focus ring on the capture buttons; BoxedList gains an optional description. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(i18n): translate page turner section and key strings (#4139) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
104 lines
3.4 KiB
TypeScript
104 lines
3.4 KiB
TypeScript
import { describe, test, expect } from 'vitest';
|
|
import {
|
|
normalizeNativeKey,
|
|
normalizeDomKeyEvent,
|
|
matchesBinding,
|
|
resolvePageTurn,
|
|
} from '@/utils/keybinding';
|
|
import { HardwarePageTurnerSettings } from '@/types/settings';
|
|
|
|
describe('normalizeNativeKey', () => {
|
|
test('maps a known native key to a friendly label', () => {
|
|
expect(normalizeNativeKey('MediaNext')).toEqual({
|
|
source: 'native',
|
|
id: 'MediaNext',
|
|
label: 'Media Next',
|
|
});
|
|
});
|
|
|
|
test('falls back to the raw id for an unknown native key', () => {
|
|
expect(normalizeNativeKey('Keycode99')).toEqual({
|
|
source: 'native',
|
|
id: 'Keycode99',
|
|
label: 'Keycode99',
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('normalizeDomKeyEvent', () => {
|
|
test('uses event.code and a friendly label for a known key', () => {
|
|
const event = { code: 'ArrowLeft', key: 'ArrowLeft' } as KeyboardEvent;
|
|
expect(normalizeDomKeyEvent(event)).toEqual({
|
|
source: 'dom',
|
|
id: 'ArrowLeft',
|
|
label: 'Arrow Left',
|
|
});
|
|
});
|
|
|
|
test('falls back to event.key when event.code is empty', () => {
|
|
const event = { code: '', key: 'MediaTrackNext' } as KeyboardEvent;
|
|
expect(normalizeDomKeyEvent(event)).toEqual({
|
|
source: 'dom',
|
|
id: 'MediaTrackNext',
|
|
label: 'Media Next',
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('matchesBinding', () => {
|
|
const binding = { source: 'native' as const, id: 'MediaNext', label: 'Media Next' };
|
|
|
|
test('matches same source and id', () => {
|
|
expect(matchesBinding(binding, { source: 'native', id: 'MediaNext' })).toBe(true);
|
|
});
|
|
|
|
test('rejects different id', () => {
|
|
expect(matchesBinding(binding, { source: 'native', id: 'MediaPrevious' })).toBe(false);
|
|
});
|
|
|
|
test('rejects different source', () => {
|
|
expect(matchesBinding(binding, { source: 'dom', id: 'MediaNext' })).toBe(false);
|
|
});
|
|
|
|
test('rejects a null binding', () => {
|
|
expect(matchesBinding(null, { source: 'native', id: 'MediaNext' })).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('resolvePageTurn', () => {
|
|
const settings: HardwarePageTurnerSettings = {
|
|
enabled: true,
|
|
bindings: {
|
|
pagePrev: { source: 'native', id: 'MediaPrevious', label: 'Media Previous' },
|
|
pageNext: { source: 'native', id: 'MediaNext', label: 'Media Next' },
|
|
sectionPrev: { source: 'dom', id: 'PageUp', label: 'Page Up' },
|
|
sectionNext: { source: 'dom', id: 'PageDown', label: 'Page Down' },
|
|
},
|
|
};
|
|
|
|
test('returns "pagePrev" for the pagePrev binding', () => {
|
|
expect(resolvePageTurn(settings, { source: 'native', id: 'MediaPrevious' })).toBe('pagePrev');
|
|
});
|
|
|
|
test('returns "pageNext" for the pageNext binding', () => {
|
|
expect(resolvePageTurn(settings, { source: 'native', id: 'MediaNext' })).toBe('pageNext');
|
|
});
|
|
|
|
test('returns "sectionPrev" for the sectionPrev binding', () => {
|
|
expect(resolvePageTurn(settings, { source: 'dom', id: 'PageUp' })).toBe('sectionPrev');
|
|
});
|
|
|
|
test('returns "sectionNext" for the sectionNext binding', () => {
|
|
expect(resolvePageTurn(settings, { source: 'dom', id: 'PageDown' })).toBe('sectionNext');
|
|
});
|
|
|
|
test('returns null for an unbound key', () => {
|
|
expect(resolvePageTurn(settings, { source: 'native', id: 'MediaPlayPause' })).toBeNull();
|
|
});
|
|
|
|
test('returns null when the feature is disabled', () => {
|
|
const disabled = { ...settings, enabled: false };
|
|
expect(resolvePageTurn(disabled, { source: 'native', id: 'MediaNext' })).toBeNull();
|
|
});
|
|
});
|