Skip to content

Commit

Permalink
Keyboard Shortcuts (#181)
Browse files Browse the repository at this point in the history
## Please describe the changes this PR makes and why it should be
merged:

- Add keyboard shortcut capability
- Close #176

## Status and versioning classification:

- Code changes have been tested against the Discord API, or there are no
code changes
- I know how to update typings and have done so, or typings don't need
updating
- This PR changes the library's interface (methods or parameters added)
  • Loading branch information
brianferri authored Dec 12, 2023
2 parents eaab89e + 713c1ea commit 1e5a907
Show file tree
Hide file tree
Showing 13 changed files with 443 additions and 0 deletions.
56 changes: 56 additions & 0 deletions dashboard/kbdsrct.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Spotify Shortcuts

For reference, only a few are gonna be implemented:


#### Basic

- Create new playlist Alt+Shift+P
- Create new folder Ctrl+Alt+Shift+P
- Open context menu Alt+J
- Open Quick Search Ctrl+K
- Search in Your Library Shift+Ctrl+Alt+F
- Log out Alt+Shift+F6

#### Playback
- Play / Pause Space
- Like Alt+Shift+B
- Shuffle Alt+S
- Repeat Alt+R
- Skip to previous Alt+←
- Skip to next Alt+→
- Seek backward Shift+←
- Seek forward Shift+→
- Raise volume Alt+↑
- Lower volume Alt+↓


#### Navigation

- Home Alt+Shift+H
- Back in history Ctrl+←
- Forward in history Ctrl+→
- Currently playing Alt+Shift+J
- Search Ctrl+Shift+L
- Liked songs Alt+Shift+S
- Queue Alt+Shift+Q
- Your Library Alt+Shift+0
- Your playlists Alt+Shift+1
- Your podcasts Alt+Shift+2
- Your artists Alt+Shift+3
- Your albums Alt+Shift+4
- Made for you Alt+Shift+M
- New Releases Alt+Shift+N
- Charts Alt+Shift+C


#### Layout

- Toggle left sidebar Alt+Shift+L
- Decrease navigation bar width Alt+Shift+←
- Increase navigation bar width Alt+Shift+→
- Toggle right sidebar Alt+Shift+R
- Decrease activity tab width Alt+Shift+↓
- Increase activity tab width Alt+Shift+↑
1 change: 1 addition & 0 deletions dashboard/src/assets/icons/modal-close.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
22 changes: 22 additions & 0 deletions dashboard/src/components/Modal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { IModalProps } from '@/interfaces/components/Modal';

const containerStyle: React.HTMLAttributes<HTMLDivElement>['style'] = {
position: 'fixed',
width: '100vw',
height: '100vh',
top: 0,
left: 0,
display: 'flex',
justifyContent: 'center',
zIndex: 500,
};

export default function Modal({ children, fullHeight, open }: IModalProps) {
if (!open) return null;

const cpyStyles = { ...containerStyle };

if (!fullHeight) cpyStyles.alignItems = 'center';

return <div style={cpyStyles}>{children}</div>;
}
84 changes: 84 additions & 0 deletions dashboard/src/components/ModalShortcut.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import Modal from '@/components/Modal';
import { IModalShortcutProps } from '@/interfaces/components/Modal';
import { Container } from '@nextui-org/react';
import ModalCloseIcon from '@/assets/icons/modal-close.svg';
import Key from '@/components/shortcuts/Key';
import ShortcutEntry from './shortcuts/ShortcutEntry';

export default function ModalShortcut({
open,
onClose,
modalContainerProps = {},
}: IModalShortcutProps) {
return (
<Modal open={open} {...modalContainerProps}>
<Container
css={{
borderRadius: '6px',
backgroundColor: '$gray50',
padding: 0,
maxWidth: 600,
boxShadow: '0px 0px 300px 0px rgba(255,255,255,0.1)',
}}
fluid
responsive={false}
>
<Container
css={{
padding: '20px',
borderBottom: '1px solid $primary',
justifyContent: 'center',
}}
fluid
responsive={false}
>
<div
style={{
position: 'relative',
}}
>
<h2>Keyboard Shortcuts</h2>

<ModalCloseIcon
onClick={onClose}
style={{
position: 'absolute',
top: 0,
right: 0,
cursor: 'pointer',
}}
/>
</div>
<div
style={{
fontSize: 18,
display: 'flex',
alignItems: 'center',
}}
>
Press <Key>Ctrl</Key>
<Key>/</Key> to toggle this modal.
</div>
</Container>

<div
style={{
padding: '20px',
overflow: 'auto',
fontSize: 18,
}}
>
<div>
<h2>Playback</h2>
<ShortcutEntry name="Play / Pause" comb={['Space']} />
<ShortcutEntry name="Next Track" comb={['Alt', '→']} />
<ShortcutEntry
name="Previous Track"
comb={['Alt', '←']}
/>
</div>
</div>
</Container>
</Modal>
);
}
16 changes: 16 additions & 0 deletions dashboard/src/components/shortcuts/Key.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { IKeyProps } from '@/interfaces/components/Shortcuts';

export default function Key({ children }: IKeyProps) {
return (
<span
style={{
border: '1px solid white',
borderRadius: '6px',
padding: '2px 10px',
margin: '4px',
}}
>
{children}
</span>
);
}
28 changes: 28 additions & 0 deletions dashboard/src/components/shortcuts/ShortcutEntry.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { IShortcutEntryProps } from '@/interfaces/components/Shortcuts';
import Key from '@/components/shortcuts/Key';

export default function ShortcutEntry({
name,
comb = [],
}: IShortcutEntryProps) {
return (
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<div>{name}</div>
<div
style={{
display: 'flex',
}}
>
{comb.map((v, i) => (
<Key key={i}>{v}</Key>
))}
</div>
</div>
);
}
11 changes: 11 additions & 0 deletions dashboard/src/interfaces/components/Modal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export interface IModalProps {
children?: React.ReactNode;
fullHeight?: boolean;
open?: boolean;
}

export interface IModalShortcutProps {
open?: IModalProps['open'];
onClose?: React.ComponentProps<'svg'>['onClick'];
modalContainerProps?: IModalProps;
}
8 changes: 8 additions & 0 deletions dashboard/src/interfaces/components/Shortcuts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export interface IKeyProps {
children?: React.ReactNode;
}

export interface IShortcutEntryProps {
name?: React.ReactNode;
comb?: IKeyProps['children'][];
}
4 changes: 4 additions & 0 deletions dashboard/src/interfaces/kbdsrct.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export interface IKbdsrct {
comb: string[];
cb: () => void;
}
86 changes: 86 additions & 0 deletions dashboard/src/layouts/AppLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import ModalShortcut from '@/components/ModalShortcut';
import { PageLayout } from '@/interfaces/layouts';
import {
execKbdsrct,
registerKbdsrct,
unregisterKbdsrct,
} from '@/libs/kbdsrct';
import { createTheme, NextUIProvider } from '@nextui-org/react';
import { useEffect, useRef, useState } from 'react';

const AppLayout: PageLayout = ({
children,
Expand All @@ -13,6 +20,81 @@ const AppLayout: PageLayout = ({
overflow: 'hidden',
},
}) => {
const [modalShortcutOpen, setModalShortcutOpen] = useState(false);

const pressesRef = useRef<string[]>([]);

const removePresses = (key: string) => {
const rmIdx = pressesRef.current.findIndex((v) => key === v);
if (rmIdx < 0) return false;

pressesRef.current.splice(rmIdx, 1);
return true;
};

/**
* Modifier detection may not work depends on user's keyboard configuration, there's at least
* OS level and app level capture, each that can block browser javascript from getting any key press event
* or preventing some modifier to give its current actual state (mostly Alt key for me)
*
* The browser might not even fire keyboard event cuz it's already captured by the OS or other app
*/
const kbdsrctInHandler = (e: KeyboardEvent) => {
if (e.shiftKey) {
pressesRef.current.push('Shift');
}

if (e.ctrlKey) {
pressesRef.current.push('Control');
}

if (e.altKey) {
pressesRef.current.push('Alt');
}

pressesRef.current.push(e.key);

if (execKbdsrct(pressesRef.current)) e.preventDefault();
};

const kbdsrctOutHandler = (e: KeyboardEvent) => {
if (removePresses(e.key)) {
e.preventDefault();
}

// remove modifier keys too
if (e.shiftKey) {
removePresses('Shift');
}

if (e.ctrlKey) {
removePresses('Control');
}

if (e.altKey) {
removePresses('Alt');
}
};

const modalShortcutKbdsrct = {
comb: ['Control', '/'],
cb: () => setModalShortcutOpen((v) => !v),
};

useEffect(() => {
document.body.addEventListener('keydown', kbdsrctInHandler);
document.body.addEventListener('keyup', kbdsrctOutHandler);

registerKbdsrct(modalShortcutKbdsrct);

return () => {
document.body.removeEventListener('keydown', kbdsrctInHandler);
document.body.removeEventListener('keyup', kbdsrctOutHandler);

unregisterKbdsrct(modalShortcutKbdsrct);
};
}, []);

return (
<NextUIProvider
theme={createTheme({
Expand All @@ -26,6 +108,10 @@ const AppLayout: PageLayout = ({
})}
>
<div style={contentContainerStyle}>{children}</div>
<ModalShortcut
open={modalShortcutOpen}
onClose={() => modalShortcutOpen && setModalShortcutOpen(false)}
/>
</NextUIProvider>
);
};
Expand Down
Loading

0 comments on commit 1e5a907

Please sign in to comment.