diff --git a/packages/webamp-docs/docs/05_features/01_hotkeys.md b/packages/webamp-docs/docs/05_features/01_hotkeys.md index 25f2af28de..2a66d37bb7 100644 --- a/packages/webamp-docs/docs/05_features/01_hotkeys.md +++ b/packages/webamp-docs/docs/05_features/01_hotkeys.md @@ -16,6 +16,8 @@ const webamp = new Webamp({ webamp.renderWhenReady(document.getElementById("winamp-container")); ``` +By default, hotkeys are handled anywhere in the page. If your page has its own keyboard interactions, pass `enableHotkeys: "scoped"` _(since [Unreleased](../12_changelog.md#unreleased))_ to only handle hotkeys while focus is within Webamp. + ## Hotkeys | Key Combo | Action | diff --git a/packages/webamp-docs/docs/06_API/02_webamp-constructor.md b/packages/webamp-docs/docs/06_API/02_webamp-constructor.md index 447fe0dbc1..f31435dac1 100644 --- a/packages/webamp-docs/docs/06_API/02_webamp-constructor.md +++ b/packages/webamp-docs/docs/06_API/02_webamp-constructor.md @@ -126,7 +126,11 @@ In keeping with the original Winamp, **double size mode does not apply to resiza ### `enableHotkeys` -A boolean indicating if global hotkeys should be enabled. **Default:** `false`. +Controls whether hotkeys are enabled. **Default:** `false`. + +- `true`: Hotkeys are handled anywhere in the page. +- `"scoped"` _(since [Unreleased](../12_changelog.md#unreleased))_: Hotkeys are only handled while focus is within Webamp. Use this if your page has its own keyboard interactions. +- `false`: Hotkeys are disabled. ```ts const webamp = new Webamp({ diff --git a/packages/webamp-docs/docs/12_changelog.md b/packages/webamp-docs/docs/12_changelog.md index 9ebe8bba9a..8620549e0c 100644 --- a/packages/webamp-docs/docs/12_changelog.md +++ b/packages/webamp-docs/docs/12_changelog.md @@ -15,6 +15,7 @@ If you want access to the changes in this section before they are officially rel - The Milkdrop window now has a stable id, `#milkdrop-window`, like `#main-window`, `#equalizer-window` and `#playlist-window`. - Added a new Webamp instance method: [`webamp.onMediaError(cb)`](./06_API/03_instance-methods.md#onmediaerrorcb-error--code-number---void---void), which is called when a track fails to load or play. - Added a new Webamp instance method: [`webamp.setTracks(tracks)`](./06_API/03_instance-methods.md#settrackstracks-track-void), which replaces the playlist without starting playback. +- The [`enableHotkeys`](./06_API/02_webamp-constructor.md#enablehotkeys) option now accepts `"scoped"`, which only handles hotkeys while focus is within Webamp. ### Bug Fixes diff --git a/packages/webamp/js/components/App.tsx b/packages/webamp/js/components/App.tsx index 31cba88bcc..0c145677db 100644 --- a/packages/webamp/js/components/App.tsx +++ b/packages/webamp/js/components/App.tsx @@ -6,7 +6,7 @@ import { StrictMode, } from "react"; import ReactDOM from "react-dom"; -import { FilePicker } from "../types"; +import { FilePicker, Dispatch } from "../types"; import { WINDOWS } from "../constants"; import * as Selectors from "../selectors"; import * as Actions from "../actionCreators"; @@ -21,7 +21,8 @@ import EqualizerWindow from "./EqualizerWindow"; import Skin from "./Skin"; import { IMedia } from "../media"; -import { useTypedSelector, useActionCreator } from "../hooks"; +import { useTypedSelector, useActionCreator, useTypedDispatch } from "../hooks"; +import { bindHotkeys } from "../hotkeys"; import Css from "./Css"; // @ts-ignore @@ -31,6 +32,7 @@ interface Props { filePickers: FilePicker[]; media: IMedia; parentDomNode: HTMLElement; + enableHotkeys: boolean | "scoped"; onMount?: () => void; } @@ -42,6 +44,7 @@ export default function App({ filePickers, onMount, parentDomNode, + enableHotkeys, }: Props) { const closed = useTypedSelector(Selectors.getClosed); const genWindowsInfo = useTypedSelector(Selectors.getGenWindows); @@ -64,6 +67,16 @@ export default function App({ webampNode.style.zIndex = String(zIndex); }, [webampNode, zIndex]); + const dispatch = useTypedDispatch(); + useEffect(() => { + if (!enableHotkeys) { + return; + } + // Scoped hotkeys only see key events from within Webamp's DOM node. + const target = enableHotkeys === "scoped" ? webampNode : document; + return bindHotkeys(dispatch as Dispatch, target); + }, [enableHotkeys, dispatch, webampNode]); + useLayoutEffect(() => { parentDomNode.appendChild(webampNode); return () => { diff --git a/packages/webamp/js/hotkeys.test.ts b/packages/webamp/js/hotkeys.test.ts new file mode 100644 index 0000000000..dec476ac80 --- /dev/null +++ b/packages/webamp/js/hotkeys.test.ts @@ -0,0 +1,54 @@ +import { bindHotkeys } from "./hotkeys"; + +function pressR(target: EventTarget) { + // R toggles repeat. + target.dispatchEvent( + new KeyboardEvent("keydown", { keyCode: 82, bubbles: true }) + ); +} + +describe("bindHotkeys", () => { + let webampNode: HTMLElement; + let button: HTMLElement; + let outside: HTMLElement; + + beforeEach(() => { + webampNode = document.createElement("div"); + button = document.createElement("button"); + webampNode.appendChild(button); + outside = document.createElement("button"); + document.body.append(webampNode, outside); + }); + + afterEach(() => { + webampNode.remove(); + outside.remove(); + }); + + it("handles hotkeys anywhere in the document by default", () => { + const dispatch = vi.fn(); + const unbind = bindHotkeys(dispatch); + pressR(outside); + pressR(button); + unbind(); + expect(dispatch).toHaveBeenCalledTimes(2); + expect(dispatch).toHaveBeenCalledWith({ type: "TOGGLE_REPEAT" }); + }); + + it("only handles hotkeys within the target when given one", () => { + const dispatch = vi.fn(); + const unbind = bindHotkeys(dispatch, webampNode); + pressR(outside); + expect(dispatch).not.toHaveBeenCalled(); + pressR(button); + expect(dispatch).toHaveBeenCalledWith({ type: "TOGGLE_REPEAT" }); + unbind(); + }); + + it("stops handling hotkeys once unbound", () => { + const dispatch = vi.fn(); + bindHotkeys(dispatch, webampNode)(); + pressR(button); + expect(dispatch).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/webamp/js/hotkeys.ts b/packages/webamp/js/hotkeys.ts index 8eae211805..651d719c6c 100644 --- a/packages/webamp/js/hotkeys.ts +++ b/packages/webamp/js/hotkeys.ts @@ -20,7 +20,12 @@ import { Dispatch } from "./types"; const IGNORE_EVENTS_FROM_TAGS = new Set(["input", "textarea", "select"]); -export function bindHotkeys(dispatch: Dispatch): () => void { +// By default hotkeys are handled anywhere in the document. Pass a `target` to +// only handle them when focus is within that element. +export function bindHotkeys( + dispatch: Dispatch, + target: Document | HTMLElement = document +): () => void { let currentPos: number = 0; const trigger = [ 78, // N @@ -148,9 +153,12 @@ export function bindHotkeys(dispatch: Dispatch): () => void { } } }; - document.addEventListener("keydown", listener); + // TypeScript can't pick the right overload for the `Document | HTMLElement` + // union. + const eventListener = listener as EventListener; + target.addEventListener("keydown", eventListener); return () => { - document.removeEventListener("keydown", listener); + target.removeEventListener("keydown", eventListener); }; } diff --git a/packages/webamp/js/types.ts b/packages/webamp/js/types.ts index 9b3879b6c7..21912af853 100644 --- a/packages/webamp/js/types.ts +++ b/packages/webamp/js/types.ts @@ -688,11 +688,16 @@ export interface Options { enableDoubleSizeMode?: boolean; /** - * Should global hotkeys be enabled? + * Should hotkeys be enabled? + * + * - `true`: Hotkeys are handled anywhere in the page. + * - `"scoped"`: Hotkeys are only handled while focus is within Webamp. Useful + * if the page has other keyboard interactions. + * - `false`: Hotkeys are disabled. * * Default: `false` */ - enableHotkeys?: boolean; + enableHotkeys?: boolean | "scoped"; /** * An array of additional file pickers. diff --git a/packages/webamp/js/webampLazy.tsx b/packages/webamp/js/webampLazy.tsx index 86a1cd2234..25ffe2fb65 100644 --- a/packages/webamp/js/webampLazy.tsx +++ b/packages/webamp/js/webampLazy.tsx @@ -20,7 +20,6 @@ import { } from "./types"; import getStore from "./store"; import App from "./components/App"; -import { bindHotkeys } from "./hotkeys"; import Media, { IMedia, IMediaClass } from "./media"; import * as Selectors from "./selectors"; import * as Actions from "./actionCreators"; @@ -83,7 +82,6 @@ class Webamp { initialTracks, initialSkin, availableSkins, - enableHotkeys = false, zIndex, requireJSZip, requireMusicMetadata, @@ -214,10 +212,6 @@ class Webamp { } this.store.dispatch(Actions.setWindowLayout(options.windowLayout)); - - if (enableHotkeys) { - this._disposable.add(bindHotkeys(this.store.dispatch)); - } } /** @@ -556,6 +550,7 @@ class Webamp {