Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
294 changes: 179 additions & 115 deletions client/js/shortcuts.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { tinykeys } from 'tinykeys';
import { KeybindingsMap, tinykeys } from 'tinykeys';
import selfoss from './selfoss-base';
import { Direction } from './helpers/navigation';
import { RefObject } from 'react';

type KeyboardEventHandler = (event: KeyboardEvent) => void;

Expand All @@ -10,157 +11,220 @@ type KeyboardEventHandler = (event: KeyboardEvent) => void;
*/
function ignoreWhenInteracting(
handler: KeyboardEventHandler,
ignoreDialog: RefObject<HTMLDialogElement> = undefined,
): KeyboardEventHandler {
return (event: KeyboardEvent): void => {
if (selfoss.lightboxActive.value) {
return;
} else if (ignoreDialog && ignoreDialog.current.open) {
return;
}

handler(event);
};
}

interface IKeybinding {
readableName?: string;
description: string;
action: KeyboardEventHandler;
}

/**
* Set up shortcuts on document.
* A selfoss-side definition of all keybindings
* This is used to:
* - Limit boilerplate
* - Generate a tinykeys compatible KeybindingsMap (@see makeKeybindingsMap)
Comment thread
Denperidge marked this conversation as resolved.
* - Generate a keybindings overview for the help page (see HelpShortcuts component)
*/
export default function makeShortcuts(): () => void {
return tinykeys(window, {
// 'space': next article
Space: ignoreWhenInteracting((event: KeyboardEvent): void => {
event.preventDefault();
export const KEYBINDINGS: { [keycombo: string]: IKeybinding } = {
Space: {
description: 'select and open next entry',
action: () => {
selfoss.entriesPage?.jumpToNext();
}),

// 'n': next article
n: ignoreWhenInteracting((event: KeyboardEvent): void => {
event.preventDefault();
},
},
n: {
description: 'select next entry',
action: () => {
selfoss.entriesPage?.nextPrev(Direction.NEXT, false);
}),

// 'right cursor': next article
ArrowRight: ignoreWhenInteracting((event: KeyboardEvent): void => {
event.preventDefault();
},
},
Arrowright: {
readableName: '→',
description: 'select next entry (and open it when the current is open)',
action: () => {
selfoss.entriesPage?.entryNav(Direction.NEXT);
}),

// 'j': next article
j: ignoreWhenInteracting((event: KeyboardEvent): void => {
event.preventDefault();
},
},
j: {
description: 'select and open next entry',
action: () => {
selfoss.entriesPage?.nextPrev(Direction.NEXT, true);
}),

// 'shift+space': previous article
'Shift+Space': ignoreWhenInteracting((event: KeyboardEvent): void => {
event.preventDefault();
},
},
'Shift+Space': {
description: 'select and open previous entry',
action: () => {
selfoss.entriesPage?.nextPrev(Direction.PREV, true);
}),

// 'p': previous article
p: ignoreWhenInteracting((event: KeyboardEvent): void => {
event.preventDefault();
},
},
p: {
description: 'select previous entry',
action: () => {
selfoss.entriesPage?.nextPrev(Direction.PREV, false);
}),

// 'left': previous article
ArrowLeft: ignoreWhenInteracting((event: KeyboardEvent): void => {
event.preventDefault();
},
},
ArrowLeft: {
readableName: '←',
description:
'select previous entry (and open it when the current is open)',
action: () => {
selfoss.entriesPage?.entryNav(Direction.PREV);
}),

// 'k': previous article
k: ignoreWhenInteracting((event: KeyboardEvent): void => {
event.preventDefault();
},
},
k: {
description: 'select and open previous entry',
action: () => {
selfoss.entriesPage?.nextPrev(Direction.PREV, true);
}),

// 's': star/unstar
s: ignoreWhenInteracting((event: KeyboardEvent): void => {
event.preventDefault();
},
},
s: {
description:
'mark and unmark current selected entry as starred/unstarred',
action: () => {
selfoss.entriesPage?.toggleSelectedStarred();
}),

// 'm': mark/unmark
m: ignoreWhenInteracting((event: KeyboardEvent): void => {
event.preventDefault();
},
},
m: {
description: 'mark and unmark current selected entry as read/unread',
action: () => {
selfoss.entriesPage?.toggleSelectedRead();
}),

// 'o': open/close entry
o: ignoreWhenInteracting((event: KeyboardEvent): void => {
event.preventDefault();
},
},
'Control+m': {
description: 'mark all as read',
action: () => {
document.querySelector<HTMLButtonElement>('#nav-mark').click();
},
},
o: {
description: 'open / close current entry',
action: () => {
selfoss.entriesPage?.toggleSelectedExpanded();
}),

// 'Shift + o': close open entries
'Shift+o': ignoreWhenInteracting((event: KeyboardEvent): void => {
event.preventDefault();
},
},
'Shift+o': {
description: 'close all open entries',
action: () => {
selfoss.entriesPage?.collapseAllEntries();
}),

// 'v': open target
v: ignoreWhenInteracting((event: KeyboardEvent): void => {
event.preventDefault();
},
},
v: {
description: 'open url of current entry in new tab/window',
action: () => {
selfoss.entriesPage?.openSelectedTarget();
}),

// 'Shift + v': open target and mark read
'Shift+v': ignoreWhenInteracting((event: KeyboardEvent): void => {
event.preventDefault();
},
},
'Shift+v': {
description:
'open url of current entry in new tab/window and mark read',
action: () => {
selfoss.entriesPage?.openSelectedTargetAndMarkRead();
}),

// 'r': Reload the current view
r: ignoreWhenInteracting((event: KeyboardEvent): void => {
event.preventDefault();
},
},
r: {
description: 'reload the list',
action: () => {
selfoss.entriesPage?.reload();
}),

// 'Shift + r': Refresh sources
'Shift+r': ignoreWhenInteracting((event: KeyboardEvent): void => {
event.preventDefault();
},
},
'Shift+r': {
description: 'refresh sources',
action: () => {
document.querySelector<HTMLButtonElement>('#nav-refresh').click();
}),

// 'Control+m': mark all as read
'Control+m': ignoreWhenInteracting((event: KeyboardEvent): void => {
event.preventDefault();
document.querySelector<HTMLButtonElement>('#nav-mark').click();
}),

// 't': throw (mark as read & open next)
t: ignoreWhenInteracting((event: KeyboardEvent): void => {
event.preventDefault();
},
},
t: {
description: 'throw current entry to next (mark as read & open next)',
action: () => {
selfoss.entriesPage?.throw(Direction.NEXT);
}),

// throw (mark as read & open previous)
'Shift+t': ignoreWhenInteracting((event: KeyboardEvent): void => {
event.preventDefault();
},
},
'Shift+t': {
description:
'throw current entry to previous (mark as read & open previous)',
action: () => {
selfoss.entriesPage?.throw(Direction.PREV);
}),

// 'Shift+n': switch to newest items overview / menu item
'Shift+n': ignoreWhenInteracting((event: KeyboardEvent): void => {
event.preventDefault();
},
},
'Shift+n': {
description: 'open newest entries page',
action: () => {
document
.querySelector<HTMLAnchorElement>('#nav-filter-newest')
.click();
}),

// 'Shift+u': switch to unread items overview / menu item
'Shift+u': ignoreWhenInteracting((event: KeyboardEvent): void => {
event.preventDefault();
},
},
'Shift+u': {
description: 'open unread entries page',
action: () => {
document
.querySelector<HTMLAnchorElement>('#nav-filter-unread')
.click();
}),

// 'Shift+s': switch to starred items overview / menu item
'Shift+s': ignoreWhenInteracting((event: KeyboardEvent): void => {
event.preventDefault();
},
},
'Shift+s': {
description: 'open starred entries page',
action: () => {
document
.querySelector<HTMLAnchorElement>('#nav-filter-starred')
.click();
}),
});
},
},
'[Shift]+?': {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should also add it to the static docs.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure! Although, perhaps finishing up the auto-generated docs table might be better to make sure nothing falls through the cracks properly? Shift+R is also undocumented in the docs as of now

readableName: '?',
description: 'show help',
Comment thread
Denperidge marked this conversation as resolved.
action: () => {},
},
};

function makeKeybindingsMap(
shortcutsDialog: RefObject<HTMLDialogElement>,
): KeybindingsMap {
const shortcuts = Object.entries(KEYBINDINGS);
const keybindingsMap: KeybindingsMap = {};

for (const [keycombo, keybind] of shortcuts) {
if (keycombo === '[Shift]+?') {
keybindingsMap[keycombo] = ignoreWhenInteracting(
(event: KeyboardEvent) => {
event.preventDefault();
if (shortcutsDialog.current.open) {
shortcutsDialog.current.close();
} else {
shortcutsDialog.current.showModal();
}
},
);
} else {
keybindingsMap[keycombo] = ignoreWhenInteracting(
(event: KeyboardEvent) => {
event.preventDefault();
keybind.action(event);
},
shortcutsDialog,
);
}
}
return keybindingsMap;
}

/**
* Set up shortcuts on document.
*/
export default function makeShortcuts(
shortcutsDialog: RefObject<HTMLDialogElement>,
): () => void {
return tinykeys(window, makeKeybindingsMap(shortcutsDialog));
}
9 changes: 8 additions & 1 deletion client/js/templates/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import React, {
useMemo,
useState,
MouseEvent,
useRef,
} from 'react';
import {
BrowserRouter as Router,
Expand Down Expand Up @@ -44,6 +45,8 @@ import locales, {
} from '../locales';
import { useEntriesParams, useLocation } from '../helpers/uri';
import { NavSource, NavTag } from '../requests/items';
import Dialog from './Dialog';
import HelpShortcuts from './HelpShortcuts';

type MessageAction = {
label: string;
Expand Down Expand Up @@ -246,14 +249,15 @@ function PureApp(props: PureAppProps): React.JSX.Element {
} = props;

const [navExpanded, setNavExpanded] = useState(false);
const shortcutsDialog = useRef<HTMLDialogElement>(null);
const smartphone = useIsSmartphone();
const offlineEnabled = useListenableValue(selfoss.db.enableOffline);
const [entriesPage, setEntriesPage] = useState(null);
const configuration = use(ConfigurationContext);

useEffect(() => {
// init shortcut handler
const destroyShortcuts = makeShortcuts();
const destroyShortcuts = makeShortcuts(shortcutsDialog);

return () => {
destroyShortcuts();
Expand Down Expand Up @@ -480,6 +484,9 @@ function PureApp(props: PureAppProps): React.JSX.Element {
/>
<Route path="*" element={<NotFound />} />
</Routes>
<Dialog ref={shortcutsDialog}>
<HelpShortcuts />
</Dialog>
</div>
</CheckAuthorization>
}
Expand Down
Loading