mirror of
https://github.com/pybricks/pybricks-code.git
synced 2026-09-12 01:23:52 +00:00
editor: convert Editor to function component
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user