editor: drop EditorContext

We want to rely less on directly accessing the editor object.
This commit is contained in:
David Lechner
2022-03-12 18:29:56 -06:00
parent ceeb34436f
commit c75f3720f5
6 changed files with 112 additions and 104 deletions
+57 -65
View File
@@ -2,10 +2,10 @@
// Copyright (c) 2020-2022 The Pybricks Authors
import { Classes } from '@blueprintjs/core';
import React, { useEffect, useMemo, useState } from 'react';
import React, { useEffect, useState } from 'react';
import { useDispatch } from 'react-redux';
import SplitterLayout from 'react-splitter-layout';
import Editor, { EditorContext, EditorContextType, EditorType } from '../editor/Editor';
import Editor, { EditorType } from '../editor/Editor';
import Explorer from '../explorer/Explorer';
import { useSelector } from '../reducers';
import { toggleBoolean } from '../settings/actions';
@@ -17,6 +17,7 @@ import { isMacOS } from '../utils/os';
import 'react-splitter-layout/lib/index.css';
import './app.scss';
import { appEditor } from './actions';
const Docs: React.FunctionComponent = (_props) => {
const dispatch = useDispatch();
@@ -131,21 +132,7 @@ const App: React.VoidFunctionComponent<AppProps> = ({ onEditorChanged }) => {
const darkMode = useSelector((s): boolean => s.settings.darkMode);
const showDocs = useSelector((s): boolean => s.settings.showDocs);
const [isDragging, setIsDragging] = useState(false);
const [editor, setEditor] = useState<EditorType>(null);
const editorContext = useMemo<EditorContextType>(
() => ({
editor,
setEditor: (editor) => {
setEditor(editor);
if (onEditorChanged) {
onEditorChanged(editor);
}
},
}),
[editor],
);
const dispatch = useDispatch();
// darkMode class has to be applied to body element, otherwise it won't
// affect portals
@@ -160,56 +147,61 @@ const App: React.VoidFunctionComponent<AppProps> = ({ onEditorChanged }) => {
}, [darkMode]);
return (
<EditorContext.Provider value={editorContext}>
<div className="pb-app h-100 w-100 p-absolute">
<Toolbar />
<SplitterLayout
customClassName={`pb-app-body ${
showDocs ? 'pb-show-docs' : 'pb-hide-docs'
}`}
onDragStart={(): void => setIsDragging(true)}
onDragEnd={(): void => setIsDragging(false)}
percentage={true}
secondaryInitialSize={Number(
localStorage.getItem('app-docs-split') || 30,
)}
onSecondaryPaneSizeChange={(value): void =>
localStorage.setItem('app-docs-split', String(value))
}
>
<div className="h-100 w-100" style={{ display: 'flex' }}>
<div style={{ display: 'inline-block', width: 250 }}>
<Explorer />
</div>
<div style={{ display: 'inline-block' }}>
<SplitterLayout
vertical={true}
percentage={true}
secondaryInitialSize={Number(
localStorage.getItem('app-terminal-split') || 30,
)}
onSecondaryPaneSizeChange={(value): void =>
localStorage.setItem(
'app-terminal-split',
String(value),
)
}
>
<Editor />
<div className="pb-app-terminal-padding h-100">
<Terminal />
</div>
</SplitterLayout>
</div>
<div className="pb-app h-100 w-100 p-absolute">
<Toolbar />
<SplitterLayout
customClassName={`pb-app-body ${
showDocs ? 'pb-show-docs' : 'pb-hide-docs'
}`}
onDragStart={(): void => setIsDragging(true)}
onDragEnd={(): void => setIsDragging(false)}
percentage={true}
secondaryInitialSize={Number(
localStorage.getItem('app-docs-split') || 30,
)}
onSecondaryPaneSizeChange={(value): void =>
localStorage.setItem('app-docs-split', String(value))
}
>
<div className="h-100 w-100" style={{ display: 'flex' }}>
<div style={{ display: 'inline-block', width: 250 }}>
<Explorer />
</div>
<div className="h-100 w-100">
{isDragging && <div className="h-100 w-100 p-absolute" />}
<Docs />
<div style={{ display: 'inline-block' }}>
<SplitterLayout
vertical={true}
percentage={true}
secondaryInitialSize={Number(
localStorage.getItem('app-terminal-split') || 30,
)}
onSecondaryPaneSizeChange={(value): void =>
localStorage.setItem(
'app-terminal-split',
String(value),
)
}
>
<Editor
onEditorChanged={(editor) => {
dispatch(appEditor(editor !== null));
if (onEditorChanged) {
onEditorChanged(editor);
}
}}
/>
<div className="pb-app-terminal-padding h-100">
<Terminal />
</div>
</SplitterLayout>
</div>
</SplitterLayout>
<StatusBar />
</div>
</EditorContext.Provider>
</div>
<div className="h-100 w-100">
{isDragging && <div className="h-100 w-100 p-absolute" />}
<Docs />
</div>
</SplitterLayout>
<StatusBar />
</div>
);
};
+6
View File
@@ -48,3 +48,9 @@ export const didInstall = createAction(() => ({
export const didStart = createAction(() => ({
type: 'app.action.didStart',
}));
/** Temporary action for transitioning editor context. Do no use in new code. */
export const appEditor = createAction((hasEditor: boolean) => ({
type: 'app.action.editor',
hasEditor,
}));
+1
View File
@@ -22,6 +22,7 @@ test('initial state', () => {
expect(reducers(undefined, {} as AnyAction)).toMatchInlineSnapshot(`
Object {
"checkingForUpdate": false,
"hasEditor": false,
"hasUnresolvedInstallPrompt": false,
"isServiceWorkerRegistered": false,
"promptingInstall": false,
+11
View File
@@ -13,6 +13,7 @@ import {
appDidCheckForUpdate,
appDidReceiveBeforeInstallPrompt,
appDidResolveInstallPrompt,
appEditor,
appShowInstallPrompt,
didInstall,
} from './actions';
@@ -91,6 +92,15 @@ const readyForOfflineUse: Reducer<boolean> = (state = false, action) => {
return state;
};
/** Temporary reducer for transitioning editor context. Do no use in new code. */
const hasEditor: Reducer<boolean> = (state = false, action) => {
if (appEditor.matches(action)) {
return action.hasEditor;
}
return state;
};
export default combineReducers({
isServiceWorkerRegistered,
checkingForUpdate,
@@ -98,4 +108,5 @@ export default combineReducers({
hasUnresolvedInstallPrompt,
promptingInstall,
readyForOfflineUse,
hasEditor,
});
+34 -35
View File
@@ -2,15 +2,11 @@
// Copyright (c) 2020-2022 The Pybricks Authors
import { Menu, MenuDivider, MenuItem } from '@blueprintjs/core';
import {
ContextMenu2,
ContextMenu2ContentProps,
ResizeSensor2,
} from '@blueprintjs/popover2';
import { ContextMenu2, ResizeSensor2 } from '@blueprintjs/popover2';
import { 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, { createContext, useContext, useRef } from 'react';
import React, { useState } from 'react';
import MonacoEditor, { monaco } from 'react-monaco-editor';
import { useDispatch } from 'react-redux';
import { IDisposable } from 'xterm';
@@ -32,22 +28,6 @@ import './editor.scss';
*/
export type EditorType = monaco.editor.ICodeEditor | null;
/**
* The type for the value of EditorContext.
*/
export type EditorContextType = {
editor: EditorType;
setEditor: (editor: EditorType) => void;
};
/**
* Editor context for getting access to the current editor.
*/
export const EditorContext = createContext<EditorContextType>({
editor: null,
setEditor: () => undefined,
});
const pybricksMicroPythonId = 'pybricks-micropython';
monaco.languages.register({ id: pybricksMicroPythonId });
@@ -83,11 +63,22 @@ monaco.editor.defineTheme(
const xcodeId = 'xcode';
monaco.editor.defineTheme(xcodeId, xcodeTheme as monaco.editor.IStandaloneThemeData);
const contextMenu = (_props: ContextMenu2ContentProps): JSX.Element => {
const { editor } = useContext(EditorContext);
type EditorContextMenuProps = { editor: EditorType };
const EditorContextMenu: React.VoidFunctionComponent<EditorContextMenuProps> = ({
editor,
}) => {
const [i18n] = useI18n({ id: 'editor', translations: { en }, fallback: en });
const hasEditor = editor !== null;
const selection = editor?.getSelection();
const hasSelection = selection && !selection.isEmpty();
const model = editor?.getModel();
const canUndo = model && model.canUndo();
const canRedo = model && model.canRedo();
return (
<Menu>
<MenuItem
@@ -98,7 +89,7 @@ const contextMenu = (_props: ContextMenu2ContentProps): JSX.Element => {
text={i18n.translate(EditorStringId.Copy)}
icon="duplicate"
label={isMacOS() ? 'Cmd-C' : 'Ctrl-C'}
disabled={!editor?.getSelection() || editor?.getSelection()?.isEmpty()}
disabled={!hasSelection}
/>
<MenuItem
onClick={() => {
@@ -108,6 +99,7 @@ const contextMenu = (_props: ContextMenu2ContentProps): JSX.Element => {
text={i18n.translate(EditorStringId.Paste)}
icon="clipboard"
label={isMacOS() ? 'Cmd-V' : 'Ctrl-V'}
disabled={!hasEditor}
/>
<MenuItem
onClick={() => {
@@ -117,6 +109,7 @@ const contextMenu = (_props: ContextMenu2ContentProps): JSX.Element => {
text={i18n.translate(EditorStringId.SelectAll)}
icon="blank"
label={isMacOS() ? 'Cmd-A' : 'Ctrl-A'}
disabled={!hasEditor}
/>
<MenuDivider />
<MenuItem
@@ -127,7 +120,7 @@ const contextMenu = (_props: ContextMenu2ContentProps): JSX.Element => {
text={i18n.translate(EditorStringId.Undo)}
icon="undo"
label={isMacOS() ? 'Cmd-Z' : 'Ctrl-Z'}
disabled={!editor?.getModel()?.canUndo()}
disabled={!canUndo}
/>
<MenuItem
onClick={() => {
@@ -137,30 +130,33 @@ const contextMenu = (_props: ContextMenu2ContentProps): JSX.Element => {
text={i18n.translate(EditorStringId.Redo)}
icon="redo"
label={isMacOS() ? 'Cmd-Shift-Z' : 'Ctrl-Shift-Z'}
disabled={!editor?.getModel()?.canRedo()}
disabled={!canRedo}
/>
</Menu>
);
};
const Editor: React.FunctionComponent = (_props) => {
const editorRef = useRef<MonacoEditor>(null);
const dispatch = useDispatch();
const { setEditor } = useContext(EditorContext);
type EditorProps = { onEditorChanged: (editor: EditorType) => void };
const Editor: React.VoidFunctionComponent<EditorProps> = ({ onEditorChanged }) => {
const dispatch = useDispatch();
const [editor, setEditor] = useState<EditorType>(null);
const darkMode = useSelector((s) => s.settings.darkMode);
const [i18n] = useI18n({ id: 'editor', translations: { en }, fallback: en });
return (
<ResizeSensor2 onResize={() => editorRef?.current?.editor?.layout()}>
<ResizeSensor2 onResize={() => editor?.layout()}>
<ContextMenu2
className="h-100"
content={contextMenu}
popoverProps={{ onClosed: () => editorRef.current?.editor?.focus() }}
// NB: we have to create a new context menu each time it is
// shown in order to get some state, like canUndo and canRedo
// that don't have events to monitor changes.
content={() => <EditorContextMenu editor={editor} />}
popoverProps={{ onClosed: () => editor?.focus() }}
>
<MonacoEditor
ref={editorRef}
language={pybricksMicroPythonId}
theme={darkMode ? tomorrowNightEightiesId : xcodeId}
width="100%"
@@ -224,6 +220,9 @@ const Editor: React.FunctionComponent = (_props) => {
);
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))}
+3 -4
View File
@@ -1,9 +1,8 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2020-2022 The Pybricks Authors
import React, { useContext } from 'react';
import React from 'react';
import { useDispatch } from 'react-redux';
import { EditorContext } from '../editor/Editor';
import { useSelector } from '../reducers';
import ActionButton, { ActionButtonProps } from '../toolbar/ActionButton';
import { TooltipId } from '../toolbar/i18n';
@@ -17,9 +16,9 @@ const RunButton: React.VoidFunctionComponent<RunButtonProps> = ({
id,
keyboardShortcut,
}) => {
const { editor } = useContext(EditorContext);
const downloadProgress = useSelector((s) => s.hub.downloadProgress);
const runtime = useSelector((s) => s.hub.runtime);
const hasEditor = useSelector((s) => s.app.hasEditor);
const dispatch = useDispatch();
@@ -30,7 +29,7 @@ const RunButton: React.VoidFunctionComponent<RunButtonProps> = ({
tooltip={TooltipId.Run}
progressTooltip={TooltipId.RunProgress}
icon={runIcon}
enabled={editor !== null && runtime === HubRuntimeState.Idle}
enabled={hasEditor && runtime === HubRuntimeState.Idle}
showProgress={runtime === HubRuntimeState.Loading}
progress={downloadProgress === null ? undefined : downloadProgress}
onAction={() => dispatch(downloadAndRun())}