// SPDX-License-Identifier: MIT // Copyright (c) 2020-2021 The Pybricks Authors import { ContextMenuTarget, Menu, MenuDivider, MenuItem, ResizeSensor, } from '@blueprintjs/core'; import { WithI18nProps, withI18n } from '@shopify/react-i18n'; import { Ace, config } from 'ace-builds'; import React from 'react'; import AceEditor from 'react-ace'; import { IAceEditor } from 'react-ace/lib/types'; import { connect } from 'react-redux'; import { Action, Dispatch } from '../actions'; import { setEditSession, storageChanged } from '../actions/editor'; import { compile } from '../actions/mpy'; import { RootState } from '../reducers'; import { isMacOS } from '../utils/os'; import { EditorStringId } from './editor-i18n'; import en from './editor-i18n.en.json'; import 'ace-builds/src-noconflict/mode-python'; import 'ace-builds/src-noconflict/theme-tomorrow_night_eighties'; import 'ace-builds/src-noconflict/theme-xcode'; import 'ace-builds/src-noconflict/ext-searchbox'; import 'ace-builds/src-noconflict/ext-keybinding_menu'; import 'ace-builds/src-noconflict/ext-language_tools'; import './editor-snippets'; import './editor.scss'; type StateProps = { darkMode: boolean; }; type DispatchProps = { onSessionChanged: (session?: Ace.EditSession) => void; onProgramStorageChanged: (newValue: string) => void; onCheck: (script: string) => void; }; type EditorProps = StateProps & DispatchProps & WithI18nProps; @ContextMenuTarget class Editor extends React.Component { private editorRef: React.RefObject; private keyBindings?: Array<{ key: string; command: string }>; constructor(props: EditorProps) { super(props); this.editorRef = React.createRef(); } /** convenience property for getting Ace editor object */ private get editor(): IAceEditor | undefined { return this.editorRef.current?.editor; } onStorage = (e: StorageEvent): void => { if ( e.key === 'program' && e.newValue && e.newValue !== this.editor?.getValue() ) { this.props.onProgramStorageChanged(e.newValue); } }; componentDidMount(): void { window.addEventListener('storage', this.onStorage); } componentWillUnmount(): void { window.removeEventListener('storage', this.onStorage); } render(): JSX.Element { const { darkMode, i18n, onSessionChanged, onCheck } = this.props; return (
this.editor?.resize()}> { // default binding is F2 which conflicts with 'check' e.commands.byName['toggleFoldWidget'].bindKey = { win: 'Shift-F2', mac: 'Shift-F2', }; config.loadModule( 'ace/ext/menu_tools/get_editor_keyboard_shortcuts', (m) => { this.keyBindings = m.getEditorKeybordShortcuts(e); }, ); config.loadModule('ace/ext/keybinding_menu', (m) => m.init(e), ); }} onFocus={(_, e): void => { onSessionChanged(e?.session); }} onChange={(v): void => { localStorage.setItem('program', v); }} commands={[ { // command to check current program for errors name: 'check', bindKey: { win: 'F2', mac: 'F2' }, exec: (editor) => onCheck(editor.getValue()), }, { name: 'save', bindKey: { win: 'Ctrl-S', mac: 'Cmd-S' }, exec: (): void => { // We already automatically save the file // to local storage after every change, so // CTRL+S is ignored console.debug('CTRL+S ignored'); }, }, ]} />
); } renderContextMenu(): JSX.Element { const { i18n } = this.props; return ( { const selected = this.editor?.getSelectedText(); if (selected) { navigator.clipboard.writeText(selected); } }} text={i18n.translate(EditorStringId.Copy)} icon="duplicate" label={isMacOS() ? 'Cmd-C' : 'Ctrl-C'} disabled={this.editor?.getSelection().isEmpty()} /> => { this.editor?.execCommand( 'paste', await navigator.clipboard.readText(), ); }} text={i18n.translate(EditorStringId.Paste)} icon="clipboard" label={isMacOS() ? 'Cmd-V' : 'Ctrl-V'} /> this.editor?.selectAll()} text={i18n.translate(EditorStringId.SelectAll)} icon="blank" label={isMacOS() ? 'Cmd-A' : 'Ctrl-A'} /> this.editor?.undo()} text={i18n.translate(EditorStringId.Undo)} icon="undo" label={this.keyBindings?.find((x) => x.command === 'undo')?.key} disabled={!this.editor?.session.getUndoManager().canUndo()} /> this.editor?.redo()} text={i18n.translate(EditorStringId.Redo)} icon="redo" label={this.keyBindings?.find((x) => x.command === 'redo')?.key} active disabled={!this.editor?.session.getUndoManager().canRedo()} /> ); } } const mapStateToProps = (state: RootState): StateProps => ({ darkMode: state.settings.darkMode, }); const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => ({ onSessionChanged: (s): Action => dispatch(setEditSession(s)), onProgramStorageChanged: (v): Action => dispatch(storageChanged(v)), // 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) => dispatch(compile(script)), }); export default connect( mapStateToProps, mapDispatchToProps, )(withI18n({ id: 'editor', fallback: en, translations: { en } })(Editor));