mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
fix(packages): remove redundant "Shift" modifier from playback rate hotkeys (#1290)
This commit is contained in:
@@ -58,8 +58,8 @@ Each element declares one binding. To bind multiple keys to the same action, use
|
||||
<media-hotkey keys="c" action="toggleSubtitles"></media-hotkey>
|
||||
|
||||
<!-- Speed (steps through available playbackRates) -->
|
||||
<media-hotkey keys="Shift+>" action="speedUp"></media-hotkey>
|
||||
<media-hotkey keys="Shift+<" action="speedDown"></media-hotkey>
|
||||
<media-hotkey keys=">" action="speedUp"></media-hotkey>
|
||||
<media-hotkey keys="<" action="speedDown"></media-hotkey>
|
||||
|
||||
<!-- Disabled -->
|
||||
<media-hotkey keys="k" action="togglePaused" disabled></media-hotkey>
|
||||
@@ -75,7 +75,7 @@ Each element declares one binding. To bind multiple keys to the same action, use
|
||||
<MediaHotkey keys="ArrowRight" action="seekStep" value={5} />
|
||||
<MediaHotkey keys="ArrowLeft" action="seekStep" value={-5} />
|
||||
<MediaHotkey keys="ArrowUp" action="volumeStep" value={0.05} />
|
||||
<MediaHotkey keys="Shift+>" action="speedUp" />
|
||||
<MediaHotkey keys=">" action="speedUp" />
|
||||
```
|
||||
|
||||
`keys` (not `key`) avoids collision with React's reserved `key` prop.
|
||||
@@ -141,7 +141,7 @@ Key patterns use `KeyboardEvent.key` values — layout-dependent, mnemonic ("K f
|
||||
| `Space` | Space bar |
|
||||
| `ArrowLeft` | Left arrow |
|
||||
| `0-9` | Any digit key (0 through 9) |
|
||||
| `Shift+>` | Shift + > (the shifted character, not the unshifted key) |
|
||||
| `>` | > key, no modifiers (on US keyboards, produced by Shift+.) |
|
||||
| `Mod+k` | Cmd+K on macOS, Ctrl+K elsewhere |
|
||||
| `Ctrl+Shift+f` | Ctrl + Shift + F |
|
||||
|
||||
@@ -166,6 +166,7 @@ Only `0-9` is supported. Arbitrary ranges (e.g., `a-z`) are not — there's no u
|
||||
- Patterns are parsed into `{ modifiers, key }` at registration time — no parsing per event.
|
||||
- `event.key` is compared case-insensitively.
|
||||
- **Exact modifier matching** — all specified modifiers must be active, all unspecified must be inactive. `k` does not fire when Ctrl+K is pressed.
|
||||
- **Implicit Shift** — For single non-letter character keys (`>`, `<`, `?`, `!`, etc.), `shiftKey` is ignored during matching. These characters require Shift on some layouts but not others. Explicitly specifying `Shift+>` still requires Shift to be held.
|
||||
- `Mod` resolves at parse time based on platform detection (`navigator.userAgentData?.platform` with `navigator.platform` fallback).
|
||||
- IME composition input is filtered — events where `event.key === 'Unidentified'` are skipped.
|
||||
|
||||
@@ -312,7 +313,7 @@ Every major video player ships keyboard shortcuts. The bindings are remarkably c
|
||||
| Seek ±10s | `J` / `L` | YouTube, Vimeo, Media Chrome |
|
||||
| Percentage seek | `0`–`9` | YouTube, Netflix, Video.js 7 |
|
||||
| Captions | `C` | Vimeo, Media Chrome, Vidstack |
|
||||
| Speed | `Shift+>` / `Shift+<` | YouTube, VLC, Media Chrome, Vidstack |
|
||||
| Speed | `>` / `<` | YouTube, VLC, Media Chrome, Vidstack |
|
||||
| PiP | `I` or `P` | YouTube (`I`), Media Chrome (`P`) |
|
||||
| Help overlay | `Shift+?` | YouTube, Vimeo, Media Chrome |
|
||||
|
||||
@@ -351,8 +352,8 @@ Not shipped as a preset — documented as the standard set for users to compose:
|
||||
| `0-9` | `seekToPercent` | — | YouTube/Netflix. Each digit × 10%. |
|
||||
| `Home` | `seekToPercent` | `0` | Jump to start. |
|
||||
| `End` | `seekToPercent` | `100` | Jump to end. |
|
||||
| `Shift+>` | `speedUp` | — | YouTube/VLC. Steps through `playbackRates`. |
|
||||
| `Shift+<` | `speedDown` | — | YouTube/VLC. Steps through `playbackRates`. |
|
||||
| `>` | `speedUp` | — | YouTube/VLC. Steps through `playbackRates`. |
|
||||
| `<` | `speedDown` | — | YouTube/VLC. Steps through `playbackRates`. |
|
||||
|
||||
## Edge cases
|
||||
|
||||
|
||||
@@ -31,8 +31,8 @@ const MODIFIER_KEYS = new Set(['shift', 'ctrl', 'alt', 'meta']);
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* parseHotkeyPattern('Shift+>');
|
||||
* // [{ modifiers: Set('shift'), key: '>', originalKey: '>' }]
|
||||
* parseHotkeyPattern('>');
|
||||
* // [{ modifiers: Set(), key: '>', originalKey: '>' }]
|
||||
*
|
||||
* parseHotkeyPattern('0-9');
|
||||
* // 10 bindings, one per digit
|
||||
@@ -70,6 +70,16 @@ export function parseHotkeyPattern(pattern: string): ParsedHotkeyBinding[] {
|
||||
return [{ modifiers, key, originalKey: rawKey }];
|
||||
}
|
||||
|
||||
/**
|
||||
* Single non-letter character — Shift was used to produce the character itself,
|
||||
* not as a deliberate modifier (e.g. Shift+. → ">", Shift+/ → "?").
|
||||
* Letters excluded because Shift changes case intentionally (k vs K).
|
||||
* Named keys excluded because event.key.length > 1 (ArrowLeft, Tab, etc.).
|
||||
*/
|
||||
function isImplicitShift(key: string): boolean {
|
||||
return key.length === 1 && !/[a-z]/i.test(key);
|
||||
}
|
||||
|
||||
/** Whether a parsed binding matches a keyboard event. */
|
||||
export function matchesHotkeyEvent(binding: ParsedHotkeyBinding, event: KeyboardEvent): boolean {
|
||||
// IME composition filtering.
|
||||
@@ -78,8 +88,13 @@ export function matchesHotkeyEvent(binding: ParsedHotkeyBinding, event: Keyboard
|
||||
// Case-insensitive key comparison.
|
||||
if (event.key.toLowerCase() !== binding.key) return false;
|
||||
|
||||
// Implicit Shift: non-letter character keys (>, <, ?, !) may require Shift to produce
|
||||
// on some layouts but not others. Treat Shift as present only when the event has it,
|
||||
// but ignore extra shiftKey when the binding doesn't ask for it.
|
||||
const shiftKey = isImplicitShift(event.key) ? event.shiftKey && binding.modifiers.has('shift') : event.shiftKey;
|
||||
|
||||
// Exact modifier matching — all four must agree.
|
||||
if (event.shiftKey !== binding.modifiers.has('shift')) return false;
|
||||
if (shiftKey !== binding.modifiers.has('shift')) return false;
|
||||
if (event.ctrlKey !== binding.modifiers.has('ctrl')) return false;
|
||||
if (event.altKey !== binding.modifiers.has('alt')) return false;
|
||||
if (event.metaKey !== binding.modifiers.has('meta')) return false;
|
||||
|
||||
@@ -13,7 +13,7 @@ describe('toAriaKeyShortcut', () => {
|
||||
});
|
||||
|
||||
it('maps shift to Shift', () => {
|
||||
expect(toAriaKeyShortcut(parseHotkeyPattern('Shift+>'))).toBe('Shift+>');
|
||||
expect(toAriaKeyShortcut(parseHotkeyPattern('Shift+ArrowLeft'))).toBe('Shift+ArrowLeft');
|
||||
});
|
||||
|
||||
it('formats multiple modifiers in consistent order', () => {
|
||||
|
||||
@@ -13,10 +13,10 @@ describe('parseHotkeyPattern', () => {
|
||||
});
|
||||
|
||||
it('parses Shift modifier', () => {
|
||||
const result = parseHotkeyPattern('Shift+>');
|
||||
const result = parseHotkeyPattern('Shift+ArrowLeft');
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.key).toBe('>');
|
||||
expect(result[0]!.key).toBe('arrowleft');
|
||||
expect(result[0]!.modifiers.has('shift')).toBe(true);
|
||||
expect(result[0]!.modifiers.size).toBe(1);
|
||||
});
|
||||
@@ -120,6 +120,60 @@ describe('matchesHotkeyEvent', () => {
|
||||
const binding = parseHotkeyPattern('k')[0]!;
|
||||
expect(matchesHotkeyEvent(binding, createEvent('j'))).toBe(false);
|
||||
});
|
||||
|
||||
describe('implicit shift for non-letter characters', () => {
|
||||
it('matches > when Shift is held (US keyboard: Shift+. produces >)', () => {
|
||||
const binding = parseHotkeyPattern('>')[0]!;
|
||||
expect(matchesHotkeyEvent(binding, createEvent('>', { shiftKey: true }))).toBe(true);
|
||||
});
|
||||
|
||||
it('matches < when Shift is held (US keyboard: Shift+, produces <)', () => {
|
||||
const binding = parseHotkeyPattern('<')[0]!;
|
||||
expect(matchesHotkeyEvent(binding, createEvent('<', { shiftKey: true }))).toBe(true);
|
||||
});
|
||||
|
||||
it('matches > when Shift is NOT held (European keyboard: > is unshifted)', () => {
|
||||
const binding = parseHotkeyPattern('>')[0]!;
|
||||
expect(matchesHotkeyEvent(binding, createEvent('>'))).toBe(true);
|
||||
});
|
||||
|
||||
it('matches ? when Shift is held', () => {
|
||||
const binding = parseHotkeyPattern('?')[0]!;
|
||||
expect(matchesHotkeyEvent(binding, createEvent('?', { shiftKey: true }))).toBe(true);
|
||||
});
|
||||
|
||||
it('matches Ctrl+> with Ctrl and Shift held', () => {
|
||||
const binding = parseHotkeyPattern('Ctrl+>')[0]!;
|
||||
expect(matchesHotkeyEvent(binding, createEvent('>', { ctrlKey: true, shiftKey: true }))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects Ctrl+> when only Shift is held (no Ctrl)', () => {
|
||||
const binding = parseHotkeyPattern('Ctrl+>')[0]!;
|
||||
expect(matchesHotkeyEvent(binding, createEvent('>', { shiftKey: true }))).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects letter k when Shift is held (Shift changes letter case)', () => {
|
||||
const binding = parseHotkeyPattern('k')[0]!;
|
||||
expect(matchesHotkeyEvent(binding, createEvent('K', { shiftKey: true }))).toBe(false);
|
||||
});
|
||||
|
||||
it('requires exact Shift for named keys', () => {
|
||||
const binding = parseHotkeyPattern('Shift+ArrowLeft')[0]!;
|
||||
expect(matchesHotkeyEvent(binding, createEvent('ArrowLeft', { shiftKey: true }))).toBe(true);
|
||||
expect(matchesHotkeyEvent(binding, createEvent('ArrowLeft'))).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects ArrowLeft when Shift is held but binding has no Shift', () => {
|
||||
const binding = parseHotkeyPattern('ArrowLeft')[0]!;
|
||||
expect(matchesHotkeyEvent(binding, createEvent('ArrowLeft', { shiftKey: true }))).toBe(false);
|
||||
});
|
||||
|
||||
it('Shift+> binding requires Shift held', () => {
|
||||
const binding = parseHotkeyPattern('Shift+>')[0]!;
|
||||
expect(matchesHotkeyEvent(binding, createEvent('>', { shiftKey: true }))).toBe(true);
|
||||
expect(matchesHotkeyEvent(binding, createEvent('>'))).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createHotkey', () => {
|
||||
|
||||
@@ -108,8 +108,8 @@ function getTemplateHTML() {
|
||||
<media-hotkey keys="0-9" action="seekToPercent"></media-hotkey>
|
||||
<media-hotkey keys="Home" action="seekToPercent" value="0"></media-hotkey>
|
||||
<media-hotkey keys="End" action="seekToPercent" value="100"></media-hotkey>
|
||||
<media-hotkey keys="Shift+>" action="speedUp"></media-hotkey>
|
||||
<media-hotkey keys="Shift+<" action="speedDown"></media-hotkey>
|
||||
<media-hotkey keys=">" action="speedUp"></media-hotkey>
|
||||
<media-hotkey keys="<" action="speedDown"></media-hotkey>
|
||||
</media-container>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -147,8 +147,8 @@ function getTemplateHTML() {
|
||||
<media-hotkey keys="0-9" action="seekToPercent"></media-hotkey>
|
||||
<media-hotkey keys="Home" action="seekToPercent" value="0"></media-hotkey>
|
||||
<media-hotkey keys="End" action="seekToPercent" value="100"></media-hotkey>
|
||||
<media-hotkey keys="Shift+>" action="speedUp"></media-hotkey>
|
||||
<media-hotkey keys="Shift+<" action="speedDown"></media-hotkey>
|
||||
<media-hotkey keys=">" action="speedUp"></media-hotkey>
|
||||
<media-hotkey keys="<" action="speedDown"></media-hotkey>
|
||||
</media-container>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -170,8 +170,8 @@ export function AudioSkin(props: AudioSkinProps): ReactNode {
|
||||
<MediaHotkey keys="0-9" action="seekToPercent" />
|
||||
<MediaHotkey keys="Home" action="seekToPercent" value={0} />
|
||||
<MediaHotkey keys="End" action="seekToPercent" value={100} />
|
||||
<MediaHotkey keys="Shift+>" action="speedUp" />
|
||||
<MediaHotkey keys="Shift+<" action="speedDown" />
|
||||
<MediaHotkey keys=">" action="speedUp" />
|
||||
<MediaHotkey keys="<" action="speedDown" />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -247,8 +247,8 @@ export function VideoSkin(props: VideoSkinProps): ReactNode {
|
||||
<MediaHotkey keys="0-9" action="seekToPercent" />
|
||||
<MediaHotkey keys="Home" action="seekToPercent" value={0} />
|
||||
<MediaHotkey keys="End" action="seekToPercent" value={100} />
|
||||
<MediaHotkey keys="Shift+>" action="speedUp" />
|
||||
<MediaHotkey keys="Shift+<" action="speedDown" />
|
||||
<MediaHotkey keys=">" action="speedUp" />
|
||||
<MediaHotkey keys="<" action="speedDown" />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user