From d1fb8bed4ae14dbf8d3764353005e41815271710 Mon Sep 17 00:00:00 2001 From: Lucas Bento Date: Tue, 3 Mar 2020 09:19:05 -0800 Subject: [PATCH] Add React Native/Metro hotkeys (#822) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: This PR fixes https://github.com/facebook/flipper/issues/798 by adding customizable hotkeys to reload and/or open developer menu in React Native apps. ![Screenshot of the Preferences window with hotkeys](https://user-images.githubusercontent.com/6207220/75113976-b27c0280-5652-11ea-8d5d-020d2650425b.png) #### TODO: - [x] Add correct icon for removing content of the hotkey input (currently using `undo`) - cc passy 😄 ## Changelog Add customizable hotkeys to reload and/or open developer menu in React Native apps. Pull Request resolved: https://github.com/facebook/flipper/pull/822 Test Plan: - Run React Native on version `0.62.0-rc.2` (you can use this app: https://github.com/lucasbento/RNWithFlipper); - Open the Preferences window (`⌘,`); - Customise the React Native hotkeys to whatever you want; - Test them out with Flipper's window active and inactive. > **Note**: this has been tested only in macOS. Reviewed By: jknoxville Differential Revision: D20061833 Pulled By: passy fbshipit-source-id: 601d29e07d7de2683d2c70c7c87f0d841aa3559e --- src/chrome/SettingsSheet.tsx | 75 +++++- src/chrome/settings/KeyboardShortcutInput.tsx | 251 ++++++++++++++++++ src/dispatcher/index.tsx | 9 + src/dispatcher/reactNative.tsx | 53 ++++ src/reducers/settings.tsx | 14 + static/main.ts | 12 +- 6 files changed, 405 insertions(+), 9 deletions(-) create mode 100644 src/chrome/settings/KeyboardShortcutInput.tsx create mode 100644 src/dispatcher/reactNative.tsx diff --git a/src/chrome/SettingsSheet.tsx b/src/chrome/SettingsSheet.tsx index d186a5922..c0fa8cad9 100644 --- a/src/chrome/SettingsSheet.tsx +++ b/src/chrome/SettingsSheet.tsx @@ -21,11 +21,11 @@ import {Settings, DEFAULT_ANDROID_SDK_PATH} from '../reducers/settings'; import {flush} from '../utils/persistor'; import ToggledSection from './settings/ToggledSection'; import {FilePathConfigField, ConfigText} from './settings/configFields'; +import KeyboardShortcutInput from './settings/KeyboardShortcutInput'; import isEqual from 'lodash.isequal'; import restartFlipper from '../utils/restartFlipper'; import LauncherSettingsPanel from '../fb-stubs/LauncherSettingsPanel'; import {reportUsage} from '../utils/metrics'; -import os from 'os'; const Container = styled(FlexColumn)({ padding: 20, @@ -81,12 +81,20 @@ class SettingsSheet extends Component { }; render() { + const { + enableAndroid, + androidHome, + enableIOS, + enablePrefetching, + reactNative, + } = this.state.updatedSettings; + return ( Settings { this.setState({ updatedSettings: { @@ -98,7 +106,7 @@ class SettingsSheet extends Component { { this.setState({ updatedSettings: { @@ -111,10 +119,7 @@ class SettingsSheet extends Component { { this.setState({ @@ -134,7 +139,7 @@ class SettingsSheet extends Component { )} { this.setState({ updatedSettings: { @@ -153,6 +158,60 @@ class SettingsSheet extends Component { }); }} /> + { + this.setState(prevState => ({ + updatedSettings: { + ...prevState.updatedSettings, + reactNative: { + ...prevState.updatedSettings.reactNative, + shortcuts: { + ...prevState.updatedSettings.reactNative.shortcuts, + enabled, + }, + }, + }, + })); + }}> + { + this.setState(prevState => ({ + updatedSettings: { + ...prevState.updatedSettings, + reactNative: { + ...prevState.updatedSettings.reactNative, + shortcuts: { + ...prevState.updatedSettings.reactNative.shortcuts, + reload, + }, + }, + }, + })); + }} + /> + { + this.setState(prevState => ({ + updatedSettings: { + ...prevState.updatedSettings, + reactNative: { + ...prevState.updatedSettings.reactNative, + shortcuts: { + ...prevState.updatedSettings.reactNative.shortcuts, + openDevMenu, + }, + }, + }, + })); + }} + /> +
diff --git a/src/chrome/settings/KeyboardShortcutInput.tsx b/src/chrome/settings/KeyboardShortcutInput.tsx new file mode 100644 index 000000000..5a78cd91b --- /dev/null +++ b/src/chrome/settings/KeyboardShortcutInput.tsx @@ -0,0 +1,251 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + */ + +import {FlexColumn, styled, FlexRow, Text, Glyph, colors} from 'flipper'; +import React, {useRef, useState, useEffect} from 'react'; + +type PressedKeys = { + metaKey: boolean; + altKey: boolean; + ctrlKey: boolean; + shiftKey: boolean; + character: string; +}; + +const KEYCODES = { + DELETE: 8, + ALT: 18, + SHIFT: 16, + CTRL: 17, + LEFT_COMMAND: 91, // Left ⌘ / Windows Key / Chromebook Search key + RIGHT_COMMAND: 93, // Right ⌘ / Windows Menu +}; + +const ACCELERATORS = { + COMMAND: 'Command', + ALT: 'Alt', + CONTROL: 'Control', + SHIFT: 'Shift', +}; + +const Container = styled(FlexRow)({ + paddingTop: 5, + paddingLeft: 10, + paddingRight: 10, + width: 343, +}); + +const Label = styled(Text)({ + alignSelf: 'center', + width: 140, +}); + +const ShortcutKeysContainer = styled(FlexRow)<{invalid: boolean}>( + { + backgroundColor: colors.white, + borderWidth: 1, + borderStyle: 'solid', + borderRadius: 4, + display: 'flex', + height: 28, + flexGrow: 1, + padding: 2, + }, + props => ({borderColor: props.invalid ? colors.red : colors.light15}), +); + +const ShortcutKeyContainer = styled.div({ + border: `1px solid ${colors.light20}`, + backgroundColor: colors.light05, + padding: 3, + margin: '0 1px', + borderRadius: 3, + width: 23, + textAlign: 'center', + boxShadow: `inset 0 -1px 0 ${colors.light20}`, +}); + +const ShortcutKey = styled.span({ + color: colors.dark70, + verticalAlign: 'middle', +}); + +const HiddenInput = styled.input({ + opacity: 0, + width: 0, + height: 0, + position: 'absolute', +}); + +const CenteredGlyph = styled(Glyph)({ + margin: 'auto', + marginLeft: 10, +}); + +const KeyboardShortcutInput = (props: { + label: string; + value: string; + onChange?: (value: string) => void; +}) => { + const getInitialStateFromProps = (): PressedKeys => ({ + metaKey: Boolean(props.value && props.value.includes(ACCELERATORS.COMMAND)), + altKey: Boolean(props.value && props.value.includes(ACCELERATORS.ALT)), + ctrlKey: Boolean(props.value && props.value.includes(ACCELERATORS.CONTROL)), + shiftKey: Boolean(props.value && props.value.includes(ACCELERATORS.SHIFT)), + character: + props.value && + props.value.replace( + new RegExp( + `${ACCELERATORS.COMMAND}|${ACCELERATORS.ALT}|Or|${ACCELERATORS.CONTROL}|${ACCELERATORS.SHIFT}|\\+`, + 'g', + ), + '', + ), + }); + + const [initialPressedKeys] = useState( + getInitialStateFromProps(), + ); + const [pressedKeys, setPressedKeys] = useState( + initialPressedKeys, + ); + const [isShortcutValid, setIsShortcutValid] = useState( + undefined, + ); + + useEffect(() => { + if (!isShortcutValid) { + return; + } + + const {metaKey, altKey, ctrlKey, shiftKey, character} = pressedKeys; + + const accelerator = [ + metaKey && ACCELERATORS.COMMAND, + altKey && ACCELERATORS.ALT, + ctrlKey && ACCELERATORS.CONTROL, + shiftKey && ACCELERATORS.SHIFT, + character, + ].filter(Boolean); + + if (typeof props.onChange === 'function') { + props.onChange(accelerator.join('+')); + } + }, [isShortcutValid]); + + const inputRef = useRef(null); + let typingTimeout: NodeJS.Timeout; + + const handleFocusInput = () => { + if (inputRef.current !== null) { + inputRef.current.focus(); + } + }; + + const isCharacterSpecial = (keycode: number) => + Object.values(KEYCODES).includes(keycode); + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.which === 9) { + return; + } + + event.preventDefault(); + + const {metaKey, altKey, ctrlKey, shiftKey} = event; + const character = isCharacterSpecial(event.which) + ? '' + : String.fromCharCode(event.which); + + setPressedKeys({ + metaKey, + altKey, + ctrlKey, + shiftKey, + character, + }); + setIsShortcutValid(undefined); + }; + + const handleKeyUp = () => { + const {metaKey, altKey, ctrlKey, shiftKey, character} = pressedKeys; + + clearTimeout(typingTimeout); + typingTimeout = setTimeout( + () => + setIsShortcutValid( + ([metaKey, altKey, ctrlKey, shiftKey].includes(true) && + character !== '') || + [metaKey, altKey, ctrlKey, shiftKey, character].every( + value => !value, + ), + ), + 500, + ); + }; + + const handleUpdatePressedKeys = (keys: PressedKeys) => { + setPressedKeys(keys); + handleKeyUp(); + handleFocusInput(); + setIsShortcutValid(undefined); + }; + + const renderKeys = () => { + const keys = [ + pressedKeys.metaKey && '⌘', + pressedKeys.altKey && '⌥', + pressedKeys.ctrlKey && '⌃', + pressedKeys.shiftKey && '⇧', + pressedKeys.character, + ].filter(Boolean); + + return keys.map((key, index) => ( + + {key} + + )); + }; + + return ( + + + + {renderKeys()} + + + + + handleUpdatePressedKeys(initialPressedKeys)}> + + + + + handleUpdatePressedKeys({ + metaKey: false, + altKey: false, + ctrlKey: false, + shiftKey: false, + character: '', + }) + }> + + + + ); +}; + +export default KeyboardShortcutInput; diff --git a/src/dispatcher/index.tsx b/src/dispatcher/index.tsx index 0efade1df..d757911a8 100644 --- a/src/dispatcher/index.tsx +++ b/src/dispatcher/index.tsx @@ -7,6 +7,7 @@ * @format */ +import {remote} from 'electron'; import androidDevice from './androidDevice'; import metroDevice from './metroDevice'; import iOSDevice from './iOSDevice'; @@ -18,6 +19,7 @@ import notifications from './notifications'; import plugins from './plugins'; import user from './user'; import pluginManager from './pluginManager'; +import reactNative from './reactNative'; import {Logger} from '../fb-interfaces/Logger'; import {Store} from '../reducers/index'; @@ -25,6 +27,12 @@ import {Dispatcher} from './types'; import {notNull} from '../utils/typeUtils'; export default function(store: Store, logger: Logger): () => Promise { + // This only runs in development as when the reload + // kicks in it doesn't unregister the shortcuts + if (process.env.NODE_ENV === 'development') { + remote.globalShortcut.unregisterAll(); + } + const dispatchers: Array = [ application, store.getState().settingsState.enableAndroid ? androidDevice : null, @@ -37,6 +45,7 @@ export default function(store: Store, logger: Logger): () => Promise { plugins, user, pluginManager, + reactNative, ].filter(notNull); const globalCleanup = dispatchers .map(dispatcher => dispatcher(store, logger)) diff --git a/src/dispatcher/reactNative.tsx b/src/dispatcher/reactNative.tsx new file mode 100644 index 000000000..7fb881b03 --- /dev/null +++ b/src/dispatcher/reactNative.tsx @@ -0,0 +1,53 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + */ + +import {remote} from 'electron'; +import {MetroDevice} from 'flipper'; +import {Store} from 'src/reducers'; + +type ShortcutEventCommand = + | { + shortcut: string; + command: string; + } + | ''; + +export default (store: Store) => { + const settings = store.getState().settingsState.reactNative; + + if (!settings.shortcuts.enabled) { + return; + } + + const shortcuts: ShortcutEventCommand[] = [ + settings.shortcuts.reload && { + shortcut: settings.shortcuts.reload, + command: 'reload', + }, + settings.shortcuts.openDevMenu && { + shortcut: settings.shortcuts.openDevMenu, + command: 'devMenu', + }, + ]; + + shortcuts.forEach( + (shortcut: ShortcutEventCommand) => + shortcut && + shortcut.shortcut && + remote.globalShortcut.register(shortcut.shortcut, () => { + const devices = store + .getState() + .connections.devices.filter( + device => device.os === 'Metro' && !device.isArchived, + ) as MetroDevice[]; + + devices.forEach(device => device.sendCommand(shortcut.command)); + }), + ); +}; diff --git a/src/reducers/settings.tsx b/src/reducers/settings.tsx index c56acc25b..88e4906b2 100644 --- a/src/reducers/settings.tsx +++ b/src/reducers/settings.tsx @@ -34,6 +34,13 @@ export type Settings = { width: number; }; }; + reactNative: { + shortcuts: { + enabled: boolean; + reload: string; + openDevMenu: string; + }; + }; }; export type Action = @@ -57,6 +64,13 @@ const initialState: Settings = { width: 800, }, }, + reactNative: { + shortcuts: { + enabled: false, + reload: 'Alt+Shift+R', + openDevMenu: 'Alt+Shift+D', + }, + }, }; export default function reducer( diff --git a/static/main.ts b/static/main.ts index 02e78aa6d..a32d9ee35 100644 --- a/static/main.ts +++ b/static/main.ts @@ -10,7 +10,13 @@ const [s, ns] = process.hrtime(); let launchStartTime: number | undefined = s * 1e3 + ns / 1e6; -import {app, BrowserWindow, ipcMain, Notification} from 'electron'; +import { + app, + BrowserWindow, + ipcMain, + Notification, + globalShortcut, +} from 'electron'; import path from 'path'; import url from 'url'; import fs from 'fs'; @@ -199,6 +205,10 @@ app.on('ready', () => { }); }); +app.on('will-quit', () => { + globalShortcut.unregisterAll(); +}); + ipcMain.on('componentDidMount', _event => { if (deeplinkURL) { win.webContents.send('flipper-protocol-handler', deeplinkURL);