diff --git a/package-lock.json b/package-lock.json index 862699247..093933d50 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,6 +50,7 @@ "mobile-drag-drop": "^2.3.0-rc.2", "react": "^18.0.0", "react-dom": "^18.0.0", + "react-hotkeys-hook": "^4.5.0", "react-icons": "^4.8.0", "react-intl": "^6.2.10", "vite": "^5.1.5", @@ -8808,6 +8809,15 @@ } } }, + "node_modules/react-hotkeys-hook": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/react-hotkeys-hook/-/react-hotkeys-hook-4.5.0.tgz", + "integrity": "sha512-Samb85GSgAWFQNvVt3PS90LPPGSf9mkH/r4au81ZP1yOIFayLC3QAvqTgGtJ8YEDMXtPmaVBs6NgipHO6h4Mug==", + "peerDependencies": { + "react": ">=16.8.1", + "react-dom": ">=16.8.1" + } + }, "node_modules/react-icons": { "version": "4.12.0", "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-4.12.0.tgz", diff --git a/package.json b/package.json index c4df05d20..6e0eff4f0 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "mobile-drag-drop": "^2.3.0-rc.2", "react": "^18.0.0", "react-dom": "^18.0.0", + "react-hotkeys-hook": "^4.5.0", "react-icons": "^4.8.0", "react-intl": "^6.2.10", "vite": "^5.1.5", diff --git a/src/common/GenericDialog.tsx b/src/common/GenericDialog.tsx index c1e92a1d5..ba7e54790 100644 --- a/src/common/GenericDialog.tsx +++ b/src/common/GenericDialog.tsx @@ -16,6 +16,7 @@ import { ThemingProps } from "@chakra-ui/styled-system"; import { ReactNode } from "react"; import { FormattedMessage } from "react-intl"; import ModalCloseButton from "./ModalCloseButton"; +import { FinalFocusRef } from "../project/project-actions"; export interface GenericDialogProps { header?: ReactNode; @@ -24,7 +25,7 @@ export interface GenericDialogProps { size?: ThemingProps<"Button">["size"]; onClose: () => void; returnFocusOnClose?: boolean; - finalFocusRef?: React.RefObject; + finalFocusRef?: FinalFocusRef; } export const GenericDialog = ({ diff --git a/src/common/InputDialog.tsx b/src/common/InputDialog.tsx index ca258d13b..8d13f8e45 100644 --- a/src/common/InputDialog.tsx +++ b/src/common/InputDialog.tsx @@ -16,6 +16,7 @@ import { import { ThemeTypings } from "@chakra-ui/styled-system"; import { ReactNode, useCallback, useState } from "react"; import { FormattedMessage } from "react-intl"; +import { FinalFocusRef } from "../project/project-actions"; export interface InputValidationResult { ok: boolean; @@ -39,7 +40,7 @@ export interface InputDialogProps { actionLabel: string; size?: ThemeTypings["components"]["Modal"]["sizes"]; validate?: (input: T) => InputValidationResult; - finalFocusRef?: React.RefObject; + finalFocusRef?: FinalFocusRef; callback: (value: ValueOrCancelled) => void; } diff --git a/src/common/PostSaveDialog.tsx b/src/common/PostSaveDialog.tsx index 7a6d76a0f..5fdbcc14e 100644 --- a/src/common/PostSaveDialog.tsx +++ b/src/common/PostSaveDialog.tsx @@ -8,6 +8,7 @@ import { ReactNode, useCallback } from "react"; import { FormattedMessage } from "react-intl"; import { GenericDialog, GenericDialogFooter } from "../common/GenericDialog"; import { useProject } from "../project/project-hooks"; +import { FinalFocusRef } from "../project/project-actions"; export const enum PostSaveChoice { ShowTransferHexHelp, @@ -18,7 +19,7 @@ export const enum PostSaveChoice { interface PostSaveDialogProps { callback: (value: PostSaveChoice) => void; dialogNormallyHidden: boolean; - finalFocusRef: React.RefObject; + finalFocusRef: FinalFocusRef; } export const PostSaveDialog = ({ diff --git a/src/common/keyboard-shortcuts.ts b/src/common/keyboard-shortcuts.ts new file mode 100644 index 000000000..dbc213970 --- /dev/null +++ b/src/common/keyboard-shortcuts.ts @@ -0,0 +1,14 @@ +// Shortcuts are global unless noted otherwise. +export const keyboardShortcuts = { + // This is scoped by keyboard focus. + copyCode: ["ctrl+c", "meta+c", "enter"], + search: ["ctrl+shift+f", "meta+shift+f"], + sendToMicrobit: ["ctrl+shift+e", "meta+shift+e"], + saveProject: ["ctrl+shift+s", "meta+shift+s"], +}; + +export const globalShortcutConfig = { + preventDefault: true, + enableOnContentEditable: true, + enableOnFormTags: true, +}; diff --git a/src/documentation/api/ApiNode.tsx b/src/documentation/api/ApiNode.tsx index d245123d2..9c1c6eae2 100644 --- a/src/documentation/api/ApiNode.tsx +++ b/src/documentation/api/ApiNode.tsx @@ -41,6 +41,8 @@ import ShowMoreButton from "../common/ShowMoreButton"; import { allowWrapAtPeriods } from "../common/wrap"; import { useCodeDragImage } from "../documentation-hooks"; import Highlight from "../reference/Highlight"; +import { useHotkeys } from "react-hotkeys-hook"; +import { keyboardShortcuts } from "../../common/keyboard-shortcuts"; const kindToFontSize: Record = { module: "2xl", @@ -422,20 +424,9 @@ const DraggableSignature = ({ onCopy(); await actions?.copyCode(code, codeWithImports, type, id); }, [actions, code, codeWithImports, onCopy, type, id]); - const isMac = /Mac/.test(navigator.platform); - const handleKeyDown = useCallback( - async (e: React.KeyboardEvent) => { - if (e.key === "Enter") { - e.preventDefault(); - handleCopyCode(); - } - if ((e.key === "c" || e.key === "C") && (isMac ? e.metaKey : e.ctrlKey)) { - e.preventDefault(); - handleCopyCode(); - } - }, - [handleCopyCode, isMac] - ); + const hotKeysRef = useHotkeys(keyboardShortcuts.copyCode, handleCopyCode, { + preventDefault: true, + }); const intl = useIntl(); const [{ dragDropSuccess }] = useSessionSettings(); return ( @@ -448,6 +439,7 @@ const DraggableSignature = ({ isDisabled={dragDropSuccess} > diff --git a/src/documentation/common/CodeEmbed.tsx b/src/documentation/common/CodeEmbed.tsx index 531cbe042..b2e34d463 100644 --- a/src/documentation/common/CodeEmbed.tsx +++ b/src/documentation/common/CodeEmbed.tsx @@ -13,6 +13,7 @@ import { } from "@chakra-ui/react"; import { forwardRef } from "@chakra-ui/system"; import React, { + LegacyRef, Ref, useCallback, useEffect, @@ -33,6 +34,8 @@ import { useSessionSettings } from "../../settings/session-settings"; import DragHandle from "../common/DragHandle"; import { useCodeDragImage } from "../documentation-hooks"; import CodeActionButton from "./CodeActionButton"; +import { useHotkeys } from "react-hotkeys-hook"; +import { keyboardShortcuts } from "../../common/keyboard-shortcuts"; interface CodeEmbedProps { code: string; @@ -132,20 +135,9 @@ const CodeEmbed = ({ const textHeight = lineCount * 1.375 + "em"; const codeHeight = `calc(${textHeight} + var(--chakra-space-2) + var(--chakra-space-2))`; const codePopUpHeight = `calc(${codeHeight} + 2px)`; // Account for border. - const isMac = /Mac/.test(navigator.platform); - const handleKeyDown = useCallback( - async (e: React.KeyboardEvent) => { - if (e.key === "Enter") { - e.preventDefault(); - handleCopyCode(); - } - if ((e.key === "c" || e.key === "C") && (isMac ? e.metaKey : e.ctrlKey)) { - e.preventDefault(); - handleCopyCode(); - } - }, - [handleCopyCode, isMac] - ); + const hotKeysRef = useHotkeys(keyboardShortcuts.copyCode, handleCopyCode, { + preventDefault: true, + }) as LegacyRef; const determineBackground = () => { if ( (toolkitType === "ideas" && state === "highlighted") || @@ -157,7 +149,7 @@ const CodeEmbed = ({ }; return ( - + {state === "raised" && ( diff --git a/src/project/SaveButton.tsx b/src/project/SaveButton.tsx index 4d6528ee5..eece5280e 100644 --- a/src/project/SaveButton.tsx +++ b/src/project/SaveButton.tsx @@ -4,13 +4,18 @@ * SPDX-License-Identifier: MIT */ import { Tooltip } from "@chakra-ui/react"; -import { useRef } from "react"; +import { useCallback, useRef } from "react"; import { RiDownload2Line } from "react-icons/ri"; import { useIntl } from "react-intl"; import CollapsibleButton, { CollapsibleButtonProps, } from "../common/CollapsibleButton"; import { useProjectActions } from "./project-hooks"; +import { useHotkeys } from "react-hotkeys-hook"; +import { + globalShortcutConfig, + keyboardShortcuts, +} from "../common/keyboard-shortcuts"; interface SaveButtonProps extends Omit {} @@ -27,6 +32,12 @@ const SaveButton = (props: SaveButtonProps) => { const actions = useProjectActions(); const intl = useIntl(); const menuButtonRef = useRef(null); + const activeElementRef = useRef(null); + const handleSave = useCallback(() => { + activeElementRef.current = document.activeElement as HTMLElement; + actions.save(activeElementRef); + }, [actions]); + useHotkeys(keyboardShortcuts.saveProject, handleSave, globalShortcutConfig); return ( { - if (flashing.current.flashing) { - // Ignore repeated clicks. - return; - } - flashing.current = { - flashing: true, - lastCompleteFlash: flashing.current.lastCompleteFlash, - }; - try { - await actions.flash(sendButtonRef); - } finally { + const handleSendToMicrobit = useCallback( + async (finalFocusRef: FinalFocusRef) => { + if (flashing.current.flashing) { + // Ignore repeated clicks. + return; + } flashing.current = { - flashing: false, - lastCompleteFlash: new Date().getTime(), + flashing: true, + lastCompleteFlash: flashing.current.lastCompleteFlash, }; - } - }, [flashing, actions, sendButtonRef]); + try { + await actions.flash(finalFocusRef); + } finally { + flashing.current = { + flashing: false, + lastCompleteFlash: new Date().getTime(), + }; + } + }, + [flashing, actions] + ); const handleFocus = useCallback( (e: FocusEvent) => { const inProgress = flashing.current.flashing; @@ -79,6 +88,16 @@ const SendButton = React.forwardRef( [flashing] ); const menuButtonRef = useRef(null); + const activeElementRef = useRef(null); + const handleSendToMicrobitShortcut = useCallback(() => { + activeElementRef.current = document.activeElement as HTMLElement; + handleSendToMicrobit(activeElementRef); + }, [handleSendToMicrobit]); + useHotkeys( + keyboardShortcuts.sendToMicrobit, + handleSendToMicrobitShortcut, + globalShortcutConfig + ); return ( @@ -96,7 +115,7 @@ const SendButton = React.forwardRef( size={size} variant="solid" leftIcon={} - onClick={handleSendToMicrobit} + onClick={() => handleSendToMicrobit(sendButtonRef)} > diff --git a/src/project/project-actions.tsx b/src/project/project-actions.tsx index 31c8346a4..c794e27bd 100644 --- a/src/project/project-actions.tsx +++ b/src/project/project-actions.tsx @@ -76,6 +76,8 @@ import ProjectNameQuestion from "./ProjectNameQuestion"; */ export type LoadType = "drop-load" | "file-upload"; +export type FinalFocusRef = React.RefObject | undefined; + export interface MainScriptChoice { main: string | undefined; } @@ -120,7 +122,7 @@ export class ProjectActions { connect = async ( forceConnectHelp: boolean, userAction: ConnectionAction, - finalFocusRef: React.RefObject + finalFocusRef: FinalFocusRef ): Promise => { this.logging.event({ type: "connect", @@ -147,7 +149,7 @@ export class ProjectActions { */ private async showConnectHelp( force: boolean, - finalFocusRef: React.RefObject + finalFocusRef: FinalFocusRef ): Promise { const showConnectHelpSetting = this.settings.values.showConnectHelp; if ( @@ -186,11 +188,11 @@ export class ProjectActions { private async connectInternal( options: ConnectOptions, userAction: ConnectionAction, - finalFocusRef: React.RefObject + finalFocusRef: FinalFocusRef ) { try { await this.device.connect(options); - finalFocusRef.current?.focus(); + finalFocusRef?.current?.focus(); return true; } catch (e) { this.handleWebUSBError(e, userAction, finalFocusRef); @@ -201,7 +203,7 @@ export class ProjectActions { /** * Disconnect from the device. */ - disconnect = async (finalFocusRef: React.RefObject) => { + disconnect = async (finalFocusRef: FinalFocusRef) => { this.logging.event({ type: "disconnect", }); @@ -488,7 +490,7 @@ export class ProjectActions { * Flash the device, reporting progress via a dialog. */ flash = async ( - finalFocusRef: React.RefObject, + finalFocusRef: FinalFocusRef, tryAgain?: boolean ): Promise => { this.logging.event({ @@ -547,7 +549,7 @@ export class ProjectActions { * Trigger a browser download with a universal hex file. */ save = async ( - finalFocusRef: React.RefObject, + finalFocusRef: FinalFocusRef, saveViaWebUsbNotSupported?: boolean ) => { this.logging.event({ @@ -728,7 +730,7 @@ export class ProjectActions { isDefaultProjectName = (): boolean => this.fs.project.name === undefined; ensureProjectName = async ( - finalFocusRef: React.RefObject + finalFocusRef: FinalFocusRef ): Promise => { if (this.isDefaultProjectName()) { return await this.editProjectName(true, finalFocusRef); @@ -738,7 +740,7 @@ export class ProjectActions { editProjectName = async ( isSave: boolean = false, - finalFocusRef?: React.RefObject + finalFocusRef?: FinalFocusRef ) => { const name = await this.dialogs.show((callback) => ( + finalFocusRef: FinalFocusRef ) => { if (choice !== ConnectErrorChoice.TRY_AGAIN) { return; @@ -799,7 +801,7 @@ export class ProjectActions { private async handleNotFound( userAction: ConnectionAction, - finalFocusRef: React.RefObject + finalFocusRef: FinalFocusRef ) { const choice = await this.dialogs.show((callback) => ( @@ -810,7 +812,7 @@ export class ProjectActions { private async handleFirmwareUpdate( _errorCode: WebUSBErrorCode, userAction: ConnectionAction, - finalFocusRef: React.RefObject + finalFocusRef: FinalFocusRef ) { this.device.clearDevice(); const choice = await this.dialogs.show((callback) => ( @@ -822,7 +824,7 @@ export class ProjectActions { private async handleWebUSBError( e: any, userAction: ConnectionAction, - finalFocusRef: React.RefObject + finalFocusRef: FinalFocusRef ) { if (e instanceof WebUSBError) { this.device.emit(EVENT_END_USB_SELECT); @@ -857,7 +859,7 @@ export class ProjectActions { } private async webusbNotSupportedError( - finalFocusRef: React.RefObject + finalFocusRef: FinalFocusRef ): Promise { if (this.sessionSettings.values.showWebUsbNotSupported) { await this.dialogs.show((callback) => ( @@ -939,9 +941,7 @@ export class ProjectActions { } } - private async handlePostSaveDialog( - finalFocusRef: React.RefObject - ) { + private async handlePostSaveDialog(finalFocusRef: FinalFocusRef) { const showPostSaveHelpSetting = this.settings.values.showPostSaveHelp; if (!showPostSaveHelpSetting) { return; @@ -966,7 +966,7 @@ export class ProjectActions { private async handleTransferHexDialog( forceTransferHexHelp: boolean, - finalFocusRef: React.RefObject + finalFocusRef: FinalFocusRef ) { const showTransferHexHelpSetting = this.settings.values.showTransferHexHelp; if (!forceTransferHexHelp && !showTransferHexHelpSetting) { diff --git a/src/workbench/SideBarHeader.tsx b/src/workbench/SideBarHeader.tsx index a3e812df2..511059111 100644 --- a/src/workbench/SideBarHeader.tsx +++ b/src/workbench/SideBarHeader.tsx @@ -33,6 +33,11 @@ import SearchDialog from "../documentation/search/SearchDialog"; import { useLogging } from "../logging/logging-hooks"; import { RouterState, useRouterState } from "../router-hooks"; import { useSettings } from "../settings/settings"; +import { useHotkeys } from "react-hotkeys-hook"; +import { + globalShortcutConfig, + keyboardShortcuts, +} from "../common/keyboard-shortcuts"; interface SideBarHeaderProps { sidebarShown: boolean; @@ -72,35 +77,21 @@ const SideBarHeader = ({ const [{ languageId }] = useSettings(); const searchAvailable = supportedSearchLanguages.includes(languageId); - // When we add more keyboard shortcuts, we should pull this up and have a CM-like model of the - // available actions and their shortcuts, with a hook used here to register a handler for the action. - useEffect(() => { - const isMac = /Mac/.test(navigator.platform); - const keydown = (e: KeyboardEvent) => { - if ( - (e.key === "F" || e.key === "f") && - (isMac ? e.metaKey : e.ctrlKey) && - e.shiftKey && - !e.repeat && - searchAvailable - ) { - handleModalOpened(); - if (!sidebarShown) { - onSidebarToggled(); - } + + const handleSearchShortcut = useCallback(() => { + if (searchAvailable) { + handleModalOpened(); + if (!sidebarShown) { + onSidebarToggled(); } - }; - document.addEventListener("keydown", keydown); - return () => { - document.removeEventListener("keydown", keydown); - }; - }, [ - onSidebarToggled, - searchModal, - sidebarShown, - handleModalOpened, - searchAvailable, - ]); + } + }, [handleModalOpened, onSidebarToggled, searchAvailable, sidebarShown]); + + useHotkeys( + keyboardShortcuts.search, + handleSearchShortcut, + globalShortcutConfig + ); const handleQueryChange: React.ChangeEventHandler = useCallback( diff --git a/src/workbench/connect-dialogs/ConnectDialog.tsx b/src/workbench/connect-dialogs/ConnectDialog.tsx index 612aabeea..2ae12ffe2 100644 --- a/src/workbench/connect-dialogs/ConnectDialog.tsx +++ b/src/workbench/connect-dialogs/ConnectDialog.tsx @@ -10,6 +10,7 @@ import { FormattedMessage } from "react-intl"; import { GenericDialog } from "../../common/GenericDialog"; import ConnectCableDialogBody from "./ConnectCableDialog"; import ConnectHelpDialogBody from "./ConnectHelpDialog"; +import { FinalFocusRef } from "../../project/project-actions"; export const enum ConnectHelpChoice { Next, @@ -21,7 +22,7 @@ interface ConnectHelpDialogProps { callback: (choice: ConnectHelpChoice) => void; dialogNormallyHidden: boolean; shownByRequest: boolean; - finalFocusRef: React.RefObject; + finalFocusRef: FinalFocusRef; } const enum Stage { diff --git a/src/workbench/connect-dialogs/FirmwareDialog.tsx b/src/workbench/connect-dialogs/FirmwareDialog.tsx index 21820abf5..a14194b51 100644 --- a/src/workbench/connect-dialogs/FirmwareDialog.tsx +++ b/src/workbench/connect-dialogs/FirmwareDialog.tsx @@ -11,6 +11,7 @@ import { RiExternalLinkLine } from "react-icons/ri"; import { FormattedMessage } from "react-intl"; import { GenericDialog } from "../../common/GenericDialog"; import firmwareUpgrade from "./firmware-upgrade.svg"; +import { FinalFocusRef } from "../../project/project-actions"; export const enum ConnectErrorChoice { TRY_AGAIN = "TRY_AGAIN", @@ -19,7 +20,7 @@ export const enum ConnectErrorChoice { interface FirmwareDialogProps { callback: (choice: ConnectErrorChoice) => void; - finalFocusRef: React.RefObject; + finalFocusRef: FinalFocusRef; } const FirmwareDialog = ({ callback, finalFocusRef }: FirmwareDialogProps) => { diff --git a/src/workbench/connect-dialogs/NotFoundDialog.tsx b/src/workbench/connect-dialogs/NotFoundDialog.tsx index ace1dd97e..0a25562df 100644 --- a/src/workbench/connect-dialogs/NotFoundDialog.tsx +++ b/src/workbench/connect-dialogs/NotFoundDialog.tsx @@ -13,10 +13,11 @@ import { GenericDialog } from "../../common/GenericDialog"; import SaveButton from "../../project/SaveButton"; import { ConnectErrorChoice } from "./FirmwareDialog"; import notFound from "./not-found.svg"; +import { FinalFocusRef } from "../../project/project-actions"; interface NotFoundDialogProps { callback: (value: ConnectErrorChoice) => void; - finalFocusRef: React.RefObject; + finalFocusRef: FinalFocusRef; } export const NotFoundDialog = ({ diff --git a/src/workbench/connect-dialogs/TransferHexDialog.tsx b/src/workbench/connect-dialogs/TransferHexDialog.tsx index 779bc97ed..6abd79107 100644 --- a/src/workbench/connect-dialogs/TransferHexDialog.tsx +++ b/src/workbench/connect-dialogs/TransferHexDialog.tsx @@ -10,6 +10,7 @@ import { FormattedMessage } from "react-intl"; import { GenericDialog, GenericDialogFooter } from "../../common/GenericDialog"; import transferHexMac from "./transfer-hex-mac.gif"; import transferHexWin from "./transfer-hex-win.gif"; +import { FinalFocusRef } from "../../project/project-actions"; export const enum TransferHexChoice { CloseDontShowAgain, @@ -20,7 +21,7 @@ interface TransferHexDialogProps { callback: (value: TransferHexChoice) => void; dialogNormallyHidden: boolean; shownByRequest: boolean; - finalFocusRef: React.RefObject; + finalFocusRef: FinalFocusRef; } export const TransferHexDialog = ({ diff --git a/src/workbench/connect-dialogs/WebUSBDialog.tsx b/src/workbench/connect-dialogs/WebUSBDialog.tsx index 0ab33ce7d..21b1544ec 100644 --- a/src/workbench/connect-dialogs/WebUSBDialog.tsx +++ b/src/workbench/connect-dialogs/WebUSBDialog.tsx @@ -10,10 +10,11 @@ import { FormattedMessage } from "react-intl"; import { GenericDialog } from "../../common/GenericDialog"; import { isChromeOS105 } from "../../device/webusb"; import chromeOSErrorImage from "./chrome-os-105-error.png"; +import { FinalFocusRef } from "../../project/project-actions"; interface WebUSBDialogProps { callback: () => void; - finalFocusRef: React.RefObject; + finalFocusRef: FinalFocusRef; } export const WebUSBDialog = ({