Skip to content

Commit bcedfb3

Browse files
Centralize keyboard shortcuts (#1175)
This also adds two new keyboard shortcuts: Save project hex: (Windows) Ctrl+Shift+S; (Mac) Cmd+Shift+S Send to micro:bit: (Windows) Ctrl+Shift+E; (Mac) Cmd+Shift+E
1 parent 027bb24 commit bcedfb3

17 files changed

+139
-103
lines changed

package-lock.json

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
"mobile-drag-drop": "^2.3.0-rc.2",
4747
"react": "^18.0.0",
4848
"react-dom": "^18.0.0",
49+
"react-hotkeys-hook": "^4.5.0",
4950
"react-icons": "^4.8.0",
5051
"react-intl": "^6.2.10",
5152
"vite": "^5.1.5",

src/common/GenericDialog.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { ThemingProps } from "@chakra-ui/styled-system";
1616
import { ReactNode } from "react";
1717
import { FormattedMessage } from "react-intl";
1818
import ModalCloseButton from "./ModalCloseButton";
19+
import { FinalFocusRef } from "../project/project-actions";
1920

2021
export interface GenericDialogProps {
2122
header?: ReactNode;
@@ -24,7 +25,7 @@ export interface GenericDialogProps {
2425
size?: ThemingProps<"Button">["size"];
2526
onClose: () => void;
2627
returnFocusOnClose?: boolean;
27-
finalFocusRef?: React.RefObject<HTMLButtonElement>;
28+
finalFocusRef?: FinalFocusRef;
2829
}
2930

3031
export const GenericDialog = ({

src/common/InputDialog.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
import { ThemeTypings } from "@chakra-ui/styled-system";
1717
import { ReactNode, useCallback, useState } from "react";
1818
import { FormattedMessage } from "react-intl";
19+
import { FinalFocusRef } from "../project/project-actions";
1920

2021
export interface InputValidationResult {
2122
ok: boolean;
@@ -39,7 +40,7 @@ export interface InputDialogProps<T> {
3940
actionLabel: string;
4041
size?: ThemeTypings["components"]["Modal"]["sizes"];
4142
validate?: (input: T) => InputValidationResult;
42-
finalFocusRef?: React.RefObject<HTMLButtonElement>;
43+
finalFocusRef?: FinalFocusRef;
4344
callback: (value: ValueOrCancelled<T>) => void;
4445
}
4546

src/common/PostSaveDialog.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { ReactNode, useCallback } from "react";
88
import { FormattedMessage } from "react-intl";
99
import { GenericDialog, GenericDialogFooter } from "../common/GenericDialog";
1010
import { useProject } from "../project/project-hooks";
11+
import { FinalFocusRef } from "../project/project-actions";
1112

1213
export const enum PostSaveChoice {
1314
ShowTransferHexHelp,
@@ -18,7 +19,7 @@ export const enum PostSaveChoice {
1819
interface PostSaveDialogProps {
1920
callback: (value: PostSaveChoice) => void;
2021
dialogNormallyHidden: boolean;
21-
finalFocusRef: React.RefObject<HTMLButtonElement>;
22+
finalFocusRef: FinalFocusRef;
2223
}
2324

2425
export const PostSaveDialog = ({

src/common/keyboard-shortcuts.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// Shortcuts are global unless noted otherwise.
2+
export const keyboardShortcuts = {
3+
// This is scoped by keyboard focus.
4+
copyCode: ["ctrl+c", "meta+c", "enter"],
5+
search: ["ctrl+shift+f", "meta+shift+f"],
6+
sendToMicrobit: ["ctrl+shift+e", "meta+shift+e"],
7+
saveProject: ["ctrl+shift+s", "meta+shift+s"],
8+
};
9+
10+
export const globalShortcutConfig = {
11+
preventDefault: true,
12+
enableOnContentEditable: true,
13+
enableOnFormTags: true,
14+
};

src/documentation/api/ApiNode.tsx

Lines changed: 6 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ import ShowMoreButton from "../common/ShowMoreButton";
4141
import { allowWrapAtPeriods } from "../common/wrap";
4242
import { useCodeDragImage } from "../documentation-hooks";
4343
import Highlight from "../reference/Highlight";
44+
import { useHotkeys } from "react-hotkeys-hook";
45+
import { keyboardShortcuts } from "../../common/keyboard-shortcuts";
4446

4547
const kindToFontSize: Record<string, any> = {
4648
module: "2xl",
@@ -422,20 +424,9 @@ const DraggableSignature = ({
422424
onCopy();
423425
await actions?.copyCode(code, codeWithImports, type, id);
424426
}, [actions, code, codeWithImports, onCopy, type, id]);
425-
const isMac = /Mac/.test(navigator.platform);
426-
const handleKeyDown = useCallback(
427-
async (e: React.KeyboardEvent<HTMLDivElement>) => {
428-
if (e.key === "Enter") {
429-
e.preventDefault();
430-
handleCopyCode();
431-
}
432-
if ((e.key === "c" || e.key === "C") && (isMac ? e.metaKey : e.ctrlKey)) {
433-
e.preventDefault();
434-
handleCopyCode();
435-
}
436-
},
437-
[handleCopyCode, isMac]
438-
);
427+
const hotKeysRef = useHotkeys(keyboardShortcuts.copyCode, handleCopyCode, {
428+
preventDefault: true,
429+
});
439430
const intl = useIntl();
440431
const [{ dragDropSuccess }] = useSessionSettings();
441432
return (
@@ -448,6 +439,7 @@ const DraggableSignature = ({
448439
isDisabled={dragDropSuccess}
449440
>
450441
<HStack
442+
ref={hotKeysRef}
451443
draggable
452444
spacing={0}
453445
onClick={copyCodeButton.onToggle}
@@ -467,7 +459,6 @@ const DraggableSignature = ({
467459
boxShadow: "var(--chakra-shadows-outline);",
468460
outline: "none",
469461
}}
470-
onKeyDown={handleKeyDown}
471462
{...props}
472463
cursor="grab"
473464
>

src/documentation/common/CodeEmbed.tsx

Lines changed: 7 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
} from "@chakra-ui/react";
1414
import { forwardRef } from "@chakra-ui/system";
1515
import React, {
16+
LegacyRef,
1617
Ref,
1718
useCallback,
1819
useEffect,
@@ -33,6 +34,8 @@ import { useSessionSettings } from "../../settings/session-settings";
3334
import DragHandle from "../common/DragHandle";
3435
import { useCodeDragImage } from "../documentation-hooks";
3536
import CodeActionButton from "./CodeActionButton";
37+
import { useHotkeys } from "react-hotkeys-hook";
38+
import { keyboardShortcuts } from "../../common/keyboard-shortcuts";
3639

3740
interface CodeEmbedProps {
3841
code: string;
@@ -132,20 +135,9 @@ const CodeEmbed = ({
132135
const textHeight = lineCount * 1.375 + "em";
133136
const codeHeight = `calc(${textHeight} + var(--chakra-space-2) + var(--chakra-space-2))`;
134137
const codePopUpHeight = `calc(${codeHeight} + 2px)`; // Account for border.
135-
const isMac = /Mac/.test(navigator.platform);
136-
const handleKeyDown = useCallback(
137-
async (e: React.KeyboardEvent<HTMLDivElement>) => {
138-
if (e.key === "Enter") {
139-
e.preventDefault();
140-
handleCopyCode();
141-
}
142-
if ((e.key === "c" || e.key === "C") && (isMac ? e.metaKey : e.ctrlKey)) {
143-
e.preventDefault();
144-
handleCopyCode();
145-
}
146-
},
147-
[handleCopyCode, isMac]
148-
);
138+
const hotKeysRef = useHotkeys(keyboardShortcuts.copyCode, handleCopyCode, {
139+
preventDefault: true,
140+
}) as LegacyRef<HTMLDivElement>;
149141
const determineBackground = () => {
150142
if (
151143
(toolkitType === "ideas" && state === "highlighted") ||
@@ -157,7 +149,7 @@ const CodeEmbed = ({
157149
};
158150
return (
159151
<Box position="relative">
160-
<Box height={codeHeight} fontSize="md">
152+
<Box height={codeHeight} fontSize="md" ref={hotKeysRef} tabIndex={-1}>
161153
<Code
162154
onMouseEnter={toRaised}
163155
onMouseLeave={handleMouseLeave}
@@ -180,7 +172,6 @@ const CodeEmbed = ({
180172
_focusVisible={{
181173
outline: "none",
182174
}}
183-
onKeyDown={handleKeyDown}
184175
zIndex={zIndexCode}
185176
/>
186177
{state === "raised" && (

src/project/SaveButton.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,18 @@
44
* SPDX-License-Identifier: MIT
55
*/
66
import { Tooltip } from "@chakra-ui/react";
7-
import { useRef } from "react";
7+
import { useCallback, useRef } from "react";
88
import { RiDownload2Line } from "react-icons/ri";
99
import { useIntl } from "react-intl";
1010
import CollapsibleButton, {
1111
CollapsibleButtonProps,
1212
} from "../common/CollapsibleButton";
1313
import { useProjectActions } from "./project-hooks";
14+
import { useHotkeys } from "react-hotkeys-hook";
15+
import {
16+
globalShortcutConfig,
17+
keyboardShortcuts,
18+
} from "../common/keyboard-shortcuts";
1419

1520
interface SaveButtonProps
1621
extends Omit<CollapsibleButtonProps, "onClick" | "text" | "icon"> {}
@@ -27,6 +32,12 @@ const SaveButton = (props: SaveButtonProps) => {
2732
const actions = useProjectActions();
2833
const intl = useIntl();
2934
const menuButtonRef = useRef<HTMLButtonElement>(null);
35+
const activeElementRef = useRef<HTMLElement | null>(null);
36+
const handleSave = useCallback(() => {
37+
activeElementRef.current = document.activeElement as HTMLElement;
38+
actions.save(activeElementRef);
39+
}, [actions]);
40+
useHotkeys(keyboardShortcuts.saveProject, handleSave, globalShortcutConfig);
3041
return (
3142
<Tooltip
3243
hasArrow

src/project/SendButton.tsx

Lines changed: 36 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ import { ConnectionAction, ConnectionStatus } from "../device/device";
2222
import { useConnectionStatus } from "../device/device-hooks";
2323
import MoreMenuButton from "./MoreMenuButton";
2424
import { useProjectActions } from "./project-hooks";
25+
import { useHotkeys } from "react-hotkeys-hook";
26+
import {
27+
globalShortcutConfig,
28+
keyboardShortcuts,
29+
} from "../common/keyboard-shortcuts";
30+
import { FinalFocusRef } from "./project-actions";
2531

2632
interface SendButtonProps {
2733
size?: ThemeTypings["components"]["Button"]["sizes"];
@@ -48,24 +54,27 @@ const SendButton = React.forwardRef(
4854
flashing: false,
4955
lastCompleteFlash: 0,
5056
});
51-
const handleSendToMicrobit = useCallback(async () => {
52-
if (flashing.current.flashing) {
53-
// Ignore repeated clicks.
54-
return;
55-
}
56-
flashing.current = {
57-
flashing: true,
58-
lastCompleteFlash: flashing.current.lastCompleteFlash,
59-
};
60-
try {
61-
await actions.flash(sendButtonRef);
62-
} finally {
57+
const handleSendToMicrobit = useCallback(
58+
async (finalFocusRef: FinalFocusRef) => {
59+
if (flashing.current.flashing) {
60+
// Ignore repeated clicks.
61+
return;
62+
}
6363
flashing.current = {
64-
flashing: false,
65-
lastCompleteFlash: new Date().getTime(),
64+
flashing: true,
65+
lastCompleteFlash: flashing.current.lastCompleteFlash,
6666
};
67-
}
68-
}, [flashing, actions, sendButtonRef]);
67+
try {
68+
await actions.flash(finalFocusRef);
69+
} finally {
70+
flashing.current = {
71+
flashing: false,
72+
lastCompleteFlash: new Date().getTime(),
73+
};
74+
}
75+
},
76+
[flashing, actions]
77+
);
6978
const handleFocus = useCallback(
7079
(e: FocusEvent<unknown>) => {
7180
const inProgress = flashing.current.flashing;
@@ -79,6 +88,16 @@ const SendButton = React.forwardRef(
7988
[flashing]
8089
);
8190
const menuButtonRef = useRef<HTMLButtonElement>(null);
91+
const activeElementRef = useRef<HTMLElement | null>(null);
92+
const handleSendToMicrobitShortcut = useCallback(() => {
93+
activeElementRef.current = document.activeElement as HTMLElement;
94+
handleSendToMicrobit(activeElementRef);
95+
}, [handleSendToMicrobit]);
96+
useHotkeys(
97+
keyboardShortcuts.sendToMicrobit,
98+
handleSendToMicrobitShortcut,
99+
globalShortcutConfig
100+
);
82101
return (
83102
<HStack>
84103
<Menu>
@@ -96,7 +115,7 @@ const SendButton = React.forwardRef(
96115
size={size}
97116
variant="solid"
98117
leftIcon={<RiUsbLine />}
99-
onClick={handleSendToMicrobit}
118+
onClick={() => handleSendToMicrobit(sendButtonRef)}
100119
>
101120
<FormattedMessage id="send-action" />
102121
</Button>

0 commit comments

Comments
 (0)