diff --git a/package.json b/package.json index b9399f84..f7353fa9 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/editor/Editor.test.tsx b/src/editor/Editor.test.tsx index 6856eecf..dd8e318d 100644 --- a/src/editor/Editor.test.tsx +++ b/src/editor/Editor.test.tsx @@ -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(), diff --git a/src/editor/Editor.tsx b/src/editor/Editor.tsx index c76be844..435130a9 100644 --- a/src/editor/Editor.tsx +++ b/src/editor/Editor.tsx @@ -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 implements IContextMenuTarget { - private editorRef: React.RefObject; +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 ( + + { + 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()} + /> + { + editor?.focus(); + editor?.trigger(null, 'editor.action.clipboardPasteAction', null); + }} + text={i18n.translate(EditorStringId.Paste)} + icon="clipboard" + label={isMacOS() ? 'Cmd-V' : 'Ctrl-V'} + /> + { + editor?.focus(); + editor?.trigger(null, 'editor.action.selectAll', null); + }} + text={i18n.translate(EditorStringId.SelectAll)} + icon="blank" + label={isMacOS() ? 'Cmd-A' : 'Ctrl-A'} + /> + + { + 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()} + /> + { + 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()} + /> + + ); +}; - onStorage = (e: StorageEvent): void => { +const Editor: React.FunctionComponent = (_props) => { + const editorRef = useRef(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 ( -
handleContextMenu(e, this)}> - this.editor?.layout()}> - { - // 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 ( + editorRef?.current?.editor?.layout()}> + editorRef.current?.editor?.focus() }} + > + { + const subscriptions = new Array(); + // 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 implements IContextMenuTarget keybindings: [ monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, ], - }); - e.focus(); - onSessionChanged(e); - }} - onChange={(v): void => { - localStorage.setItem('program', v); - }} - /> - -
- ); - } - - renderContextMenu(): JSX.Element { - const { i18n } = this.props; - return ( - - { - 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() - } - /> - => { - 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)} /> - { - this.editor?.focus(); - this.editor?.trigger(null, 'editor.action.selectAll', null); - }} - text={i18n.translate(EditorStringId.SelectAll)} - icon="blank" - label={isMacOS() ? 'Cmd-A' : 'Ctrl-A'} - /> - - { - 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()} - /> - { - 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()} - /> - - ); - } - - 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), + + + ); }; -export default connect( - mapStateToProps, - mapDispatchToProps, -)(withI18n({ id: 'editor', fallback: en, translations: { en } })(Editor)); +export default Editor; diff --git a/src/index.scss b/src/index.scss index 24590bed..e8e80c06 100644 --- a/src/index.scss +++ b/src/index.scss @@ -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; diff --git a/yarn.lock b/yarn.lock index 4cb0916d..893cb069 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"