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
5 changes: 5 additions & 0 deletions .changeset/configurable-keyboard-shortcuts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
sable: patch
---

Allow keyboard shortcuts for navigation, bookmarks, search, and message formatting to be customized from Settings.
29 changes: 16 additions & 13 deletions src/app/components/GlobalKeyboardShortcuts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
import { useCallback, useRef } from 'react';
import { useNavigate, useLocation, matchPath } from 'react-router-dom';
import { useAtomValue, useSetAtom } from 'jotai';
import { isKeyHotkey } from 'is-hotkey';
import { useMatrixClient } from '$hooks/useMatrixClient';
import { roomToParentsAtom } from '$state/room/roomToParents';
import { mDirectAtom } from '$state/mDirectList';
Expand All @@ -32,8 +31,12 @@ import { announce } from '$utils/announce';
import { roomIdToReplyDraftAtomFamily } from '$state/room/roomInputDrafts';
import type { Room } from '$types/matrix-sdk';
import { useSelectedSpace } from '$hooks/router/useSelectedSpace';
import { useSetting } from '$state/hooks/settings';
import { settingsAtom } from '$state/settings';
import { matchesShortcut } from '../keyboard/shortcuts';

export function GlobalKeyboardShortcuts() {
const [shortcutOverrides] = useSetting(settingsAtom, 'shortcutOverrides');
const navigate = useNavigate();
const location = useLocation();
const mx = useMatrixClient();
Expand Down Expand Up @@ -95,7 +98,7 @@ export function GlobalKeyboardShortcuts() {
/** Alt+N: jump to the top-priority unread room and reset the cycle index. */
const handleNextUnreadKeyDown = useCallback(
(evt: KeyboardEvent) => {
if (!isKeyHotkey('alt+n', evt)) return;
if (!matchesShortcut('navigation.nextUnread', evt, shortcutOverrides)) return;
const unreadEntries = Array.from(roomToUnread.entries())
.filter(([id, u]) => u.total > 0 && id !== currentRoom?.roomId)
.toSorted((a, b) => b[1].highlight - a[1].highlight || b[1].total - a[1].total);
Expand All @@ -105,14 +108,14 @@ export function GlobalKeyboardShortcuts() {
const [roomId] = unreadEntries[0]!;
navigateToRoom(roomId, unreadEntries.length - 1);
},
[roomToUnread, currentRoom?.roomId, navigateToRoom]
[roomToUnread, currentRoom?.roomId, navigateToRoom, shortcutOverrides]
);

/** Alt+Shift+Down / Alt+Shift+Up: cycle through unread rooms. */
const handleUnreadNavKeyDown = useCallback(
(evt: KeyboardEvent) => {
const isDown = isKeyHotkey('alt+shift+down', evt);
const isUp = isKeyHotkey('alt+shift+up', evt);
const isDown = matchesShortcut('navigation.cycleNextUnread', evt, shortcutOverrides);
const isUp = matchesShortcut('navigation.cyclePreviousUnread', evt, shortcutOverrides);
if (!isDown && !isUp) return;
const unreadEntries = Array.from(roomToUnread.entries())
.filter(([, u]) => u.total > 0)
Expand All @@ -130,14 +133,14 @@ export function GlobalKeyboardShortcuts() {
const [roomId] = currentEntry;
navigateToRoom(roomId, unreadEntries.length - 1);
},
[roomToUnread, navigateToRoom]
[roomToUnread, navigateToRoom, shortcutOverrides]
);

/** Ctrl+Down / Ctrl+Up: cycle through messages to reply to. */
const handleReplyKeyDown = useCallback(
(evt: KeyboardEvent) => {
const isDown = isKeyHotkey('mod+down', evt);
const isUp = isKeyHotkey('mod+up', evt);
const isDown = matchesShortcut('navigation.nextReply', evt, shortcutOverrides);
const isUp = matchesShortcut('navigation.previousReply', evt, shortcutOverrides);
if (currentRoom === null) return;
if (!isDown && !isUp) return;

Expand All @@ -160,24 +163,24 @@ export function GlobalKeyboardShortcuts() {
if (eventId === undefined) return;
setReplyDraft({ userId: currentRoom.myUserId, eventId, body: '' });
},
[currentRoom, replyDraft, setReplyDraft]
[currentRoom, replyDraft, setReplyDraft, shortcutOverrides]
);

const handleBookmarkKeyDown = useCallback(
(evt: KeyboardEvent) => {
if (!isKeyHotkey('mod+shift+b', evt)) return;
if (!matchesShortcut('app.openBookmarks', evt, shortcutOverrides)) return;
evt.preventDefault();

navigate(getInboxBookmarksPath());
announce(`Navigated to bookmarks`);
},
[navigate]
[navigate, shortcutOverrides]
);

/** Ctrl+F: Search for messages */
const handleSearchMessageInRoom = useCallback(
(evt: KeyboardEvent) => {
if (!isKeyHotkey('mod+f', evt)) return;
if (!matchesShortcut('app.searchMessages', evt, shortcutOverrides)) return;
evt.preventDefault();

const searchParams: SearchPathSearchParams = {
Expand All @@ -190,7 +193,7 @@ export function GlobalKeyboardShortcuts() {
navigate(withSearchParam(path, searchParams));
announce(`Start Searching messages ${roomName ? `in ${roomName}` : ''}`);
},
[mx, currentRoom, currentSpace, navigate]
[mx, currentRoom, currentSpace, navigate, shortcutOverrides]
);

useKeyDown(window, handleNextUnreadKeyDown);
Expand Down
7 changes: 5 additions & 2 deletions src/app/components/editor/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import { RenderElement, RenderLeaf } from './Elements';
import type { CustomElement } from './slate';
import * as css from './Editor.css';
import { toggleKeyboardShortcut } from './keyboard';
import { useSetting } from '$state/hooks/settings';
import { settingsAtom } from '$state/settings';

const withInline = (editor: Editor): Editor => {
const { isInline } = editor;
Expand Down Expand Up @@ -94,6 +96,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
},
ref
) => {
const [shortcutOverrides] = useSetting(settingsAtom, 'shortcutOverrides');
// Each <Slate> instance must receive its own fresh node objects.
// Sharing a module-level constant causes Slate's global NODE_TO_ELEMENT
// WeakMap to be overwritten when multiple editors are mounted at the same
Expand Down Expand Up @@ -378,10 +381,10 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(

onKeyDown?.(evt);

const shortcutToggled = toggleKeyboardShortcut(editor, evt);
const shortcutToggled = toggleKeyboardShortcut(editor, evt, shortcutOverrides);
if (shortcutToggled) evt.preventDefault();
},
[editor, onKeyDown]
[editor, onKeyDown, shortcutOverrides]
);

const renderPlaceholder = useCallback(
Expand Down
126 changes: 97 additions & 29 deletions src/app/components/editor/MarkdownToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,16 @@ import { useEffect, useState } from 'react';
import { useSetting } from '$state/hooks/settings';
import { settingsAtom } from '$state/settings';
import { ReactEditor, useSlate } from 'slate-react';
import { isMacOS } from '$utils/user-agent';
import { KeySymbol } from '$utils/key-symbol';
import { stopPropagation } from '$utils/keyboard';
import { floatingToolbar } from '$styles/overrides/Composer.css';
import {
applyMarkdownBlockPrefix,
applyMarkdownInline,
BLOCK_HOTKEYS,
INLINE_HOTKEYS,
BLOCK_PREFIXES,
INLINE_MARKERS,
} from './keyboard';
import type { ShortcutId, ShortcutOverrides } from '../../keyboard/shortcuts';
import { formatShortcut, getShortcutBinding } from '../../keyboard/shortcuts';
import * as css from './Editor.css';
import {
CaretDown,
Expand Down Expand Up @@ -68,6 +68,9 @@ function BtnTooltip({ text, shortCode }: { text: string; shortCode?: string }) {
);
}

const shortcutLabel = (id: ShortcutId, overrides: ShortcutOverrides) =>
formatShortcut(getShortcutBinding(id, overrides));

type MarkdownInlineButtonProps = {
marker: string;
icon: PhosphorIcon;
Expand Down Expand Up @@ -133,7 +136,7 @@ function MarkdownBlockButton({ prefix, icon, tooltip }: MarkdownBlockButtonProps
function MarkdownHeadingButton() {
const editor = useSlate();
const [anchor, setAnchor] = useState<RectCords>();
const modKey = isMacOS() ? KeySymbol.Command : 'Ctrl';
const [shortcutOverrides] = useSetting(settingsAtom, 'shortcutOverrides');

const handleMenuSelect = (prefix: string) => {
setAnchor(undefined);
Expand Down Expand Up @@ -165,7 +168,12 @@ function MarkdownHeadingButton() {
<Menu style={{ padding: config.space.S100 }}>
<Box gap="100">
<TooltipProvider
tooltip={<BtnTooltip text="Heading 1" shortCode={`${modKey} + 1`} />}
tooltip={
<BtnTooltip
text="Heading 1"
shortCode={shortcutLabel('composer.heading1', shortcutOverrides)}
/>
}
delay={500}
>
{(triggerRef) => (
Expand All @@ -180,7 +188,12 @@ function MarkdownHeadingButton() {
)}
</TooltipProvider>
<TooltipProvider
tooltip={<BtnTooltip text="Heading 2" shortCode={`${modKey} + 2`} />}
tooltip={
<BtnTooltip
text="Heading 2"
shortCode={shortcutLabel('composer.heading2', shortcutOverrides)}
/>
}
delay={500}
>
{(triggerRef) => (
Expand All @@ -195,7 +208,12 @@ function MarkdownHeadingButton() {
)}
</TooltipProvider>
<TooltipProvider
tooltip={<BtnTooltip text="Heading 3" shortCode={`${modKey} + 3`} />}
tooltip={
<BtnTooltip
text="Heading 3"
shortCode={shortcutLabel('composer.heading3', shortcutOverrides)}
/>
}
delay={500}
>
{(triggerRef) => (
Expand Down Expand Up @@ -231,65 +249,115 @@ function MarkdownHeadingButton() {
}

export function MarkdownToolbar() {
const modKey = isMacOS() ? KeySymbol.Command : 'Ctrl';
const [shortcutOverrides] = useSetting(settingsAtom, 'shortcutOverrides');

return (
<Box className={`${css.EditorToolbarBase} ${floatingToolbar}`}>
<Scroll direction="Horizontal" size="0">
<Box className={css.EditorToolbar} alignItems="Center" gap="300">
<Box shrink="No" gap="100">
<MarkdownInlineButton
marker={INLINE_HOTKEYS['mod+b']!}
marker={INLINE_MARKERS['composer.bold']}
icon={TextB}
tooltip={<BtnTooltip text="Bold" shortCode={`${modKey} + B`} />}
tooltip={
<BtnTooltip
text="Bold"
shortCode={shortcutLabel('composer.bold', shortcutOverrides)}
/>
}
/>
<MarkdownInlineButton
marker={INLINE_HOTKEYS['mod+i']!}
marker={INLINE_MARKERS['composer.italic']}
icon={TextItalic}
tooltip={<BtnTooltip text="Italic" shortCode={`${modKey} + I`} />}
tooltip={
<BtnTooltip
text="Italic"
shortCode={shortcutLabel('composer.italic', shortcutOverrides)}
/>
}
/>
<MarkdownInlineButton
marker={INLINE_HOTKEYS['mod+u']!}
marker={INLINE_MARKERS['composer.underline']}
icon={TextUnderline}
tooltip={<BtnTooltip text="Underline" shortCode={`${modKey} + U`} />}
tooltip={
<BtnTooltip
text="Underline"
shortCode={shortcutLabel('composer.underline', shortcutOverrides)}
/>
}
/>
<MarkdownInlineButton
marker={INLINE_HOTKEYS['mod+s']!}
marker={INLINE_MARKERS['composer.strikethrough']}
icon={TextStrikethrough}
tooltip={<BtnTooltip text="Strike Through" shortCode={`${modKey} + S`} />}
tooltip={
<BtnTooltip
text="Strike Through"
shortCode={shortcutLabel('composer.strikethrough', shortcutOverrides)}
/>
}
/>
<MarkdownInlineButton
marker={INLINE_HOTKEYS['mod+[']!}
marker={INLINE_MARKERS['composer.inlineCode']}
icon={Code}
tooltip={<BtnTooltip text="Inline Code" shortCode={`${modKey} + [`} />}
tooltip={
<BtnTooltip
text="Inline Code"
shortCode={shortcutLabel('composer.inlineCode', shortcutOverrides)}
/>
}
/>
<MarkdownInlineButton
marker={INLINE_HOTKEYS['mod+h']!}
marker={INLINE_MARKERS['composer.spoiler']}
icon={EyeSlash}
tooltip={<BtnTooltip text="Spoiler" shortCode={`${modKey} + H`} />}
tooltip={
<BtnTooltip
text="Spoiler"
shortCode={shortcutLabel('composer.spoiler', shortcutOverrides)}
/>
}
/>
</Box>
<Line variant="SurfaceVariant" direction="Vertical" style={{ height: toRem(12) }} />
<Box shrink="No" gap="100">
<MarkdownBlockButton
prefix={BLOCK_HOTKEYS["mod+'"]!}
prefix={BLOCK_PREFIXES['composer.blockquote']}
icon={Quotes}
tooltip={<BtnTooltip text="Block Quote" shortCode={`${modKey} + '`} />}
tooltip={
<BtnTooltip
text="Block Quote"
shortCode={shortcutLabel('composer.blockquote', shortcutOverrides)}
/>
}
/>
<MarkdownBlockButton
prefix={BLOCK_HOTKEYS['mod+;']!}
prefix={BLOCK_PREFIXES['composer.codeBlock']}
icon={CodeBlock}
tooltip={<BtnTooltip text="Block Code" shortCode={`${modKey} + ;`} />}
tooltip={
<BtnTooltip
text="Block Code"
shortCode={shortcutLabel('composer.codeBlock', shortcutOverrides)}
/>
}
/>
<MarkdownBlockButton
prefix={BLOCK_HOTKEYS['mod+7']!}
prefix={BLOCK_PREFIXES['composer.orderedList']}
icon={ListNumbers}
tooltip={<BtnTooltip text="Ordered List" shortCode={`${modKey} + 7`} />}
tooltip={
<BtnTooltip
text="Ordered List"
shortCode={shortcutLabel('composer.orderedList', shortcutOverrides)}
/>
}
/>
<MarkdownBlockButton
prefix={BLOCK_HOTKEYS['mod+8']!}
prefix={BLOCK_PREFIXES['composer.unorderedList']}
icon={ListBullets}
tooltip={<BtnTooltip text="Unordered List" shortCode={`${modKey} + 8`} />}
tooltip={
<BtnTooltip
text="Unordered List"
shortCode={shortcutLabel('composer.unorderedList', shortcutOverrides)}
/>
}
/>
<MarkdownHeadingButton />
</Box>
Expand Down
Loading
Loading