diff --git a/src/editor/Editor.tsx b/src/editor/Editor.tsx index 541e5717..8fb376b7 100644 --- a/src/editor/Editor.tsx +++ b/src/editor/Editor.tsx @@ -6,14 +6,19 @@ import { ContextMenu2, ResizeSensor2 } from '@blueprintjs/popover2'; import { I18n, useI18n } from '@shopify/react-i18n'; import tomorrowNightEightiesTheme from 'monaco-themes/themes/Tomorrow-Night-Eighties.json'; import xcodeTheme from 'monaco-themes/themes/Xcode_default.json'; -import React, { useState } from 'react'; -import MonacoEditor, { monaco } from 'react-monaco-editor'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import MonacoEditor, { + ChangeHandler, + EditorDidMount, + EditorWillUnmount, + monaco, +} from 'react-monaco-editor'; import { useDispatch } from 'react-redux'; import { useTernaryDarkMode } from 'usehooks-ts'; import { IDisposable } from 'xterm'; import { fileStorageWriteFile } from '../fileStorage/actions'; import { compile } from '../mpy/actions'; -import { settingsToggleShowDocs } from '../settings/actions'; +import { useSettingIsShowDocsEnabled } from '../settings/hooks'; import { isMacOS } from '../utils/os'; import { I18nId } from './i18n'; import * as pybricksMicroPython from './pybricksMicroPython'; @@ -61,7 +66,12 @@ monaco.editor.defineTheme( const xcodeId = 'xcode'; monaco.editor.defineTheme(xcodeId, xcodeTheme as monaco.editor.IStandaloneThemeData); -type EditorContextMenuProps = { editor: EditorType; i18n: I18n }; +type EditorContextMenuProps = { + /** The editor. */ + editor?: monaco.editor.IStandaloneCodeEditor; + /** Translation context. */ + i18n: I18n; +}; const EditorContextMenu: React.VoidFunctionComponent = ({ editor, @@ -133,17 +143,150 @@ const EditorContextMenu: React.VoidFunctionComponent = ( ); }; +/** + * Wrapper around useEffect() hook that uses {@link maybeEditor}. + * @param maybeEditor The editor or undefined if the editor is not mounted. + * @param callback The callback to call when editor is defined and when {@link deps} change. + * @param deps Additional dependencies used in the {@link callback}. + */ +function useEditor( + maybeEditor: monaco.editor.IStandaloneCodeEditor | undefined, + callback: ( + editor: monaco.editor.IStandaloneCodeEditor, + ) => ReturnType, + deps: React.DependencyList, +): void { + useEffect(() => { + if (!maybeEditor) { + return; + } + + return callback(maybeEditor); + }, [maybeEditor, ...deps]); +} + +/** + * Hook for adding actions to the editor. + * @param maybeEditor The editor or undefined if the editor is not mounted. + * @param createAction A callback to create a new action. + * @param deps Additional dependencies used in {@link createAction}. + */ +function useEditorAction( + maybeEditor: monaco.editor.IStandaloneCodeEditor | undefined, + createAction: () => monaco.editor.IActionDescriptor, + deps: React.DependencyList, +): void { + useEditor( + maybeEditor, + (editor) => { + const subscription = editor.addAction(createAction()); + return () => subscription.dispose(); + }, + deps, + ); +} + type EditorProps = { onEditorChanged?: (editor: EditorType) => void }; const Editor: React.VoidFunctionComponent = ({ onEditorChanged }) => { const dispatch = useDispatch(); - const [editor, setEditor] = useState(null); + const [editor, setEditor] = useState(); + const { toggleIsSettingShowDocsEnabled } = useSettingIsShowDocsEnabled(); const { isDarkMode } = useTernaryDarkMode(); // istanbul ignore next: babel-loader rewrites this line const [i18n] = useI18n(); + const options = useMemo( + () => ({ + fontSize: 18, + minimap: { enabled: false }, + contextmenu: false, + rulers: [80], + }), + [], + ); + + useEditor( + editor, + (editor) => { + const contrib = new UntitledHintContribution( + editor, + i18n.translate(I18nId.Placeholder), + ); + return () => contrib.dispose(); + }, + [i18n], + ); + + useEditorAction( + editor, + () => ({ + id: 'pybricks.action.toggleDocs', + label: i18n.translate(I18nId.ToggleDocs), + run: () => toggleIsSettingShowDocsEnabled(), + keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyD], + }), + [i18n, toggleIsSettingShowDocsEnabled], + ); + + useEditorAction( + editor, + () => ({ + id: 'pybricks.action.check', + label: i18n.translate(I18nId.Check), + // REVISIT: the compile options here might need to be changed - hopefully there is + // one setting that works for all hub types for cases where we aren't connected. + run: (e) => { + dispatch(compile(e.getValue(), [])); + }, + keybindings: [monaco.KeyCode.F2], + }), + [i18n, dispatch], + ); + + useEditorAction( + editor, + () => ({ + id: 'pybricks.action.save', + label: 'Unused', + run: () => { + // We already automatically save the file after every change, + // so CTRL+S is ignored + console.debug('Ctrl-S ignored'); + }, + keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS], + }), + [], + ); + + const handleEditorDidMount = useCallback( + (editor) => { + editor.focus(); + setEditor(editor); + + if (onEditorChanged) { + onEditorChanged(editor); + } + }, + [onEditorChanged, setEditor], + ); + + const handleEditorWillUnmount = useCallback(() => { + if (onEditorChanged) { + onEditorChanged(null); + } + + setEditor(undefined); + }, [onEditorChanged, setEditor]); + + const handleChange = useCallback( + // REVISIT: need to ensure we have exclusive access to file + (v) => dispatch(fileStorageWriteFile('main.py', v)), + [dispatch], + ); + return ( editor?.layout()}> = ({ onEditorChanged }) = { - const subscriptions = new Array(); - // FIXME: editor does not respond to changes in i18n - subscriptions.push( - new UntitledHintContribution( - editor, - i18n.translate(I18nId.Placeholder), - ), - ); - subscriptions.push( - editor.addAction({ - id: 'pybricks.action.toggleDocs', - label: i18n.translate(I18nId.ToggleDocs), - run: () => { - // we have to use dispatch here instead of - // toggleIsSettingShowDocsEnabled since this - // isn't updated on state changes - dispatch(settingsToggleShowDocs()); - }, - keybindings: [ - monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyD, - ], - }), - ); - subscriptions.push( - editor.addAction({ - id: 'pybricks.action.check', - label: i18n.translate(I18nId.Check), - // REVISIT: the compile options here might need to be changed - hopefully there is - // one setting that works for all hub types for cases where we aren't connected. - run: (e) => { - dispatch(compile(e.getValue(), [])); - }, - keybindings: [monaco.KeyCode.F2], - }), - ); - subscriptions.push( - editor.addAction({ - id: 'pybricks.action.save', - label: 'Unused', - run: () => { - // We already automatically save the file - // to local storage after every change, so - // CTRL+S is ignored - console.debug('Ctrl-S ignored'); - }, - keybindings: [ - monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, - ], - }), - ); - editor.onDidDispose(() => - subscriptions.forEach((s) => s.dispose()), - ); - editor.focus(); - setEditor(editor); - if (onEditorChanged) { - onEditorChanged(editor); - } - }} - // REVIST: need to ensure we have exclusive access to file - onChange={(v) => dispatch(fileStorageWriteFile('main.py', v))} + options={options} + editorDidMount={handleEditorDidMount} + editorWillUnmount={handleEditorWillUnmount} + onChange={handleChange} />