diff --git a/.changeset/configurable-keyboard-shortcuts.md b/.changeset/configurable-keyboard-shortcuts.md new file mode 100644 index 0000000000..1f00a10e71 --- /dev/null +++ b/.changeset/configurable-keyboard-shortcuts.md @@ -0,0 +1,5 @@ +--- +sable: patch +--- + +Allow keyboard shortcuts for navigation, bookmarks, search, and message formatting to be customized from Settings. diff --git a/src/app/components/GlobalKeyboardShortcuts.tsx b/src/app/components/GlobalKeyboardShortcuts.tsx index 3694c9b8f6..4fe06a18d8 100644 --- a/src/app/components/GlobalKeyboardShortcuts.tsx +++ b/src/app/components/GlobalKeyboardShortcuts.tsx @@ -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'; @@ -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(); @@ -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); @@ -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) @@ -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; @@ -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 = { @@ -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); diff --git a/src/app/components/editor/Editor.tsx b/src/app/components/editor/Editor.tsx index 0a61c2cadf..0a8d08916c 100644 --- a/src/app/components/editor/Editor.tsx +++ b/src/app/components/editor/Editor.tsx @@ -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; @@ -94,6 +96,7 @@ export const CustomEditor = forwardRef( }, ref ) => { + const [shortcutOverrides] = useSetting(settingsAtom, 'shortcutOverrides'); // Each 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 @@ -378,10 +381,10 @@ export const CustomEditor = forwardRef( 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( diff --git a/src/app/components/editor/MarkdownToolbar.tsx b/src/app/components/editor/MarkdownToolbar.tsx index aea81d1056..5688a92c40 100644 --- a/src/app/components/editor/MarkdownToolbar.tsx +++ b/src/app/components/editor/MarkdownToolbar.tsx @@ -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, @@ -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; @@ -133,7 +136,7 @@ function MarkdownBlockButton({ prefix, icon, tooltip }: MarkdownBlockButtonProps function MarkdownHeadingButton() { const editor = useSlate(); const [anchor, setAnchor] = useState(); - const modKey = isMacOS() ? KeySymbol.Command : 'Ctrl'; + const [shortcutOverrides] = useSetting(settingsAtom, 'shortcutOverrides'); const handleMenuSelect = (prefix: string) => { setAnchor(undefined); @@ -165,7 +168,12 @@ function MarkdownHeadingButton() { } + tooltip={ + + } delay={500} > {(triggerRef) => ( @@ -180,7 +188,12 @@ function MarkdownHeadingButton() { )} } + tooltip={ + + } delay={500} > {(triggerRef) => ( @@ -195,7 +208,12 @@ function MarkdownHeadingButton() { )} } + tooltip={ + + } delay={500} > {(triggerRef) => ( @@ -231,7 +249,7 @@ function MarkdownHeadingButton() { } export function MarkdownToolbar() { - const modKey = isMacOS() ? KeySymbol.Command : 'Ctrl'; + const [shortcutOverrides] = useSetting(settingsAtom, 'shortcutOverrides'); return ( @@ -239,57 +257,107 @@ export function MarkdownToolbar() { } + tooltip={ + + } /> } + tooltip={ + + } /> } + tooltip={ + + } /> } + tooltip={ + + } /> } + tooltip={ + + } /> } + tooltip={ + + } /> } + tooltip={ + + } /> } + tooltip={ + + } /> } + tooltip={ + + } /> } + tooltip={ + + } /> diff --git a/src/app/components/editor/keyboard.ts b/src/app/components/editor/keyboard.ts index 05eb432132..12d31963fc 100644 --- a/src/app/components/editor/keyboard.ts +++ b/src/app/components/editor/keyboard.ts @@ -1,27 +1,30 @@ -import { isKeyHotkey } from 'is-hotkey'; import type { KeyboardEvent } from 'react'; import { Editor, Range, Transforms } from 'slate'; +import { HistoryEditor } from 'slate-history'; +import type { ShortcutId, ShortcutOverrides } from '../../keyboard/shortcuts'; +import { matchesShortcut } from '../../keyboard/shortcuts'; -export const INLINE_HOTKEYS: Record = { - 'mod+b': '**', - 'mod+i': '*', - 'mod+u': '__', - 'mod+s': '~~', - 'mod+[': '`', - 'mod+h': '||', -}; -const INLINE_KEYS = Object.keys(INLINE_HOTKEYS); +export const INLINE_MARKERS = { + 'composer.bold': '**', + 'composer.italic': '*', + 'composer.underline': '__', + 'composer.strikethrough': '~~', + 'composer.inlineCode': '`', + 'composer.spoiler': '||', +} satisfies Partial>; -export const BLOCK_HOTKEYS: Record = { - 'mod+7': '1. ', - 'mod+8': '- ', - "mod+'": '> ', - 'mod+;': '```\n', -}; -const BLOCK_KEYS = Object.keys(BLOCK_HOTKEYS); -const isHeading1 = isKeyHotkey('mod+1'); -const isHeading2 = isKeyHotkey('mod+2'); -const isHeading3 = isKeyHotkey('mod+3'); +export const BLOCK_PREFIXES = { + 'composer.heading1': '# ', + 'composer.heading2': '## ', + 'composer.heading3': '### ', + 'composer.blockquote': '> ', + 'composer.codeBlock': '```\n', + 'composer.orderedList': '1. ', + 'composer.unorderedList': '- ', +} satisfies Partial>; + +const INLINE_ACTIONS = Object.entries(INLINE_MARKERS) as [ShortcutId, string][]; +const BLOCK_ACTIONS = Object.entries(BLOCK_PREFIXES) as [ShortcutId, string][]; export const applyMarkdownInline = (editor: Editor, marker: string) => { if (editor.selection && Range.isExpanded(editor.selection)) { @@ -44,41 +47,36 @@ export const applyMarkdownBlockPrefix = (editor: Editor, prefix: string) => { /** * @return boolean true if shortcut is toggled. */ -export const toggleKeyboardShortcut = (editor: Editor, event: KeyboardEvent): boolean => { - if (isKeyHotkey('escape', event)) { - return false; +export const toggleKeyboardShortcut = ( + editor: Editor, + event: KeyboardEvent, + overrides: ShortcutOverrides +): boolean => { + if (matchesShortcut('composer.undo', event, overrides)) { + event.preventDefault(); + HistoryEditor.undo(editor as HistoryEditor); + return true; + } + if (matchesShortcut('composer.redo', event, overrides)) { + event.preventDefault(); + HistoryEditor.redo(editor as HistoryEditor); + return true; } - const blockToggled = BLOCK_KEYS.find((hotkey) => { - if (isKeyHotkey(hotkey, event)) { + const blockToggled = BLOCK_ACTIONS.find(([id, prefix]) => { + if (matchesShortcut(id, event, overrides)) { event.preventDefault(); - applyMarkdownBlockPrefix(editor, BLOCK_HOTKEYS[hotkey]!); + applyMarkdownBlockPrefix(editor, prefix); return true; } return false; }); if (blockToggled) return true; - if (isHeading1(event)) { - event.preventDefault(); - applyMarkdownBlockPrefix(editor, '# '); - return true; - } - if (isHeading2(event)) { - event.preventDefault(); - applyMarkdownBlockPrefix(editor, '## '); - return true; - } - if (isHeading3(event)) { - event.preventDefault(); - applyMarkdownBlockPrefix(editor, '### '); - return true; - } - - const inlineToggled = INLINE_KEYS.find((hotkey) => { - if (isKeyHotkey(hotkey, event)) { + const inlineToggled = INLINE_ACTIONS.find(([id, marker]) => { + if (matchesShortcut(id, event, overrides)) { event.preventDefault(); - applyMarkdownInline(editor, INLINE_HOTKEYS[hotkey]!); + applyMarkdownInline(editor, marker); return true; } return false; diff --git a/src/app/features/navigate/NavigateModal.tsx b/src/app/features/navigate/NavigateModal.tsx index f4f77d22ae..02abc26735 100644 --- a/src/app/features/navigate/NavigateModal.tsx +++ b/src/app/features/navigate/NavigateModal.tsx @@ -47,10 +47,11 @@ import { UnreadBadge, UnreadBadgeCenter } from '$components/unread-badge'; import { searchModalAtom } from '$state/searchModal'; import { useKeyDown } from '$hooks/useKeyDown'; import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; -import { KeySymbol } from '$utils/key-symbol'; -import { isMacOS } from '$utils/user-agent'; import { useSelectedSpace } from '$hooks/router/useSelectedSpace'; import { getMxIdServer } from '$utils/mxIdHelper'; +import { useSetting } from '$state/hooks/settings'; +import { settingsAtom } from '$state/settings'; +import { formatShortcut, getShortcutBinding, matchesShortcut } from '../../keyboard/shortcuts'; enum SearchRoomType { Rooms = '#', @@ -138,6 +139,7 @@ export type RoomSearchModalProps = { }; export function RoomSearchModal({ requestClose, pickRoom, isMobile }: RoomSearchModalProps) { + const [shortcutOverrides] = useSetting(settingsAtom, 'shortcutOverrides'); const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const scrollRef = useRef(null); @@ -495,7 +497,9 @@ export function RoomSearchModal({ requestClose, pickRoom, isMobile }: RoomSearch ) : ( <> Type # for rooms, @ for DMs and * for spaces. Hotkey:{' '} - {isMacOS() ? KeySymbol.Command : 'Ctrl'} + k + + {formatShortcut(getShortcutBinding('navigation.openRoomSearch', shortcutOverrides))} + )} @@ -506,18 +510,19 @@ export function RoomSearchModal({ requestClose, pickRoom, isMobile }: RoomSearch export function SearchModalRenderer() { const [opened, setOpen] = useAtom(searchModalAtom); + const [shortcutOverrides] = useSetting(settingsAtom, 'shortcutOverrides'); useKeyDown( window, useCallback( (event) => { - if (isKeyHotkey('mod+k', event)) { + if (matchesShortcut('navigation.openRoomSearch', event, shortcutOverrides)) { event.preventDefault(); setOpen(!opened); return; } }, - [opened, setOpen] + [opened, setOpen, shortcutOverrides] ) ); diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 6a170c996d..affdfa4e1c 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -91,6 +91,7 @@ import { safeFile } from '$utils/mimeTypes'; import { fulfilledPromiseSettledResult } from '$utils/common'; import { useSetting } from '$state/hooks/settings'; import { settingsAtom } from '$state/settings'; +import { matchesShortcut } from '../../keyboard/shortcuts'; import { getMentionContent, isThreadRelationEvent, reactionOrEditEvent } from '$utils/room'; import { Command, SHRUG, TABLEFLIP, UNFLIP, useCommands } from '$hooks/useCommands'; import { mobileOrTablet } from '$utils/user-agent'; @@ -290,6 +291,7 @@ export const RoomInput = forwardRef( const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline'); const [editorOldAddFile] = useSetting(settingsAtom, 'editorOldAddFile'); const [showGifPicker] = useSetting(settingsAtom, 'enableGifPicker'); + const [shortcutOverrides] = useSetting(settingsAtom, 'shortcutOverrides'); const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const [mentionInReplies] = useSetting(settingsAtom, 'mentionInReplies'); @@ -1262,7 +1264,7 @@ export const RoomInput = forwardRef( setReplyDraft(undefined); } - if (isKeyHotkey('control+e', evt)) { + if (matchesShortcut('composer.openStickerPicker', evt, shortcutOverrides)) { evt.preventDefault(); setEmojiBoardTab(EmojiBoardTab.Sticker); } @@ -1278,6 +1280,7 @@ export const RoomInput = forwardRef( editor, onEditLastMessage, setEmojiBoardTab, + shortcutOverrides, ] ); diff --git a/src/app/features/settings/keyboard-shortcuts/KeyboardShortcuts.test.tsx b/src/app/features/settings/keyboard-shortcuts/KeyboardShortcuts.test.tsx new file mode 100644 index 0000000000..7d0b69b4d6 --- /dev/null +++ b/src/app/features/settings/keyboard-shortcuts/KeyboardShortcuts.test.tsx @@ -0,0 +1,46 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { Provider, createStore } from 'jotai'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { ScreenSize, ScreenSizeProvider } from '$hooks/useScreenSize'; +import { defaultSettings, settingsAtom } from '$state/settings'; +import { KeyboardShortcuts } from './KeyboardShortcuts'; + +const renderPage = () => { + const store = createStore(); + store.set(settingsAtom, { ...defaultSettings, shortcutOverrides: {} }); + render( + + + {}} /> + + + ); + return store; +}; + +describe('KeyboardShortcuts', () => { + beforeEach(() => localStorage.clear()); + + it('captures and resets a custom binding', () => { + const store = renderPage(); + const change = screen.getByRole('button', { name: 'Change Bold' }); + + fireEvent.click(change); + fireEvent.keyDown(change, { key: 'j', ctrlKey: true }); + expect(store.get(settingsAtom).shortcutOverrides['composer.bold']).toBe('mod+j'); + + fireEvent.click(screen.getAllByRole('button', { name: 'Reset' })[0]!); + expect(store.get(settingsAtom).shortcutOverrides['composer.bold']).toBeUndefined(); + }); + + it('reports a binding conflict in an overlapping scope', () => { + const store = renderPage(); + const change = screen.getByRole('button', { name: 'Change Bold' }); + + fireEvent.click(change); + fireEvent.keyDown(change, { key: 'f', ctrlKey: true }); + + expect(screen.getByText(/Already used by “Search for messages”/)).toBeInTheDocument(); + expect(store.get(settingsAtom).shortcutOverrides['composer.bold']).toBeUndefined(); + }); +}); diff --git a/src/app/features/settings/keyboard-shortcuts/KeyboardShortcuts.tsx b/src/app/features/settings/keyboard-shortcuts/KeyboardShortcuts.tsx index ff1ea36caa..c2f969b7f2 100644 --- a/src/app/features/settings/keyboard-shortcuts/KeyboardShortcuts.tsx +++ b/src/app/features/settings/keyboard-shortcuts/KeyboardShortcuts.tsx @@ -1,113 +1,110 @@ -/** - * Keyboard Shortcuts settings page. - * - * Lists all keyboard shortcuts available in Sable in a semantic, - * screen-reader-friendly dl/dt/dd structure. - */ -import { Box, Scroll, Text, config } from 'folds'; +import { useState } from 'react'; +import type { KeyboardEvent as ReactKeyboardEvent } from 'react'; +import { Box, Button, Scroll, Text, config } from 'folds'; import { PageContent } from '$components/page'; +import { SequenceCard } from '$components/sequence-card'; +import { SettingTile } from '$components/setting-tile'; +import { SequenceCardStyle } from '$features/settings/styles.css'; +import { useSetting } from '$state/hooks/settings'; +import { settingsAtom } from '$state/settings'; +import { + SHORTCUTS, + captureShortcut, + findShortcutConflict, + formatShortcut, + getShortcutBinding, +} from '../../../keyboard/shortcuts'; +import type { ShortcutDefinition, ShortcutId } from '../../../keyboard/shortcuts'; import { SettingsSectionPage } from '../SettingsSectionPage'; -type ShortcutEntry = { - keys: string; - description: string; -}; - -type ShortcutCategory = { - name: string; - shortcuts: ShortcutEntry[]; -}; +const CATEGORIES = ['General', 'Navigation', 'Messages'] as const; -function formatKey(key: string): string { - const isMac = - typeof navigator !== 'undefined' && navigator.platform.toUpperCase().indexOf('MAC') >= 0; - return key - .replace(/\bmod\b/g, isMac ? '⌘' : 'Ctrl') - .replace(/\balt\b/gi, isMac ? '⌥' : 'Alt') - .replace(/\bshift\b/gi, '⇧'); +function ShortcutKeys({ binding }: { binding: string | null }) { + const label = formatShortcut(binding); + return ( + + {label} + + ); } -const SHORTCUT_CATEGORIES: ShortcutCategory[] = [ - { - name: 'General', - shortcuts: [{ keys: 'Ctrl+F / ⌘+F', description: 'Search for messages' }], - }, - { - name: 'Navigation', - shortcuts: [ - { keys: 'Alt+N', description: 'Jump to the highest-priority unread room' }, - { keys: 'Alt+Shift+Down', description: 'Go to next unread room (cycle)' }, - { keys: 'Alt+Shift+Up', description: 'Go to previous unread room (cycle)' }, - { keys: 'Ctrl+K / ⌘+K', description: 'Search and go to Room' }, - ], - }, - { - name: 'Messages', - shortcuts: [ - { keys: 'Ctrl+Z / ⌘+Z', description: 'Undo in message editor' }, - { keys: 'Ctrl+Shift+Z / ⌘+Shift+Z', description: 'Redo in message editor' }, - { keys: 'Ctrl+B / ⌘+B', description: 'Bold' }, - { keys: 'Ctrl+I / ⌘+I', description: 'Italic' }, - { keys: 'Ctrl+U / ⌘+U', description: 'Underline' }, - { keys: 'Ctrl+E / ⌘+E', description: 'Open Sticker Picker' }, - ], - }, -]; +type ShortcutRowProps = { + shortcut: ShortcutDefinition; + binding: string | null; + customized: boolean; + editing: boolean; + error?: string; + onEdit: () => void; + onReset: () => void; + onKeyDown: (event: ReactKeyboardEvent) => void; +}; -function ShortcutRow({ keys, description }: ShortcutEntry) { - const parts = keys.split('/').map((k) => k.trim()); +function ShortcutRow({ + shortcut, + binding, + customized, + editing, + error, + onEdit, + onReset, + onKeyDown, +}: ShortcutRowProps) { return ( -
+ + + {customized && ( + + )} + + } > - - {description} - - - {parts.map((part, i) => ( - - {part.split('+').map((seg, si, arr) => ( - - - {formatKey(seg)} - - {si < arr.length - 1 && ( - - )} - - ))} - {i < parts.length - 1 && ( - - {' / '} - - )} - - ))} - -
+ {editing && error && ( + + {error} + + )} + ); } @@ -115,7 +112,45 @@ type KeyboardShortcutsProps = { requestBack?: () => void; requestClose: () => void; }; + export function KeyboardShortcuts({ requestBack, requestClose }: KeyboardShortcutsProps) { + const [overrides, setOverrides] = useSetting(settingsAtom, 'shortcutOverrides'); + const [editingId, setEditingId] = useState(); + const [error, setError] = useState(); + + const updateOverride = (id: ShortcutId, binding: string | null | undefined) => { + setOverrides((current) => { + const next = { ...current }; + if (binding === undefined) delete next[id]; + else next[id] = binding; + return next; + }); + setEditingId(undefined); + setError(undefined); + }; + + const handleCapture = (id: ShortcutId, event: ReactKeyboardEvent) => { + event.preventDefault(); + event.stopPropagation(); + if (event.key === 'Escape') { + setEditingId(undefined); + setError(undefined); + return; + } + if (event.key === 'Backspace' || event.key === 'Delete') { + updateOverride(id, null); + return; + } + const binding = captureShortcut(event); + if (!binding) return; + const conflict = findShortcutConflict(id, binding, overrides); + if (conflict) { + setError(`Already used by “${conflict.label}” in this context.`); + return; + } + updateOverride(id, binding); + }; + return ( - {SHORTCUT_CATEGORIES.map((category) => ( - + + Choose Change, then press a new key combination. Global shortcuts do not run while + typing unless the action specifically supports it. + + {CATEGORIES.map((category) => ( + - {category.name} + {category} -
- {category.shortcuts.map((entry) => ( -
-
{entry.keys}
-
- -
-
- ))} -
+ + {SHORTCUTS.filter((shortcut) => shortcut.category === category).map( + (shortcut) => ( + + { + setEditingId(shortcut.id); + setError(undefined); + }} + onReset={() => updateOverride(shortcut.id, undefined)} + onKeyDown={(event) => handleCapture(shortcut.id, event)} + /> + + ) + )} +
))}
diff --git a/src/app/keyboard/shortcuts.test.ts b/src/app/keyboard/shortcuts.test.ts new file mode 100644 index 0000000000..c554e9344c --- /dev/null +++ b/src/app/keyboard/shortcuts.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; +import { + captureShortcut, + findShortcutConflict, + getShortcutBinding, + matchesShortcut, + sanitizeShortcutOverrides, +} from './shortcuts'; + +const keyEvent = (key: string, init: Partial = {}) => + ({ + key, + which: 0, + altKey: false, + ctrlKey: false, + metaKey: false, + shiftKey: false, + ...init, + }) as KeyboardEvent; + +describe('keyboard shortcuts', () => { + it('resolves defaults, overrides, and disabled actions', () => { + expect(getShortcutBinding('composer.bold', {})).toBe('mod+b'); + expect(getShortcutBinding('composer.bold', { 'composer.bold': 'alt+b' })).toBe('alt+b'); + expect(getShortcutBinding('composer.bold', { 'composer.bold': null })).toBeNull(); + }); + + it('captures modifiers in a portable form', () => { + expect(captureShortcut(keyEvent('K', { ctrlKey: true, shiftKey: true }))).toBe('mod+shift+k'); + expect(captureShortcut(keyEvent('Meta', { metaKey: true }))).toBeUndefined(); + }); + + it('matches custom bindings and ignores disabled bindings', () => { + const event = keyEvent('j', { ctrlKey: true }); + expect(matchesShortcut('composer.bold', event, { 'composer.bold': 'mod+j' })).toBe(true); + expect(matchesShortcut('composer.bold', event, { 'composer.bold': null })).toBe(false); + }); + + it('does not run ordinary global actions in editable controls', () => { + const input = document.createElement('input'); + const event = keyEvent('b', { ctrlKey: true, target: input }); + expect(matchesShortcut('app.openBookmarks', event, {})).toBe(false); + expect( + matchesShortcut('app.searchMessages', keyEvent('f', { ctrlKey: true, target: input }), {}) + ).toBe(true); + }); + + it('detects collisions only when scopes can overlap', () => { + expect(findShortcutConflict('composer.bold', 'mod+b', {})).toBeUndefined(); + expect(findShortcutConflict('composer.bold', 'mod+f', {})?.id).toBe('app.searchMessages'); + }); + + it('sanitizes imported overrides', () => { + expect( + sanitizeShortcutOverrides({ + 'composer.bold': 'alt+b', + 'composer.italic': null, + 'composer.spoiler': 'not-a-real-modifier+x', + unknown: 'mod+x', + }) + ).toEqual({ 'composer.bold': 'alt+b', 'composer.italic': null }); + }); +}); diff --git a/src/app/keyboard/shortcuts.ts b/src/app/keyboard/shortcuts.ts new file mode 100644 index 0000000000..731df79b18 --- /dev/null +++ b/src/app/keyboard/shortcuts.ts @@ -0,0 +1,322 @@ +import { isKeyHotkey, parseHotkey } from 'is-hotkey'; +import type { KeyboardEventLike } from 'is-hotkey'; + +type ShortcutEvent = KeyboardEventLike & { target?: EventTarget | null }; + +export type ShortcutScope = 'global' | 'composer'; + +export type ShortcutId = + | 'app.searchMessages' + | 'app.openBookmarks' + | 'navigation.nextUnread' + | 'navigation.cycleNextUnread' + | 'navigation.cyclePreviousUnread' + | 'navigation.openRoomSearch' + | 'navigation.nextReply' + | 'navigation.previousReply' + | 'composer.undo' + | 'composer.redo' + | 'composer.bold' + | 'composer.italic' + | 'composer.underline' + | 'composer.strikethrough' + | 'composer.inlineCode' + | 'composer.spoiler' + | 'composer.heading1' + | 'composer.heading2' + | 'composer.heading3' + | 'composer.blockquote' + | 'composer.codeBlock' + | 'composer.orderedList' + | 'composer.unorderedList' + | 'composer.openStickerPicker'; + +export type ShortcutOverrides = Partial>; + +export type ShortcutDefinition = { + id: ShortcutId; + label: string; + category: 'General' | 'Navigation' | 'Messages'; + defaultBinding: string; + scope: ShortcutScope; + allowInEditable?: boolean; +}; + +export const SHORTCUTS: readonly ShortcutDefinition[] = [ + { + id: 'app.searchMessages', + label: 'Search for messages', + category: 'General', + defaultBinding: 'mod+f', + scope: 'global', + allowInEditable: true, + }, + { + id: 'app.openBookmarks', + label: 'Open bookmarks', + category: 'General', + defaultBinding: 'mod+shift+b', + scope: 'global', + }, + { + id: 'navigation.nextUnread', + label: 'Jump to the highest-priority unread room', + category: 'Navigation', + defaultBinding: 'alt+n', + scope: 'global', + }, + { + id: 'navigation.cycleNextUnread', + label: 'Go to next unread room (cycle)', + category: 'Navigation', + defaultBinding: 'alt+shift+down', + scope: 'global', + }, + { + id: 'navigation.cyclePreviousUnread', + label: 'Go to previous unread room (cycle)', + category: 'Navigation', + defaultBinding: 'alt+shift+up', + scope: 'global', + }, + { + id: 'navigation.openRoomSearch', + label: 'Search and go to room', + category: 'Navigation', + defaultBinding: 'mod+k', + scope: 'global', + allowInEditable: true, + }, + { + id: 'navigation.nextReply', + label: 'Target next message to reply to', + category: 'Navigation', + defaultBinding: 'mod+down', + scope: 'global', + allowInEditable: true, + }, + { + id: 'navigation.previousReply', + label: 'Target previous message to reply to', + category: 'Navigation', + defaultBinding: 'mod+up', + scope: 'global', + allowInEditable: true, + }, + { + id: 'composer.undo', + label: 'Undo in message editor', + category: 'Messages', + defaultBinding: 'mod+z', + scope: 'composer', + }, + { + id: 'composer.redo', + label: 'Redo in message editor', + category: 'Messages', + defaultBinding: 'mod+shift+z', + scope: 'composer', + }, + { + id: 'composer.bold', + label: 'Bold', + category: 'Messages', + defaultBinding: 'mod+b', + scope: 'composer', + }, + { + id: 'composer.italic', + label: 'Italic', + category: 'Messages', + defaultBinding: 'mod+i', + scope: 'composer', + }, + { + id: 'composer.underline', + label: 'Underline', + category: 'Messages', + defaultBinding: 'mod+u', + scope: 'composer', + }, + { + id: 'composer.strikethrough', + label: 'Strikethrough', + category: 'Messages', + defaultBinding: 'mod+s', + scope: 'composer', + }, + { + id: 'composer.inlineCode', + label: 'Inline code', + category: 'Messages', + defaultBinding: 'mod+[', + scope: 'composer', + }, + { + id: 'composer.spoiler', + label: 'Spoiler', + category: 'Messages', + defaultBinding: 'mod+h', + scope: 'composer', + }, + { + id: 'composer.heading1', + label: 'Heading 1', + category: 'Messages', + defaultBinding: 'mod+1', + scope: 'composer', + }, + { + id: 'composer.heading2', + label: 'Heading 2', + category: 'Messages', + defaultBinding: 'mod+2', + scope: 'composer', + }, + { + id: 'composer.heading3', + label: 'Heading 3', + category: 'Messages', + defaultBinding: 'mod+3', + scope: 'composer', + }, + { + id: 'composer.blockquote', + label: 'Block quote', + category: 'Messages', + defaultBinding: "mod+'", + scope: 'composer', + }, + { + id: 'composer.codeBlock', + label: 'Code block', + category: 'Messages', + defaultBinding: 'mod+;', + scope: 'composer', + }, + { + id: 'composer.orderedList', + label: 'Ordered list', + category: 'Messages', + defaultBinding: 'mod+7', + scope: 'composer', + }, + { + id: 'composer.unorderedList', + label: 'Unordered list', + category: 'Messages', + defaultBinding: 'mod+8', + scope: 'composer', + }, + { + id: 'composer.openStickerPicker', + label: 'Open sticker picker', + category: 'Messages', + defaultBinding: 'control+e', + scope: 'composer', + }, +] as const; + +export const SHORTCUT_IDS = new Set(SHORTCUTS.map(({ id }) => id)); +export const SHORTCUT_BY_ID = new Map(SHORTCUTS.map((shortcut) => [shortcut.id, shortcut])); + +export const getShortcutBinding = (id: ShortcutId, overrides: ShortcutOverrides): string | null => { + const override = overrides[id]; + return override === undefined ? SHORTCUT_BY_ID.get(id)!.defaultBinding : override; +}; + +const editableEventTarget = (event: ShortcutEvent): boolean => { + const target = event.target; + return ( + target instanceof HTMLElement && + (target.isContentEditable || + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement || + target instanceof HTMLSelectElement) + ); +}; + +export const matchesShortcut = ( + id: ShortcutId, + event: ShortcutEvent, + overrides: ShortcutOverrides +): boolean => { + const shortcut = SHORTCUT_BY_ID.get(id)!; + if (shortcut.scope === 'global' && !shortcut.allowInEditable && editableEventTarget(event)) + return false; + const binding = getShortcutBinding(id, overrides); + return binding !== null && isKeyHotkey(binding, event); +}; + +const KEY_NAMES: Record = { + ' ': 'Space', + arrowup: 'Up', + arrowdown: 'Down', + arrowleft: 'Left', + arrowright: 'Right', + escape: 'Esc', + control: 'Ctrl', + meta: 'Meta', +}; + +export const formatShortcut = (binding: string | null): string => { + if (binding === null) return 'Unassigned'; + const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform); + return binding + .split('+') + .map((part) => { + const lower = part.toLowerCase(); + if (lower === 'mod') return isMac ? '⌘' : 'Ctrl'; + if (lower === 'alt') return isMac ? '⌥' : 'Alt'; + if (lower === 'shift') return isMac ? '⇧' : 'Shift'; + return KEY_NAMES[lower] ?? (part.length === 1 ? part.toUpperCase() : part); + }) + .join('+'); +}; + +export const captureShortcut = (event: ShortcutEvent): string | undefined => { + if (['Control', 'Meta', 'Alt', 'Shift'].includes(event.key)) return undefined; + const key = event.key === ' ' ? 'space' : event.key.toLowerCase(); + const modifiers: string[] = []; + const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform); + if (event.metaKey) modifiers.push(isMac ? 'mod' : 'meta'); + if (event.ctrlKey) modifiers.push(isMac ? 'control' : 'mod'); + if (event.altKey) modifiers.push('alt'); + if (event.shiftKey) modifiers.push('shift'); + return [...modifiers, key].join('+'); +}; + +export const findShortcutConflict = ( + id: ShortcutId, + binding: string, + overrides: ShortcutOverrides +): ShortcutDefinition | undefined => { + const current = SHORTCUT_BY_ID.get(id)!; + return SHORTCUTS.find((candidate) => { + if (candidate.id === id || getShortcutBinding(candidate.id, overrides) !== binding) + return false; + return ( + current.scope === candidate.scope || + (current.scope === 'composer' && candidate.allowInEditable) || + (candidate.scope === 'composer' && current.allowInEditable) + ); + }); +}; + +export const sanitizeShortcutOverrides = (value: unknown): ShortcutOverrides | undefined => { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; + const result: ShortcutOverrides = {}; + for (const [id, binding] of Object.entries(value)) { + if (!SHORTCUT_IDS.has(id as ShortcutId)) continue; + if (binding === null) result[id as ShortcutId] = null; + else if (typeof binding === 'string' && binding.length > 0 && binding.length <= 64) { + try { + parseHotkey(binding, { byKey: true }); + result[id as ShortcutId] = binding; + } catch { + // Ignore malformed imported bindings. + } + } + } + return result; +}; diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index d918344093..bc7d506b3f 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -2,6 +2,7 @@ import { atom, type WritableAtom } from 'jotai'; import type { Store } from 'jotai/vanilla/store'; import { mobileOrTablet } from '$utils/user-agent'; import type { IImageInfo } from '$types/matrix/common'; +import { sanitizeShortcutOverrides, type ShortcutOverrides } from '../keyboard/shortcuts'; const STORAGE_KEY = 'settings'; export type DateFormat = 'D MMM YYYY' | 'DD/MM/YYYY' | 'MM/DD/YYYY' | 'YYYY/MM/DD' | ''; @@ -88,6 +89,7 @@ export function shouldApplyUserHeroCards( } export interface Settings { + shortcutOverrides: ShortcutOverrides; themeId?: string; useSystemTheme: boolean; lightThemeId?: string; @@ -251,6 +253,7 @@ export interface Settings { } export const defaultSettings: Settings = { + shortcutOverrides: {}, themeId: undefined, useSystemTheme: true, lightThemeId: undefined, @@ -416,6 +419,7 @@ export const defaultSettings: Settings = { function cloneDefaultSettings(): Settings { return { ...defaultSettings, + shortcutOverrides: { ...defaultSettings.shortcutOverrides }, themeRemoteFavorites: defaultSettings.themeRemoteFavorites.map((x) => ({ ...x, })), @@ -431,6 +435,10 @@ const isCallToneId = (value: unknown): value is CallRingtoneId => CALL_TONE_ID_S const clampPercent = (value: number): number => Math.max(0, Math.min(100, Math.round(value))); function migrateParsedLocalStorage(parsed: Record): void { + const shortcutOverrides = sanitizeShortcutOverrides(parsed.shortcutOverrides); + if (shortcutOverrides) parsed.shortcutOverrides = shortcutOverrides; + else delete parsed.shortcutOverrides; + if (parsed.monochromeMode === true && parsed.saturationLevel === undefined) { parsed.saturationLevel = 0; } else if (parsed.monochromeMode === false && parsed.saturationLevel === undefined) { @@ -592,6 +600,8 @@ function isSanitizableSettingsKey(k: string): k is keyof Settings { function sanitizeSettingsKey(key: keyof Settings, val: unknown): unknown { switch (key) { + case 'shortcutOverrides': + return sanitizeShortcutOverrides(val); case 'filterPronounsBasedOnLanguage': return typeof val === 'boolean' ? val : undefined; case 'filterPronounsLanguages': diff --git a/src/app/utils/settingsSync.ts b/src/app/utils/settingsSync.ts index bd565356b0..39cba52277 100644 --- a/src/app/utils/settingsSync.ts +++ b/src/app/utils/settingsSync.ts @@ -1,4 +1,5 @@ import type { Settings } from '$state/settings'; +import { sanitizeShortcutOverrides } from '../keyboard/shortcuts'; /** * Keys excluded from cross-device sync. @@ -54,6 +55,9 @@ export const deserializeFromSync = (data: unknown, currentSettings: Settings): S if (!remote || typeof remote !== 'object' || Array.isArray(remote)) return null; const merged = { ...currentSettings, ...(remote as Partial) }; + merged.shortcutOverrides = + sanitizeShortcutOverrides((remote as Partial).shortcutOverrides) ?? + currentSettings.shortcutOverrides; // Always restore non-syncable keys from local state. NON_SYNCABLE_KEYS.forEach((key) => { (merged as unknown as Record)[key] = (