diff --git a/client/modules/IDE/components/Editor/contexts.jsx b/client/modules/IDE/components/Editor/contexts.jsx new file mode 100644 index 0000000000..44bcfa12fa --- /dev/null +++ b/client/modules/IDE/components/Editor/contexts.jsx @@ -0,0 +1,45 @@ +import React, { useContext } from 'react'; +import PropTypes from 'prop-types'; +import { createContext, useState } from 'react'; +import { metaKey } from '../../../../utils/metaKey'; + +export const EditorKeyMapsContext = createContext(); + +export function EditorKeyMapProvider({ children }) { + const [keyMaps, setKeyMaps] = useState({ + tidy: `Shift-${metaKey}-F`, + findPersistent: `${metaKey}-F`, + findPersistentNext: `${metaKey}-G`, + findPersistentPrev: `Shift-${metaKey}-G`, + colorPicker: `${metaKey}-K` + }); + + const updateKeyMap = (key, value) => { + if (key in keyMaps) { + setKeyMaps((prevKeyMaps) => ({ + ...prevKeyMaps, + [key]: value + })); + } + }; + + return ( + + {children} + + ); +} + +EditorKeyMapProvider.propTypes = { + children: PropTypes.node.isRequired +}; + +export const useEditorKeyMap = () => { + const context = useContext(EditorKeyMapsContext); + if (!context) { + throw new Error( + 'useEditorKeyMap must be used within a EditorKeyMapProvider' + ); + } + return context; +}; diff --git a/client/modules/IDE/components/Editor/index.jsx b/client/modules/IDE/components/Editor/index.jsx index 404741591a..3da7fcf69e 100644 --- a/client/modules/IDE/components/Editor/index.jsx +++ b/client/modules/IDE/components/Editor/index.jsx @@ -72,6 +72,7 @@ import UnsavedChangesIndicator from '../UnsavedChangesIndicator'; import { EditorContainer, EditorHolder } from './MobileEditor'; import { FolderIcon } from '../../../../common/icons'; import IconButton from '../../../../common/IconButton'; +import { EditorKeyMapsContext } from './contexts'; emmet(CodeMirror); @@ -104,6 +105,7 @@ class Editor extends React.Component { this.showFind = this.showFind.bind(this); this.showReplace = this.showReplace.bind(this); this.getContent = this.getContent.bind(this); + this.updateKeyMaps = this.updateKeyMaps.bind(this); } componentDidMount() { @@ -153,36 +155,9 @@ class Editor extends React.Component { delete this._cm.options.lint.options.errors; - const replaceCommand = - metaKey === 'Ctrl' ? `${metaKey}-H` : `${metaKey}-Option-F`; - this._cm.setOption('extraKeys', { - Tab: (cm) => { - if (!cm.execCommand('emmetExpandAbbreviation')) return; - // might need to specify and indent more? - const selection = cm.doc.getSelection(); - if (selection.length > 0) { - cm.execCommand('indentMore'); - } else { - cm.replaceSelection(' '.repeat(INDENTATION_AMOUNT)); - } - }, - Enter: 'emmetInsertLineBreak', - Esc: 'emmetResetAbbreviation', - [`Shift-Tab`]: false, - [`${metaKey}-Enter`]: () => null, - [`Shift-${metaKey}-Enter`]: () => null, - [`${metaKey}-F`]: 'findPersistent', - [`Shift-${metaKey}-F`]: this.tidyCode, - [`${metaKey}-G`]: 'findPersistentNext', - [`Shift-${metaKey}-G`]: 'findPersistentPrev', - [replaceCommand]: 'replace', - // Cassie Tarakajian: If you don't set a default color, then when you - // choose a color, it deletes characters inline. This is a - // hack to prevent that. - [`${metaKey}-K`]: (cm, event) => - cm.state.colorpicker.popup_color_picker({ length: 0 }), - [`${metaKey}-.`]: 'toggleComment' // Note: most adblockers use the shortcut ctrl+. - }); + const { keyMaps } = this.context; + + this.updateKeyMaps(keyMaps); this.initializeDocuments(this.props.files); this._cm.swapDoc(this._docs[this.props.file.id]); @@ -236,6 +211,16 @@ class Editor extends React.Component { } componentDidUpdate(prevProps) { + const currentKeyMaps = this.context?.keyMaps; + const prevKeyMaps = this.prevKeyMapsRef?.current; + + if ( + prevKeyMaps && + JSON.stringify(currentKeyMaps) !== JSON.stringify(prevKeyMaps) + ) { + this.updateKeyMaps(currentKeyMaps); + } + this.prevKeyMapsRef = { current: currentKeyMaps }; if (this.props.file.id !== prevProps.file.id) { const fileMode = this.getFileMode(this.props.file.name); if (fileMode === 'javascript') { @@ -515,6 +500,42 @@ class Editor extends React.Component { }); } + updateKeyMaps(keyMaps) { + const replaceCommand = + metaKey === 'Ctrl' ? `${metaKey}-H` : `${metaKey}-Option-F`; + + this._cm.setOption('extraKeys', { + Tab: (cm) => { + if (!cm.execCommand('emmetExpandAbbreviation')) return; + // might need to specify and indent more? + const selection = cm.doc.getSelection(); + if (selection.length > 0) { + cm.execCommand('indentMore'); + } else { + cm.replaceSelection(' '.repeat(INDENTATION_AMOUNT)); + } + }, + Enter: 'emmetInsertLineBreak', + Esc: 'emmetResetAbbreviation', + [`Shift-Tab`]: false, + [`${metaKey}-Enter`]: () => null, + [`Shift-${metaKey}-Enter`]: () => null, + [`${keyMaps.findPersistent}`]: 'findPersistent', + [`${keyMaps.tidy}`]: this.tidyCode, + [`${keyMaps.findPersistentNext}`]: 'findPersistentNext', + [`${keyMaps.findPersistentPrev}`]: 'findPersistentPrev', + [replaceCommand]: 'replace', + // Cassie Tarakajian: If you don't set a default color, then when you + // choose a color, it deletes characters inline. This is a + // hack to prevent that. + [`${keyMaps.colorPicker}`]: (cm, event) => + cm.state.colorpicker.popup_color_picker({ length: 0 }), + [`${metaKey}-.`]: 'toggleComment' // Note: most adblockers use the shortcut ctrl+. + }); + } + + static contextType = EditorKeyMapsContext; + render() { const editorSectionClass = classNames({ editor: true, diff --git a/client/modules/IDE/components/KeyboardShortcutItem.jsx b/client/modules/IDE/components/KeyboardShortcutItem.jsx new file mode 100644 index 0000000000..75c831cbe1 --- /dev/null +++ b/client/modules/IDE/components/KeyboardShortcutItem.jsx @@ -0,0 +1,138 @@ +import React, { useRef, useState } from 'react'; +import PropTypes from 'prop-types'; +import { useEditorKeyMap } from './Editor/contexts'; + +function KeyboardShortcutItem({ desc, keyName }) { + const [edit, setEdit] = useState(false); + const pressedKeyCombination = useRef({}); + const inputRef = useRef(null); + const { updateKeyMap, keyMaps } = useEditorKeyMap(); + + if (!Object.keys(keyMaps).includes(keyName)) { + return null; + } + + const cancelEdit = () => { + setEdit(false); + pressedKeyCombination.current = {}; + inputRef.current.innerText = keyMaps[keyName]; + }; + + const handleEdit = (state, key) => { + setEdit(state); + if (!state) { + updateKeyMap(key, inputRef.current.innerText); + cancelEdit(); + } + }; + + const handleKeyDown = (event) => { + if (!edit) return; + event.preventDefault(); + event.stopPropagation(); + let { key } = event; + if (key === 'Control') { + key = 'Ctrl'; + } + if (key === ' ') { + key = 'Space'; + } + if (key.length === 1 && key.match(/[a-z]/i)) { + key = key.toUpperCase(); + } + + pressedKeyCombination.current[key] = true; + + const allKeys = Object.keys(pressedKeyCombination.current).filter( + (k) => !['Shift', 'Ctrl', 'Alt'].includes(k) + ); + + if (event.altKey) { + allKeys.unshift('Alt'); + } + if (event.ctrlKey) { + allKeys.unshift('Ctrl'); + } + if (event.shiftKey) { + allKeys.unshift('Shift'); + } + + event.currentTarget.innerText = allKeys.join('-'); + }; + + const handleKeyUp = (event) => { + if (!edit) return; + event.preventDefault(); + event.stopPropagation(); + let { key } = event; + if (key === 'Control') { + key = 'Ctrl'; + } + if (key === ' ') { + key = 'Space'; + } + if (key.length === 1 && key.match(/[a-z]/i)) { + key = key.toUpperCase(); + } + + delete pressedKeyCombination.current[key]; + }; + + return ( +
  • + + + + + {keyMaps[keyName]} + + {desc} +
  • + ); +} + +KeyboardShortcutItem.propTypes = { + desc: PropTypes.string.isRequired, + keyName: PropTypes.string.isRequired +}; + +export default KeyboardShortcutItem; diff --git a/client/modules/IDE/components/KeyboardShortcutModal.jsx b/client/modules/IDE/components/KeyboardShortcutModal.jsx index fbca28a572..2aaed28bdd 100644 --- a/client/modules/IDE/components/KeyboardShortcutModal.jsx +++ b/client/modules/IDE/components/KeyboardShortcutModal.jsx @@ -1,9 +1,11 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; import { metaKeyName, metaKey } from '../../../utils/metaKey'; +import KeyboardShortcutItem from './KeyboardShortcutItem'; function KeyboardShortcutModal() { const { t } = useTranslation(); + const replaceCommand = metaKey === 'Ctrl' ? `${metaKeyName} + H` : `${metaKeyName} + ⌥ + F`; const newFileCommand = @@ -25,28 +27,22 @@ function KeyboardShortcutModal() { .