mirror of
https://github.com/pybricks/pybricks-code.git
synced 2026-09-13 18:14:09 +00:00
525 lines
16 KiB
TypeScript
525 lines
16 KiB
TypeScript
// SPDX-License-Identifier: MIT
|
|
// Copyright (c) 2020-2022 The Pybricks Authors
|
|
|
|
import './editor.scss';
|
|
import {
|
|
Button,
|
|
Classes,
|
|
IOverlayLifecycleProps,
|
|
IconName,
|
|
Menu,
|
|
MenuDivider,
|
|
MenuItem,
|
|
Tab,
|
|
TabId,
|
|
Tabs,
|
|
Text,
|
|
} from '@blueprintjs/core';
|
|
import { ContextMenu2, ResizeSensor2 } from '@blueprintjs/popover2';
|
|
import classNames from 'classnames';
|
|
import * as monaco from 'monaco-editor';
|
|
import tomorrowNightEightiesTheme from 'monaco-themes/themes/Tomorrow-Night-Eighties.json';
|
|
import xcodeTheme from 'monaco-themes/themes/Xcode_default.json';
|
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import { useId } from 'react-aria';
|
|
import { useDispatch } from 'react-redux';
|
|
import { useEffectOnce, useTernaryDarkMode } from 'usehooks-ts';
|
|
import { UUID } from '../fileStorage';
|
|
import { useFileStoragePath } from '../fileStorage/hooks';
|
|
import { compile } from '../mpy/actions';
|
|
import { useSelector } from '../reducers';
|
|
import { useSettingIsShowDocsEnabled } from '../settings/hooks';
|
|
import { isMacOS } from '../utils/os';
|
|
import Welcome from './Welcome';
|
|
import { editorActivateFile, editorCloseFile } from './actions';
|
|
import { useI18n } from './i18n';
|
|
import * as pybricksMicroPython from './pybricksMicroPython';
|
|
import { pybricksMicroPythonId } from './pybricksMicroPython';
|
|
import { UntitledHintContribution } from './untitledHint';
|
|
|
|
monaco.languages.register({ id: pybricksMicroPythonId });
|
|
|
|
const toDispose = new Array<monaco.IDisposable>();
|
|
toDispose.push(
|
|
monaco.languages.setLanguageConfiguration(
|
|
pybricksMicroPythonId,
|
|
pybricksMicroPython.conf,
|
|
),
|
|
monaco.languages.setMonarchTokensProvider(
|
|
pybricksMicroPythonId,
|
|
pybricksMicroPython.language,
|
|
),
|
|
monaco.languages.registerCompletionItemProvider(
|
|
pybricksMicroPythonId,
|
|
pybricksMicroPython.templateSnippetCompletions,
|
|
),
|
|
);
|
|
|
|
// https://webpack.js.org/api/hot-module-replacement/
|
|
// istanbul ignore if: only used for development
|
|
if (module.hot) {
|
|
module.hot.dispose(() => {
|
|
toDispose.forEach((s) => s.dispose());
|
|
});
|
|
}
|
|
|
|
const tomorrowNightEightiesId = 'tomorrow-night-eighties';
|
|
monaco.editor.defineTheme(
|
|
tomorrowNightEightiesId,
|
|
tomorrowNightEightiesTheme as monaco.editor.IStandaloneThemeData,
|
|
);
|
|
|
|
const xcodeId = 'xcode';
|
|
monaco.editor.defineTheme(xcodeId, xcodeTheme as monaco.editor.IStandaloneThemeData);
|
|
|
|
type EditorContextMenuItemProps = Readonly<{
|
|
/** The menu item label. */
|
|
label: string;
|
|
/** The menu item icon. */
|
|
icon: IconName;
|
|
/** The keyboard shortcut that triggers the same action. */
|
|
keyboardShortcut: string;
|
|
/** Controls the menu item disabled state. */
|
|
disabled: boolean;
|
|
/** A reference to the editor. */
|
|
editor: monaco.editor.IStandaloneCodeEditor | undefined;
|
|
/** The action handler ID passed to the editor.trigger() method. */
|
|
editorAction: string;
|
|
}>;
|
|
|
|
const EditorContextMenuItem: React.VoidFunctionComponent<
|
|
EditorContextMenuItemProps
|
|
> = ({ label, icon, keyboardShortcut, disabled, editor, editorAction }) => {
|
|
const labelId = useId();
|
|
|
|
return (
|
|
<MenuItem
|
|
role="menuitem"
|
|
aria-labelledby={labelId}
|
|
text={<span id={labelId}>{label}</span>}
|
|
icon={icon}
|
|
label={keyboardShortcut}
|
|
disabled={disabled}
|
|
onClick={() => {
|
|
// have to focus first or the trigger won't work
|
|
editor?.focus();
|
|
editor?.trigger(null, editorAction, null);
|
|
}}
|
|
/>
|
|
);
|
|
};
|
|
|
|
type EditorContextMenuProps = {
|
|
/** The editor. */
|
|
editor: monaco.editor.IStandaloneCodeEditor | undefined;
|
|
};
|
|
|
|
const EditorContextMenu: React.VoidFunctionComponent<EditorContextMenuProps> = ({
|
|
editor,
|
|
}) => {
|
|
const i18n = useI18n();
|
|
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 aria-label={i18n.translate('contextMenu.label')} role="menu">
|
|
<EditorContextMenuItem
|
|
label={i18n.translate('copy')}
|
|
icon="duplicate"
|
|
keyboardShortcut={isMacOS() ? 'Cmd-C' : 'Ctrl-C'}
|
|
disabled={!hasSelection}
|
|
editor={editor}
|
|
editorAction="editor.action.clipboardCopyAction"
|
|
/>
|
|
<EditorContextMenuItem
|
|
label={i18n.translate('paste')}
|
|
icon="clipboard"
|
|
keyboardShortcut={isMacOS() ? 'Cmd-V' : 'Ctrl-V'}
|
|
disabled={!model}
|
|
editor={editor}
|
|
editorAction="editor.action.clipboardPasteAction"
|
|
/>
|
|
<EditorContextMenuItem
|
|
label={i18n.translate('selectAll')}
|
|
icon="blank"
|
|
keyboardShortcut={isMacOS() ? 'Cmd-A' : 'Ctrl-A'}
|
|
disabled={!model}
|
|
editor={editor}
|
|
editorAction="editor.action.selectAll"
|
|
/>
|
|
<MenuDivider />
|
|
<EditorContextMenuItem
|
|
label={i18n.translate('undo')}
|
|
icon="undo"
|
|
keyboardShortcut={isMacOS() ? 'Cmd-Z' : 'Ctrl-Z'}
|
|
disabled={!canUndo}
|
|
editor={editor}
|
|
editorAction="undo"
|
|
/>
|
|
<EditorContextMenuItem
|
|
label={i18n.translate('redo')}
|
|
icon="redo"
|
|
keyboardShortcut={isMacOS() ? 'Cmd-Shift-Z' : 'Ctrl-Shift-Z'}
|
|
disabled={!canRedo}
|
|
editor={editor}
|
|
editorAction="redo"
|
|
/>
|
|
</Menu>
|
|
);
|
|
};
|
|
|
|
type FileNameProps = {
|
|
/** The DOM ID. */
|
|
id: string;
|
|
/** The file UUID. */
|
|
uuid: UUID;
|
|
/** Called when the file name changes. */
|
|
onNameChanged: () => void;
|
|
};
|
|
|
|
const TabLabel: React.VoidFunctionComponent<FileNameProps> = ({
|
|
id,
|
|
uuid,
|
|
onNameChanged,
|
|
}) => {
|
|
const fileName = useFileStoragePath(uuid);
|
|
|
|
useEffect(() => {
|
|
onNameChanged?.();
|
|
}, [fileName, onNameChanged]);
|
|
|
|
return (
|
|
<Text tagName="span" id={id} ellipsize={true}>
|
|
{fileName}
|
|
</Text>
|
|
);
|
|
};
|
|
|
|
type TabCloseButtonProps = {
|
|
/** The file UUID. */
|
|
uuid: UUID;
|
|
};
|
|
|
|
const TabCloseButton: React.VoidFunctionComponent<TabCloseButtonProps> = ({ uuid }) => {
|
|
const fileName = useFileStoragePath(uuid) ?? '';
|
|
const dispatch = useDispatch();
|
|
const i18n = useI18n();
|
|
|
|
return (
|
|
<Button
|
|
title={i18n.translate('closeFile.tooltip', {
|
|
fileName,
|
|
})}
|
|
minimal={true}
|
|
small={true}
|
|
icon="cross"
|
|
// tabs are closed with delete button by keyboard, so
|
|
// don't focus the close button
|
|
tabIndex={-1}
|
|
onFocus={(e) => e.preventDefault()}
|
|
onClick={(e) => {
|
|
dispatch(editorCloseFile(uuid));
|
|
// prevent triggering Tabs onChange
|
|
e.stopPropagation();
|
|
}}
|
|
/>
|
|
);
|
|
};
|
|
|
|
type EditorTabsProps = Readonly<{
|
|
/** Called when the selected tab changes. */
|
|
onChange?: () => void;
|
|
}>;
|
|
|
|
const EditorTabs: React.VoidFunctionComponent<EditorTabsProps> = ({ onChange }) => {
|
|
const openFiles = useSelector((s) => s.editor.openFileUuids);
|
|
const activeFile = useSelector((s) => s.editor.activeFileUuid);
|
|
const dispatch = useDispatch();
|
|
|
|
const handleChange = useCallback(
|
|
(newTabId: TabId) => {
|
|
dispatch(editorActivateFile(newTabId as UUID));
|
|
onChange?.();
|
|
},
|
|
[dispatch, onChange],
|
|
);
|
|
|
|
const labelId = useId();
|
|
|
|
// close tab when delete key is pressed
|
|
const handleKeyDown = useCallback(
|
|
(e: React.KeyboardEvent, uuid: UUID) => {
|
|
if (e.key === 'Delete') {
|
|
dispatch(editorCloseFile(uuid));
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}
|
|
},
|
|
[dispatch],
|
|
);
|
|
|
|
// close tab when middle-clicked
|
|
const handleMouseDown = useCallback(
|
|
(e: React.MouseEvent, uuid: UUID) => {
|
|
if (e.button === 1) {
|
|
dispatch(editorCloseFile(uuid));
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}
|
|
},
|
|
[dispatch],
|
|
);
|
|
|
|
const tabsRef = useRef<Tabs>(null);
|
|
|
|
// HACK: call private Tabs method to fix selection indicator animation when
|
|
// a file is renamed
|
|
const handleNameChanged = useCallback(() => {
|
|
tabsRef.current?.['moveSelectionIndicator']();
|
|
}, [tabsRef]);
|
|
|
|
const i18n = useI18n();
|
|
|
|
useEffect(() => {
|
|
// @ts-expect-error: using private property
|
|
const tablist: HTMLDivElement = tabsRef.current?.tablistElement;
|
|
|
|
// istanbul-ignore-if: should not happen
|
|
if (!tablist) {
|
|
return;
|
|
}
|
|
|
|
tablist.setAttribute('aria-label', i18n.translate('tablist.label'));
|
|
}, [i18n]);
|
|
|
|
return (
|
|
<Tabs
|
|
className="pb-editor-tablist"
|
|
selectedTabId={activeFile || undefined}
|
|
ref={tabsRef}
|
|
onChange={handleChange}
|
|
>
|
|
{openFiles.map((uuid) => (
|
|
<Tab
|
|
className="pb-editor-tablist-tab"
|
|
aria-labelledby={`${labelId}.${uuid}`}
|
|
key={uuid}
|
|
id={uuid}
|
|
onKeyDown={(e) => handleKeyDown(e, uuid)}
|
|
onMouseDown={(e) => handleMouseDown(e, uuid)}
|
|
>
|
|
<TabLabel
|
|
id={`${labelId}.${uuid}`}
|
|
uuid={uuid}
|
|
onNameChanged={handleNameChanged}
|
|
/>
|
|
<TabCloseButton uuid={uuid} />
|
|
</Tab>
|
|
))}
|
|
</Tabs>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Wrapper around useEffect() hook that uses {@link maybeEditor}.
|
|
* @param maybeEditor The editor or undefined if the editor is not mounted.
|
|
* @param callback The callback to call when editor is defined and when {@link deps} change.
|
|
* @param deps Additional dependencies used in the {@link callback}.
|
|
*/
|
|
function useEditor(
|
|
maybeEditor: monaco.editor.IStandaloneCodeEditor | undefined,
|
|
callback: (
|
|
editor: monaco.editor.IStandaloneCodeEditor,
|
|
) => ReturnType<React.EffectCallback>,
|
|
deps: React.DependencyList,
|
|
): void {
|
|
useEffect(() => {
|
|
if (!maybeEditor) {
|
|
return;
|
|
}
|
|
|
|
return callback(maybeEditor);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [maybeEditor, callback, ...deps]);
|
|
}
|
|
|
|
/**
|
|
* Hook for adding actions to the editor.
|
|
* @param maybeEditor The editor or undefined if the editor is not mounted.
|
|
* @param createAction A callback to create a new action.
|
|
* @param deps Additional dependencies used in {@link createAction}.
|
|
*/
|
|
function useEditorAction(
|
|
maybeEditor: monaco.editor.IStandaloneCodeEditor | undefined,
|
|
createAction: () => monaco.editor.IActionDescriptor,
|
|
deps: React.DependencyList,
|
|
): void {
|
|
useEditor(
|
|
maybeEditor,
|
|
(editor) => {
|
|
const subscription = editor.addAction(createAction());
|
|
return () => subscription.dispose();
|
|
},
|
|
deps,
|
|
);
|
|
}
|
|
|
|
const Editor: React.VFC = () => {
|
|
const dispatch = useDispatch();
|
|
|
|
const [editor, setEditor] = useState<monaco.editor.IStandaloneCodeEditor>();
|
|
const { isSettingShowDocsEnabled, toggleIsSettingShowDocsEnabled } =
|
|
useSettingIsShowDocsEnabled();
|
|
const { isDarkMode } = useTernaryDarkMode();
|
|
|
|
const i18n = useI18n();
|
|
|
|
useEffect(() => {
|
|
monaco.editor.setTheme(isDarkMode ? tomorrowNightEightiesId : xcodeId);
|
|
}, [isDarkMode]);
|
|
|
|
useEditor(
|
|
editor,
|
|
(editor) => {
|
|
const contrib = new UntitledHintContribution(
|
|
editor,
|
|
i18n.translate('placeholder'),
|
|
);
|
|
return () => contrib.dispose();
|
|
},
|
|
[i18n],
|
|
);
|
|
|
|
useEditorAction(
|
|
editor,
|
|
() => ({
|
|
id: 'pybricks.action.toggleDocs',
|
|
label: i18n.translate('toggleDocs'),
|
|
run: () => toggleIsSettingShowDocsEnabled(),
|
|
keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyD],
|
|
}),
|
|
[i18n, toggleIsSettingShowDocsEnabled],
|
|
);
|
|
|
|
useEditorAction(
|
|
editor,
|
|
() => ({
|
|
id: 'pybricks.action.check',
|
|
label: i18n.translate('check'),
|
|
run: (e) => {
|
|
// for checking, use the most recent compiler
|
|
dispatch(compile(e.getValue(), 6, []));
|
|
},
|
|
keybindings: [monaco.KeyCode.F2],
|
|
}),
|
|
[i18n, dispatch],
|
|
);
|
|
|
|
useEditorAction(
|
|
editor,
|
|
() => ({
|
|
id: 'pybricks.action.save',
|
|
label: 'Unused',
|
|
run: () => {
|
|
// We already automatically save the file after every change,
|
|
// so CTRL+S is ignored
|
|
console.debug('Ctrl-S ignored');
|
|
},
|
|
keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS],
|
|
}),
|
|
[],
|
|
);
|
|
|
|
const popoverProps = useMemo<IOverlayLifecycleProps>(
|
|
() => ({
|
|
onOpened: (e) => {
|
|
// a11y: focus the first item in the menu when the menu opens
|
|
const menuItems = e.getElementsByClassName(Classes.MENU_ITEM);
|
|
|
|
const firstItem = menuItems.item(0);
|
|
|
|
// istanbul ignore if: should not be reachable
|
|
if (!(firstItem instanceof HTMLElement)) {
|
|
console.error(`bug: firstItem is not an HTMLElement: ${firstItem}`);
|
|
return;
|
|
}
|
|
|
|
firstItem.focus();
|
|
},
|
|
onClosed: () => editor?.focus(),
|
|
}),
|
|
[editor],
|
|
);
|
|
|
|
const editorRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffectOnce(() => {
|
|
// istanbul ignore if: should never happen
|
|
if (!editorRef.current) {
|
|
console.error('no editorRef!');
|
|
return;
|
|
}
|
|
|
|
const monacoEditor = monaco.editor.create(editorRef.current, {
|
|
model: null,
|
|
fontSize: 18,
|
|
minimap: { enabled: false },
|
|
contextmenu: false,
|
|
rulers: [80],
|
|
lineNumbersMinChars: 4,
|
|
wordBasedSuggestions: false,
|
|
});
|
|
|
|
monacoEditor.focus();
|
|
setEditor(monacoEditor);
|
|
|
|
return () => {
|
|
setEditor(undefined);
|
|
monacoEditor.dispose();
|
|
};
|
|
});
|
|
|
|
const isEmpty = useSelector((s) => s.editor.openFileUuids.length === 0);
|
|
const { activeFileUuid } = useSelector((s) => s.editor);
|
|
const fileName = useFileStoragePath(activeFileUuid ?? ('' as UUID));
|
|
|
|
return (
|
|
<div className="pb-editor">
|
|
<EditorTabs onChange={() => editor?.focus()} />
|
|
<ResizeSensor2 onResize={() => editor?.layout()}>
|
|
<ContextMenu2
|
|
className={classNames('pb-editor-tabpanel', isEmpty && 'pb-empty')}
|
|
role="tabpanel"
|
|
aria-label={isEmpty ? i18n.translate('welcome') : fileName}
|
|
// 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={popoverProps}
|
|
>
|
|
<Welcome isVisible={isEmpty} />
|
|
<div className="pb-editor-monaco" ref={editorRef} />
|
|
</ContextMenu2>
|
|
</ResizeSensor2>
|
|
<Button
|
|
className="pb-editor-doc-button"
|
|
minimal
|
|
large
|
|
icon="manual"
|
|
title={
|
|
isSettingShowDocsEnabled
|
|
? i18n.translate('docs.hide')
|
|
: i18n.translate('docs.show')
|
|
}
|
|
onClick={toggleIsSettingShowDocsEnabled}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Editor;
|