editor: convert Editor to function component

This commit is contained in:
David Lechner
2021-12-27 12:38:46 -06:00
parent c29f879086
commit 476998030c
5 changed files with 183 additions and 174 deletions
+1
View File
@@ -9,6 +9,7 @@
},
"dependencies": {
"@blueprintjs/core": "^3.52.0",
"@blueprintjs/popover2": "^0.12.9",
"@craco/craco": "^6.4.3",
"@pybricks/firmware": "4.14.0",
"@pybricks/ide-docs": "2.1.0",
+11 -3
View File
@@ -6,6 +6,7 @@ import {
fireEvent,
render,
screen,
waitFor,
waitForElementToBeRemoved,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
@@ -22,7 +23,10 @@ function getTextArea(): HTMLTextAreaElement {
it('should focus the text area', () => {
const store = {
getState: jest.fn(() => ({ settings: { darkMode: false, showDocs: false } })),
getState: jest.fn(() => ({
editor: { current: null },
settings: { darkMode: false, showDocs: false },
})),
dispatch: jest.fn(),
subscribe: jest.fn(),
} as unknown as Store;
@@ -39,9 +43,10 @@ it('should focus the text area', () => {
});
describe('context menu', () => {
it('should show the context menu', () => {
it('should show the context menu', async () => {
const store = {
getState: jest.fn(() => ({
editor: { current: null },
settings: { darkMode: false, showDocs: false },
})),
dispatch: jest.fn(),
@@ -59,12 +64,15 @@ describe('context menu', () => {
fireEvent.contextMenu(screen.getByText('Write your program here...'));
expect(screen.getByText('Copy')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText('Copy')).toBeInTheDocument();
});
});
it('should hide the context menu when Escape is pressed', async () => {
const store = {
getState: jest.fn(() => ({
editor: { current: null },
settings: { darkMode: false, showDocs: false },
})),
dispatch: jest.fn(),
+139 -171
View File
@@ -1,19 +1,23 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2020-2021 The Pybricks Authors
import { Menu, MenuDivider, MenuItem, ResizeSensor } from '@blueprintjs/core';
import { WithI18nProps, withI18n } from '@shopify/react-i18n';
import { Menu, MenuDivider, MenuItem } from '@blueprintjs/core';
import {
ContextMenu2,
ContextMenu2ContentProps,
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 from 'react';
import React, { useEffect, useRef } from 'react';
import MonacoEditor, { monaco } from 'react-monaco-editor';
import { connect } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { IDisposable } from 'xterm';
import { compile } from '../mpy/actions';
import { RootState } from '../reducers';
import { toggleBoolean } from '../settings/actions';
import { BooleanSettingId } from '../settings/defaults';
import { IContextMenuTarget, handleContextMenu } from '../utils/IContextMenuTarget';
import { isMacOS } from '../utils/os';
import { setEditSession, storageChanged } from './actions';
import { EditorStringId } from './i18n';
@@ -23,20 +27,6 @@ import { UntitledHintContribution } from './untitledHint';
import './editor.scss';
type StateProps = {
darkMode: boolean;
showDocs: boolean;
};
type DispatchProps = {
onSessionChanged: (session?: monaco.editor.ICodeEditor) => void;
onProgramStorageChanged: (newValue: string) => void;
onCheck: (script: string) => void;
onToggleDocs: () => void;
};
type EditorProps = StateProps & DispatchProps & WithI18nProps;
const pybricksMicroPythonId = 'pybricks-micropython';
monaco.languages.register({ id: pybricksMicroPythonId });
@@ -72,78 +62,146 @@ monaco.editor.defineTheme(
const xcodeId = 'xcode';
monaco.editor.defineTheme(xcodeId, xcodeTheme as monaco.editor.IStandaloneThemeData);
class Editor extends React.Component<EditorProps> implements IContextMenuTarget {
private editorRef: React.RefObject<MonacoEditor>;
const contextMenu = (_props: ContextMenu2ContentProps): JSX.Element => {
const editor = useSelector((state: RootState) => state.editor.current);
constructor(props: EditorProps) {
super(props);
this.editorRef = React.createRef();
}
const [i18n] = useI18n({ id: 'editor', translations: { en }, fallback: en });
/** convenience property for getting editor object */
private get editor(): monaco.editor.IStandaloneCodeEditor | undefined {
return this.editorRef.current?.editor;
}
return (
<Menu>
<MenuItem
onClick={() => {
editor?.focus();
editor?.trigger(null, 'editor.action.clipboardCopyAction', null);
}}
text={i18n.translate(EditorStringId.Copy)}
icon="duplicate"
label={isMacOS() ? 'Cmd-C' : 'Ctrl-C'}
disabled={!editor?.getSelection() || editor?.getSelection()?.isEmpty()}
/>
<MenuItem
onClick={() => {
editor?.focus();
editor?.trigger(null, 'editor.action.clipboardPasteAction', null);
}}
text={i18n.translate(EditorStringId.Paste)}
icon="clipboard"
label={isMacOS() ? 'Cmd-V' : 'Ctrl-V'}
/>
<MenuItem
onClick={() => {
editor?.focus();
editor?.trigger(null, 'editor.action.selectAll', null);
}}
text={i18n.translate(EditorStringId.SelectAll)}
icon="blank"
label={isMacOS() ? 'Cmd-A' : 'Ctrl-A'}
/>
<MenuDivider />
<MenuItem
onClick={() => {
editor?.focus();
editor?.trigger(null, 'undo', null);
}}
text={i18n.translate(EditorStringId.Undo)}
icon="undo"
label={isMacOS() ? 'Cmd-Z' : 'Ctrl-Z'}
// @ts-expect-error internal method canUndo()
disabled={!editor?.getModel()?.canUndo()}
/>
<MenuItem
onClick={() => {
editor?.focus();
editor?.trigger(null, 'redo', null);
}}
text={i18n.translate(EditorStringId.Redo)}
icon="redo"
label={isMacOS() ? 'Cmd-Shift-Z' : 'Ctrl-Shift-Z'}
// @ts-expect-error internal method canUndo()
disabled={!editor?.getModel()?.canRedo()}
/>
</Menu>
);
};
onStorage = (e: StorageEvent): void => {
const Editor: React.FunctionComponent = (_props) => {
const editorRef = useRef<MonacoEditor>(null);
const dispatch = useDispatch();
const onStorage = (e: StorageEvent): void => {
if (
e.key === 'program' &&
e.newValue &&
e.newValue !== this.editor?.getValue()
e.newValue !== editorRef.current?.editor?.getValue()
) {
this.props.onProgramStorageChanged(e.newValue);
dispatch(storageChanged(e.newValue));
}
};
componentDidMount(): void {
window.addEventListener('storage', this.onStorage);
}
useEffect(() => {
window.addEventListener('storage', onStorage);
return () => window.removeEventListener('storage', onStorage);
});
componentWillUnmount(): void {
window.removeEventListener('storage', this.onStorage);
}
const darkMode = useSelector((state: RootState) => state.settings.darkMode);
render(): JSX.Element {
const { i18n, darkMode, onSessionChanged, onCheck, onToggleDocs } = this.props;
return (
<div className="h-100" onContextMenu={(e) => handleContextMenu(e, this)}>
<ResizeSensor onResize={(): void => this.editor?.layout()}>
<MonacoEditor
ref={this.editorRef}
language={pybricksMicroPythonId}
theme={darkMode ? tomorrowNightEightiesId : xcodeId}
width="100%"
height="100%"
options={{
fontSize: 18,
minimap: { enabled: false },
contextmenu: false,
rulers: [80],
}}
value={localStorage.getItem('program')}
editorDidMount={(e, _m): void => {
// FIXME: editor does not respond to changes in i18n
const untitledHintContribution =
new UntitledHintContribution(
e,
i18n.translate(EditorStringId.Placeholder),
);
e.onDidDispose(() => untitledHintContribution.dispose());
e.addAction({
const [i18n] = useI18n({ id: 'editor', translations: { en }, fallback: en });
return (
<ResizeSensor2 onResize={() => editorRef?.current?.editor?.layout()}>
<ContextMenu2
className="h-100"
content={contextMenu}
popoverProps={{ onClosed: () => editorRef.current?.editor?.focus() }}
>
<MonacoEditor
ref={editorRef}
language={pybricksMicroPythonId}
theme={darkMode ? tomorrowNightEightiesId : xcodeId}
width="100%"
height="100%"
options={{
fontSize: 18,
minimap: { enabled: false },
contextmenu: false,
rulers: [80],
}}
value={localStorage.getItem('program')}
editorDidMount={(editor, _monaco) => {
const subscriptions = new Array<IDisposable>();
// FIXME: editor does not respond to changes in i18n
subscriptions.push(
new UntitledHintContribution(
editor,
i18n.translate(EditorStringId.Placeholder),
),
);
subscriptions.push(
editor.addAction({
id: 'pybricks.action.toggleDocs',
label: i18n.translate(EditorStringId.ToggleDocs),
run: () => onToggleDocs(),
run: () => {
dispatch(toggleBoolean(BooleanSettingId.ShowDocs));
},
keybindings: [
monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyD,
],
});
e.addAction({
}),
);
subscriptions.push(
editor.addAction({
id: 'pybricks.action.check',
label: i18n.translate(EditorStringId.Check),
run: () => onCheck(e.getValue()),
// 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],
});
e.addAction({
}),
);
subscriptions.push(
editor.addAction({
id: 'pybricks.action.save',
label: 'Unused',
run: () => {
@@ -155,109 +213,19 @@ class Editor extends React.Component<EditorProps> implements IContextMenuTarget
keybindings: [
monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS,
],
});
e.focus();
onSessionChanged(e);
}}
onChange={(v): void => {
localStorage.setItem('program', v);
}}
/>
</ResizeSensor>
</div>
);
}
renderContextMenu(): JSX.Element {
const { i18n } = this.props;
return (
<Menu>
<MenuItem
onClick={(): void => {
this.editor?.focus();
this.editor?.trigger(
null,
'editor.action.clipboardCopyAction',
null,
}),
);
}}
text={i18n.translate(EditorStringId.Copy)}
icon="duplicate"
label={isMacOS() ? 'Cmd-C' : 'Ctrl-C'}
disabled={
!this.editor?.getSelection() ||
this.editor?.getSelection()?.isEmpty()
}
/>
<MenuItem
onClick={async (): Promise<void> => {
this.editor?.focus();
this.editor?.trigger(
null,
'editor.action.clipboardPasteAction',
null,
editor.onDidDispose(() =>
subscriptions.forEach((s) => s.dispose()),
);
editor.focus();
dispatch(setEditSession(editor));
}}
text={i18n.translate(EditorStringId.Paste)}
icon="clipboard"
label={isMacOS() ? 'Cmd-V' : 'Ctrl-V'}
onChange={(v) => localStorage.setItem('program', v)}
/>
<MenuItem
onClick={() => {
this.editor?.focus();
this.editor?.trigger(null, 'editor.action.selectAll', null);
}}
text={i18n.translate(EditorStringId.SelectAll)}
icon="blank"
label={isMacOS() ? 'Cmd-A' : 'Ctrl-A'}
/>
<MenuDivider />
<MenuItem
onClick={(): void => {
this.editor?.focus();
this.editor?.trigger(null, 'undo', null);
}}
text={i18n.translate(EditorStringId.Undo)}
icon="undo"
label={isMacOS() ? 'Cmd-Z' : 'Ctrl-Z'}
// @ts-expect-error internal method canUndo()
disabled={!this.editor?.getModel()?.canUndo()}
/>
<MenuItem
onClick={(): void => {
this.editor?.focus();
this.editor?.trigger(null, 'redo', null);
}}
text={i18n.translate(EditorStringId.Redo)}
icon="redo"
label={isMacOS() ? 'Cmd-Shift-Z' : 'Ctrl-Shift-Z'}
// @ts-expect-error internal method canUndo()
disabled={!this.editor?.getModel()?.canRedo()}
/>
</Menu>
);
}
onContextMenuClose = () => {
this.editor?.focus();
};
}
const mapStateToProps = (state: RootState): StateProps => ({
darkMode: state.settings.darkMode,
showDocs: state.settings.showDocs,
});
const mapDispatchToProps: DispatchProps = {
onSessionChanged: setEditSession,
onProgramStorageChanged: storageChanged,
// REVISIT: the 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.
onCheck: (script) => compile(script, []),
onToggleDocs: () => toggleBoolean(BooleanSettingId.ShowDocs),
</ContextMenu2>
</ResizeSensor2>
);
};
export default connect(
mapStateToProps,
mapDispatchToProps,
)(withI18n({ id: 'editor', fallback: en, translations: { en } })(Editor));
export default Editor;
+1
View File
@@ -4,6 +4,7 @@
@import './variables.scss';
@import '~normalize.css';
@import '~@blueprintjs/core/src/blueprint.scss';
@import '~@blueprintjs/popover2/src/blueprint-popover2.scss';
:root {
--pb-vh: 100vh;
+31
View File
@@ -1232,6 +1232,19 @@
classnames "^2.2"
tslib "~1.13.0"
"@blueprintjs/popover2@^0.12.9":
version "0.12.9"
resolved "https://registry.yarnpkg.com/@blueprintjs/popover2/-/popover2-0.12.9.tgz#ff725e05a422580a6ea8dfd5e777aad13ce13102"
integrity sha512-q1wXGav85NKYfgFo9CxXnqARY0i9QY4KUwjJpZrTqnebwM0wQJLQpAnYvFrHYWYxHacSxz+SWdoqI8AK9h/m7Q==
dependencies:
"@blueprintjs/core" "^3.52.0"
"@popperjs/core" "^2.5.4"
classnames "^2.2"
dom4 "^2.1.5"
react-popper "^2.2.4"
resize-observer-polyfill "^1.5.1"
tslib "~1.13.0"
"@cnakazawa/watch@^1.0.3":
version "1.0.4"
resolved "https://registry.yarnpkg.com/@cnakazawa/watch/-/watch-1.0.4.tgz#f864ae85004d0fcab6f50be9141c4da368d1656a"
@@ -1597,6 +1610,11 @@
schema-utils "^2.6.5"
source-map "^0.7.3"
"@popperjs/core@^2.5.4":
version "2.11.0"
resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.0.tgz#6734f8ebc106a0860dff7f92bf90df193f0935d7"
integrity sha512-zrsUxjLOKAzdewIDRWy9nsV1GQsKBCWaGwsZQlCgr6/q+vjyZhFgqedLfFBuI9anTPEUT4APq9Mu0SZBTzIcGQ==
"@pybricks/firmware@4.14.0":
version "4.14.0"
resolved "https://registry.yarnpkg.com/@pybricks/firmware/-/firmware-4.14.0.tgz#833820227f9f52de0dbf5ded999f808731ddaaac"
@@ -10192,6 +10210,11 @@ react-error-overlay@^6.0.9:
resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.9.tgz#3c743010c9359608c375ecd6bc76f35d93995b0a"
integrity sha512-nQTTcUu+ATDbrSD1BZHr5kgSD4oF8OFjxun8uAaL8RwPBacGBNPf/yAuVVdx17N8XNzRDMrZ9XcKZHCjPW+9ew==
react-fast-compare@^3.0.1:
version "3.2.0"
resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.0.tgz#641a9da81b6a6320f270e89724fb45a0b39e43bb"
integrity sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA==
react-is@^16.13.1, react-is@^16.7.0:
version "16.13.1"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
@@ -10227,6 +10250,14 @@ react-popper@^1.3.7:
typed-styles "^0.0.7"
warning "^4.0.2"
react-popper@^2.2.4:
version "2.2.5"
resolved "https://registry.yarnpkg.com/react-popper/-/react-popper-2.2.5.tgz#1214ef3cec86330a171671a4fbcbeeb65ee58e96"
integrity sha512-kxGkS80eQGtLl18+uig1UIf9MKixFSyPxglsgLBxlYnyDf65BiY9B3nZSc6C9XUNDgStROB0fMQlTEz1KxGddw==
dependencies:
react-fast-compare "^3.0.1"
warning "^4.0.2"
react-redux@^7.2.6:
version "7.2.6"
resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-7.2.6.tgz#49633a24fe552b5f9caf58feb8a138936ddfe9aa"