|
1 |
| -import React from 'react'; |
2 |
| -import { CommonEditorProps } from '../types'; |
3 |
| -import MonacoEditor, { EditorDidMount, EditorWillMount } from 'react-monaco-editor'; |
| 1 | +import * as monaco from 'monaco-editor'; |
| 2 | +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; |
| 3 | + |
4 | 4 | import { useAppSelector } from '../hooks';
|
5 |
| -import { config, grammar, themeVsDarkPlus } from './rust_monaco_def'; |
| 5 | +import { offerCrateAutocompleteOnUse } from '../selectors'; |
| 6 | +import { CommonEditorProps } from '../types'; |
| 7 | +import { themeVsDarkPlus } from './rust_monaco_def'; |
6 | 8 |
|
7 | 9 | import * as styles from './Editor.module.css';
|
8 | 10 |
|
9 |
| -const MODE_ID = 'rust'; |
| 11 | +function useEditorProp<T>( |
| 12 | + editor: monaco.editor.IStandaloneCodeEditor | null, |
| 13 | + prop: T, |
| 14 | + whenPresent: ( |
| 15 | + editor: monaco.editor.IStandaloneCodeEditor, |
| 16 | + model: monaco.editor.ITextModel, |
| 17 | + prop: T, |
| 18 | + ) => void | (() => void), |
| 19 | +) { |
| 20 | + useEffect(() => { |
| 21 | + if (!editor) { |
| 22 | + return; |
| 23 | + } |
10 | 24 |
|
11 |
| -const initMonaco: EditorWillMount = (monaco) => { |
12 |
| - monaco.editor.defineTheme('vscode-dark-plus', themeVsDarkPlus); |
13 |
| - monaco.languages.register({ |
14 |
| - id: MODE_ID, |
15 |
| - }); |
| 25 | + const model = editor.getModel(); |
| 26 | + if (!model) { |
| 27 | + return; |
| 28 | + } |
| 29 | + |
| 30 | + return whenPresent(editor, model, prop); |
| 31 | + }, [editor, prop, whenPresent]); |
| 32 | +} |
| 33 | + |
| 34 | +const MonacoEditorCore: React.FC<CommonEditorProps> = (props) => { |
| 35 | + const [editor, setEditor] = useState<monaco.editor.IStandaloneCodeEditor | null>(null); |
| 36 | + const theme = useAppSelector((s) => s.configuration.monaco.theme); |
| 37 | + const completionProvider = useRef<monaco.IDisposable | null>(null); |
| 38 | + const autocompleteOnUse = useAppSelector(offerCrateAutocompleteOnUse); |
| 39 | + |
| 40 | + // Replace `initialCode` and `initialTheme` with an "effect event" |
| 41 | + // when those stabilize. |
| 42 | + // |
| 43 | + // https://react.dev/learn/separating-events-from-effects#declaring-an-effect-event |
| 44 | + const initialCode = useRef(props.code); |
| 45 | + const initialTheme = useRef(theme); |
16 | 46 |
|
17 |
| - monaco.languages.onLanguage(MODE_ID, async () => { |
18 |
| - monaco.languages.setLanguageConfiguration(MODE_ID, config); |
19 |
| - monaco.languages.setMonarchTokensProvider(MODE_ID, grammar); |
| 47 | + // One-time setup |
| 48 | + useEffect(() => { |
| 49 | + monaco.editor.defineTheme('vscode-dark-plus', themeVsDarkPlus); |
| 50 | + }, []); |
| 51 | + |
| 52 | + // Construct the editor |
| 53 | + const child = useCallback((node: HTMLDivElement | null) => { |
| 54 | + if (!node) { |
| 55 | + return; |
| 56 | + } |
| 57 | + |
| 58 | + const nodeStyle = window.getComputedStyle(node); |
| 59 | + |
| 60 | + const editor = monaco.editor.create(node, { |
| 61 | + language: 'rust', |
| 62 | + value: initialCode.current, |
| 63 | + theme: initialTheme.current, |
| 64 | + fontSize: parseInt(nodeStyle.fontSize, 10), |
| 65 | + fontFamily: nodeStyle.fontFamily, |
| 66 | + automaticLayout: true, |
| 67 | + 'semanticHighlighting.enabled': true, |
| 68 | + }); |
| 69 | + setEditor(editor); |
| 70 | + |
| 71 | + editor.focus(); |
| 72 | + }, []); |
| 73 | + |
| 74 | + useEditorProp(editor, props.onEditCode, (_editor, model, onEditCode) => { |
| 75 | + model.onDidChangeContent(() => { |
| 76 | + onEditCode(model.getValue()); |
| 77 | + }); |
20 | 78 | });
|
21 |
| -}; |
22 | 79 |
|
23 |
| -const initEditor = (execute: () => any): EditorDidMount => (editor, monaco) => { |
24 |
| - editor.focus(); |
25 |
| - editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter, () => { |
26 |
| - execute(); |
| 80 | + useEditorProp(editor, props.execute, (editor, _model, execute) => { |
| 81 | + editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter, () => { |
| 82 | + execute(); |
| 83 | + }); |
| 84 | + // Ace's Vim mode runs code with :w, so let's do the same |
| 85 | + editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => { |
| 86 | + execute(); |
| 87 | + }); |
27 | 88 | });
|
28 |
| - // Ace's Vim mode runs code with :w, so let's do the same |
29 |
| - editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => { |
30 |
| - execute(); |
| 89 | + |
| 90 | + useEditorProp(editor, props.code, (editor, model, code) => { |
| 91 | + // Short-circuit if nothing interesting to change. |
| 92 | + if (code === model.getValue()) { |
| 93 | + return; |
| 94 | + } |
| 95 | + |
| 96 | + editor.executeEdits('redux', [ |
| 97 | + { |
| 98 | + text: code, |
| 99 | + range: model.getFullModelRange(), |
| 100 | + }, |
| 101 | + ]); |
31 | 102 | });
|
32 |
| -}; |
33 | 103 |
|
34 |
| -const MonacoEditorCore: React.FC<CommonEditorProps> = props => { |
35 |
| - const theme = useAppSelector((s) => s.configuration.monaco.theme); |
| 104 | + useEditorProp(editor, theme, (editor, _model, theme) => { |
| 105 | + editor.updateOptions({ theme }); |
| 106 | + }); |
36 | 107 |
|
37 |
| - return ( |
38 |
| - <MonacoEditor |
39 |
| - language={MODE_ID} |
40 |
| - theme={theme} |
41 |
| - className={styles.monaco} |
42 |
| - value={props.code} |
43 |
| - onChange={props.onEditCode} |
44 |
| - editorWillMount={initMonaco} |
45 |
| - editorDidMount={initEditor(props.execute)} |
46 |
| - options={{ |
47 |
| - automaticLayout: true, |
48 |
| - 'semanticHighlighting.enabled': true, |
49 |
| - }} |
50 |
| - /> |
| 108 | + const autocompleteProps = useMemo( |
| 109 | + () => ({ autocompleteOnUse, crates: props.crates }), |
| 110 | + [autocompleteOnUse, props.crates], |
51 | 111 | );
|
52 |
| -} |
| 112 | + |
| 113 | + useEditorProp(editor, autocompleteProps, (_editor, _model, { autocompleteOnUse, crates }) => { |
| 114 | + completionProvider.current = monaco.languages.registerCompletionItemProvider('rust', { |
| 115 | + triggerCharacters: [' '], |
| 116 | + |
| 117 | + provideCompletionItems(model, position, _context, _token) { |
| 118 | + const word = model.getWordUntilPosition(position); |
| 119 | + |
| 120 | + function wordBefore( |
| 121 | + word: monaco.editor.IWordAtPosition, |
| 122 | + ): monaco.editor.IWordAtPosition | null { |
| 123 | + const prevPos = { lineNumber: position.lineNumber, column: word.startColumn - 1 }; |
| 124 | + return model.getWordAtPosition(prevPos); |
| 125 | + } |
| 126 | + |
| 127 | + const preWord = wordBefore(word); |
| 128 | + const prePreWord = preWord && wordBefore(preWord); |
| 129 | + |
| 130 | + const oldStyle = prePreWord?.word === 'extern' && preWord?.word === 'crate'; |
| 131 | + const newStyle = autocompleteOnUse && preWord?.word === 'use'; |
| 132 | + |
| 133 | + const triggerPrefix = oldStyle || newStyle; |
| 134 | + |
| 135 | + if (!triggerPrefix) { |
| 136 | + return { suggestions: [] }; |
| 137 | + } |
| 138 | + |
| 139 | + const range = { |
| 140 | + startLineNumber: position.lineNumber, |
| 141 | + endLineNumber: position.lineNumber, |
| 142 | + startColumn: word.startColumn, |
| 143 | + endColumn: word.endColumn, |
| 144 | + }; |
| 145 | + |
| 146 | + const suggestions = crates.map(({ name, version, id }) => ({ |
| 147 | + kind: monaco.languages.CompletionItemKind.Module, |
| 148 | + label: `${name} (${version})`, |
| 149 | + insertText: `${id}; // ${version}`, |
| 150 | + range, |
| 151 | + })); |
| 152 | + |
| 153 | + return { suggestions }; |
| 154 | + }, |
| 155 | + }); |
| 156 | + |
| 157 | + return () => { |
| 158 | + completionProvider.current?.dispose(); |
| 159 | + }; |
| 160 | + }); |
| 161 | + |
| 162 | + return <div className={styles.monaco} ref={child} />; |
| 163 | +}; |
53 | 164 |
|
54 | 165 | export default MonacoEditorCore;
|
0 commit comments