diff options
Diffstat (limited to 'language-web/src/main/js/editor/EditorStore.ts')
-rw-r--r-- | language-web/src/main/js/editor/EditorStore.ts | 358 |
1 files changed, 220 insertions, 138 deletions
diff --git a/language-web/src/main/js/editor/EditorStore.ts b/language-web/src/main/js/editor/EditorStore.ts index 705020b9..ba31efcb 100644 --- a/language-web/src/main/js/editor/EditorStore.ts +++ b/language-web/src/main/js/editor/EditorStore.ts | |||
@@ -1,201 +1,283 @@ | |||
1 | import type { Editor, EditorConfiguration } from 'codemirror'; | 1 | import { autocompletion, completionKeymap } from '@codemirror/autocomplete'; |
2 | import { closeBrackets, closeBracketsKeymap } from '@codemirror/closebrackets'; | ||
3 | import { defaultKeymap, indentWithTab } from '@codemirror/commands'; | ||
4 | import { commentKeymap } from '@codemirror/comment'; | ||
5 | import { foldGutter, foldKeymap } from '@codemirror/fold'; | ||
6 | import { highlightActiveLineGutter, lineNumbers } from '@codemirror/gutter'; | ||
7 | import { classHighlightStyle } from '@codemirror/highlight'; | ||
8 | import { | ||
9 | history, | ||
10 | historyKeymap, | ||
11 | redo, | ||
12 | redoDepth, | ||
13 | undo, | ||
14 | undoDepth, | ||
15 | } from '@codemirror/history'; | ||
16 | import { indentOnInput } from '@codemirror/language'; | ||
17 | import { | ||
18 | Diagnostic, | ||
19 | lintKeymap, | ||
20 | setDiagnostics, | ||
21 | } from '@codemirror/lint'; | ||
22 | import { bracketMatching } from '@codemirror/matchbrackets'; | ||
23 | import { rectangularSelection } from '@codemirror/rectangular-selection'; | ||
24 | import { searchConfig, searchKeymap } from '@codemirror/search'; | ||
25 | import { | ||
26 | EditorState, | ||
27 | StateCommand, | ||
28 | StateEffect, | ||
29 | Transaction, | ||
30 | TransactionSpec, | ||
31 | } from '@codemirror/state'; | ||
32 | import { | ||
33 | drawSelection, | ||
34 | EditorView, | ||
35 | highlightActiveLine, | ||
36 | highlightSpecialChars, | ||
37 | keymap, | ||
38 | } from '@codemirror/view'; | ||
2 | import { | 39 | import { |
3 | createAtom, | ||
4 | makeAutoObservable, | 40 | makeAutoObservable, |
5 | observable, | 41 | observable, |
6 | runInAction, | 42 | reaction, |
7 | } from 'mobx'; | 43 | } from 'mobx'; |
8 | import type { IXtextOptions, IXtextServices } from 'xtext/xtext-codemirror'; | ||
9 | 44 | ||
10 | import type { IEditorChunk } from './editor'; | 45 | import { findOccurrences, IOccurrence, setOccurrences } from './findOccurrences'; |
11 | import { getLogger } from '../logging'; | 46 | import { problemLanguageSupport } from '../language/problemLanguageSupport'; |
47 | import { | ||
48 | IHighlightRange, | ||
49 | semanticHighlighting, | ||
50 | setSemanticHighlighting, | ||
51 | } from './semanticHighlighting'; | ||
12 | import type { ThemeStore } from '../theme/ThemeStore'; | 52 | import type { ThemeStore } from '../theme/ThemeStore'; |
53 | import { getLogger } from '../utils/logger'; | ||
54 | import { XtextClient } from '../xtext/XtextClient'; | ||
13 | 55 | ||
14 | const log = getLogger('EditorStore'); | 56 | const log = getLogger('editor.EditorStore'); |
15 | 57 | ||
16 | const xtextLang = 'problem'; | 58 | export class EditorStore { |
59 | private readonly themeStore; | ||
17 | 60 | ||
18 | const xtextOptions: IXtextOptions = { | 61 | state: EditorState; |
19 | xtextLang, | ||
20 | enableFormattingAction: true, | ||
21 | }; | ||
22 | 62 | ||
23 | const codeMirrorGlobalOptions: EditorConfiguration = { | 63 | private readonly client: XtextClient; |
24 | mode: `xtext/${xtextLang}`, | ||
25 | indentUnit: 2, | ||
26 | styleActiveLine: true, | ||
27 | screenReaderLabel: 'Model source code', | ||
28 | inputStyle: 'contenteditable', | ||
29 | }; | ||
30 | 64 | ||
31 | export class EditorStore { | 65 | showLineNumbers = false; |
32 | themeStore; | ||
33 | 66 | ||
34 | atom; | 67 | showSearchPanel = false; |
35 | 68 | ||
36 | chunk?: IEditorChunk; | 69 | showLintPanel = false; |
37 | 70 | ||
38 | editor?: Editor; | 71 | errorCount = 0; |
39 | 72 | ||
40 | xtextServices?: IXtextServices; | 73 | warningCount = 0; |
41 | 74 | ||
42 | value = ''; | 75 | infoCount = 0; |
43 | 76 | ||
44 | showLineNumbers = false; | 77 | private readonly defaultDispatcher = (tr: Transaction): void => { |
78 | this.onTransaction(tr); | ||
79 | }; | ||
45 | 80 | ||
46 | initialSelection!: { start: number, end: number, focused: boolean }; | 81 | private dispatcher = this.defaultDispatcher; |
47 | 82 | ||
48 | constructor(themeStore: ThemeStore) { | 83 | constructor(initialValue: string, themeStore: ThemeStore) { |
49 | this.themeStore = themeStore; | 84 | this.themeStore = themeStore; |
50 | this.atom = createAtom('EditorStore'); | 85 | this.state = EditorState.create({ |
51 | this.resetInitialSelection(); | 86 | doc: initialValue, |
52 | makeAutoObservable(this, { | 87 | extensions: [ |
53 | themeStore: false, | 88 | autocompletion({ |
54 | atom: false, | 89 | activateOnTyping: true, |
55 | chunk: observable.ref, | 90 | override: [ |
56 | editor: observable.ref, | 91 | (context) => this.client.contentAssist(context), |
57 | xtextServices: observable.ref, | 92 | ], |
58 | initialSelection: false, | 93 | }), |
94 | classHighlightStyle.extension, | ||
95 | closeBrackets(), | ||
96 | bracketMatching(), | ||
97 | drawSelection(), | ||
98 | EditorState.allowMultipleSelections.of(true), | ||
99 | EditorView.theme({}, { | ||
100 | dark: this.themeStore.darkMode, | ||
101 | }), | ||
102 | findOccurrences, | ||
103 | highlightActiveLine(), | ||
104 | highlightActiveLineGutter(), | ||
105 | highlightSpecialChars(), | ||
106 | history(), | ||
107 | indentOnInput(), | ||
108 | rectangularSelection(), | ||
109 | searchConfig({ | ||
110 | top: true, | ||
111 | matchCase: true, | ||
112 | }), | ||
113 | semanticHighlighting, | ||
114 | // We add the gutters to `extensions` in the order we want them to appear. | ||
115 | foldGutter(), | ||
116 | lineNumbers(), | ||
117 | keymap.of([ | ||
118 | ...closeBracketsKeymap, | ||
119 | ...commentKeymap, | ||
120 | ...completionKeymap, | ||
121 | ...foldKeymap, | ||
122 | ...historyKeymap, | ||
123 | indentWithTab, | ||
124 | // Override keys in `lintKeymap` to go through the `EditorStore`. | ||
125 | { key: 'Mod-Shift-m', run: () => this.setLintPanelOpen(true) }, | ||
126 | ...lintKeymap, | ||
127 | // Override keys in `searchKeymap` to go through the `EditorStore`. | ||
128 | { key: 'Mod-f', run: () => this.setSearchPanelOpen(true), scope: 'editor search-panel' }, | ||
129 | { key: 'Escape', run: () => this.setSearchPanelOpen(false), scope: 'editor search-panel' }, | ||
130 | ...searchKeymap, | ||
131 | ...defaultKeymap, | ||
132 | ]), | ||
133 | problemLanguageSupport(), | ||
134 | ], | ||
59 | }); | 135 | }); |
60 | this.loadChunk(); | 136 | this.client = new XtextClient(this); |
61 | } | 137 | reaction( |
62 | 138 | () => this.themeStore.darkMode, | |
63 | private loadChunk(): void { | 139 | (darkMode) => { |
64 | const loadingStartMillis = Date.now(); | 140 | log.debug('Update editor dark mode', darkMode); |
65 | log.info('Requesting editor chunk'); | 141 | this.dispatch({ |
66 | import('./editor').then(({ editorChunk }) => { | 142 | effects: [ |
67 | runInAction(() => { | 143 | StateEffect.appendConfig.of(EditorView.theme({}, { |
68 | this.chunk = editorChunk; | 144 | dark: darkMode, |
69 | }); | 145 | })), |
70 | const loadingDurationMillis = Date.now() - loadingStartMillis; | 146 | ], |
71 | log.info('Loaded editor chunk in', loadingDurationMillis, 'ms'); | 147 | }); |
72 | }).catch((error) => { | 148 | }, |
73 | log.error('Error while loading editor', error); | 149 | ); |
150 | makeAutoObservable(this, { | ||
151 | state: observable.ref, | ||
74 | }); | 152 | }); |
75 | } | 153 | } |
76 | 154 | ||
77 | setInitialSelection(start: number, end: number, focused: boolean): void { | 155 | updateDispatcher(newDispatcher: ((tr: Transaction) => void) | null): void { |
78 | this.initialSelection = { start, end, focused }; | 156 | this.dispatcher = newDispatcher || this.defaultDispatcher; |
79 | this.applyInitialSelectionToEditor(); | 157 | } |
158 | |||
159 | onTransaction(tr: Transaction): void { | ||
160 | log.trace('Editor transaction', tr); | ||
161 | this.state = tr.state; | ||
162 | this.client.onTransaction(tr); | ||
80 | } | 163 | } |
81 | 164 | ||
82 | private resetInitialSelection(): void { | 165 | dispatch(...specs: readonly TransactionSpec[]): void { |
83 | this.initialSelection = { | 166 | this.dispatcher(this.state.update(...specs)); |
84 | start: 0, | ||
85 | end: 0, | ||
86 | focused: false, | ||
87 | }; | ||
88 | } | 167 | } |
89 | 168 | ||
90 | private applyInitialSelectionToEditor(): void { | 169 | doStateCommand(command: StateCommand): boolean { |
91 | if (this.editor) { | 170 | return command({ |
92 | const { start, end, focused } = this.initialSelection; | 171 | state: this.state, |
93 | const doc = this.editor.getDoc(); | 172 | dispatch: this.dispatcher, |
94 | const startPos = doc.posFromIndex(start); | 173 | }); |
95 | const endPos = doc.posFromIndex(end); | 174 | } |
96 | doc.setSelection(startPos, endPos, { | 175 | |
97 | scroll: true, | 176 | updateDiagnostics(diagnostics: Diagnostic[]): void { |
98 | }); | 177 | this.dispatch(setDiagnostics(this.state, diagnostics)); |
99 | if (focused) { | 178 | this.errorCount = 0; |
100 | this.editor.focus(); | 179 | this.warningCount = 0; |
180 | this.infoCount = 0; | ||
181 | diagnostics.forEach(({ severity }) => { | ||
182 | switch (severity) { | ||
183 | case 'error': | ||
184 | this.errorCount += 1; | ||
185 | break; | ||
186 | case 'warning': | ||
187 | this.warningCount += 1; | ||
188 | break; | ||
189 | case 'info': | ||
190 | this.infoCount += 1; | ||
191 | break; | ||
101 | } | 192 | } |
102 | this.resetInitialSelection(); | 193 | }); |
103 | } | ||
104 | } | 194 | } |
105 | 195 | ||
106 | /** | 196 | get highestDiagnosticLevel(): Diagnostic['severity'] | null { |
107 | * Attaches a new CodeMirror instance and creates Xtext services. | 197 | if (this.errorCount > 0) { |
108 | * | 198 | return 'error'; |
109 | * The store will not subscribe to any CodeMirror events. Instead, | 199 | } |
110 | * the editor component should subscribe to them and relay them to the store. | 200 | if (this.warningCount > 0) { |
111 | * | 201 | return 'warning'; |
112 | * @param newEditor The new CodeMirror instance | ||
113 | */ | ||
114 | editorDidMount(newEditor: Editor): void { | ||
115 | if (!this.chunk) { | ||
116 | throw new Error('Editor not loaded yet'); | ||
117 | } | 202 | } |
118 | if (this.editor) { | 203 | if (this.infoCount > 0) { |
119 | throw new Error('CoreMirror editor mounted before unmounting'); | 204 | return 'info'; |
120 | } | 205 | } |
121 | this.editor = newEditor; | 206 | return null; |
122 | this.xtextServices = this.chunk.createServices(newEditor, xtextOptions); | ||
123 | this.applyInitialSelectionToEditor(); | ||
124 | } | 207 | } |
125 | 208 | ||
126 | editorWillUnmount(): void { | 209 | updateSemanticHighlighting(ranges: IHighlightRange[]): void { |
127 | if (!this.chunk) { | 210 | this.dispatch(setSemanticHighlighting(ranges)); |
128 | throw new Error('Editor not loaded yet'); | 211 | } |
129 | } | 212 | |
130 | if (this.editor) { | 213 | updateOccurrences(write: IOccurrence[], read: IOccurrence[]): void { |
131 | this.chunk.removeServices(this.editor); | 214 | this.dispatch(setOccurrences(write, read)); |
132 | } | ||
133 | delete this.editor; | ||
134 | delete this.xtextServices; | ||
135 | } | 215 | } |
136 | 216 | ||
137 | /** | 217 | /** |
138 | * Updates the contents of the editor. | 218 | * @returns `true` if there is history to undo |
139 | * | ||
140 | * @param newValue The new contents of the editor | ||
141 | */ | 219 | */ |
142 | updateValue(newValue: string): void { | 220 | get canUndo(): boolean { |
143 | this.value = newValue; | 221 | return undoDepth(this.state) > 0; |
144 | } | 222 | } |
145 | 223 | ||
146 | reportChanged(): void { | 224 | // eslint-disable-next-line class-methods-use-this |
147 | this.atom.reportChanged(); | 225 | undo(): void { |
226 | log.debug('Undo', this.doStateCommand(undo)); | ||
148 | } | 227 | } |
149 | 228 | ||
150 | protected observeEditorChanges(): void { | 229 | /** |
151 | this.atom.reportObserved(); | 230 | * @returns `true` if there is history to redo |
231 | */ | ||
232 | get canRedo(): boolean { | ||
233 | return redoDepth(this.state) > 0; | ||
152 | } | 234 | } |
153 | 235 | ||
154 | get codeMirrorTheme(): string { | 236 | // eslint-disable-next-line class-methods-use-this |
155 | return `problem-${this.themeStore.className}`; | 237 | redo(): void { |
238 | log.debug('Redo', this.doStateCommand(redo)); | ||
156 | } | 239 | } |
157 | 240 | ||
158 | get codeMirrorOptions(): EditorConfiguration { | 241 | toggleLineNumbers(): void { |
159 | return { | 242 | this.showLineNumbers = !this.showLineNumbers; |
160 | ...codeMirrorGlobalOptions, | 243 | log.debug('Show line numbers', this.showLineNumbers); |
161 | theme: this.codeMirrorTheme, | ||
162 | lineNumbers: this.showLineNumbers, | ||
163 | }; | ||
164 | } | 244 | } |
165 | 245 | ||
166 | /** | 246 | /** |
167 | * @returns `true` if there is history to undo | 247 | * Sets whether the CodeMirror search panel should be open. |
248 | * | ||
249 | * This method can be used as a CodeMirror command, | ||
250 | * because it returns `false` if it didn't execute, | ||
251 | * allowing other commands for the same keybind to run instead. | ||
252 | * This matches the behavior of the `openSearchPanel` and `closeSearchPanel` | ||
253 | * commands from `'@codemirror/search'`. | ||
254 | * | ||
255 | * @param newShosSearchPanel whether we should show the search panel | ||
256 | * @returns `true` if the state was changed, `false` otherwise | ||
168 | */ | 257 | */ |
169 | get canUndo(): boolean { | 258 | setSearchPanelOpen(newShowSearchPanel: boolean): boolean { |
170 | this.observeEditorChanges(); | 259 | if (this.showSearchPanel === newShowSearchPanel) { |
171 | if (!this.editor) { | ||
172 | return false; | 260 | return false; |
173 | } | 261 | } |
174 | const { undo: undoSize } = this.editor.historySize(); | 262 | this.showSearchPanel = newShowSearchPanel; |
175 | return undoSize > 0; | 263 | log.debug('Show search panel', this.showSearchPanel); |
264 | return true; | ||
176 | } | 265 | } |
177 | 266 | ||
178 | undo(): void { | 267 | toggleSearchPanel(): void { |
179 | this.editor?.undo(); | 268 | this.setSearchPanelOpen(!this.showSearchPanel); |
180 | } | 269 | } |
181 | 270 | ||
182 | /** | 271 | setLintPanelOpen(newShowLintPanel: boolean): boolean { |
183 | * @returns `true` if there is history to redo | 272 | if (this.showLintPanel === newShowLintPanel) { |
184 | */ | ||
185 | get canRedo(): boolean { | ||
186 | this.observeEditorChanges(); | ||
187 | if (!this.editor) { | ||
188 | return false; | 273 | return false; |
189 | } | 274 | } |
190 | const { redo: redoSize } = this.editor.historySize(); | 275 | this.showLintPanel = newShowLintPanel; |
191 | return redoSize > 0; | 276 | log.debug('Show lint panel', this.showLintPanel); |
192 | } | 277 | return true; |
193 | |||
194 | redo(): void { | ||
195 | this.editor?.redo(); | ||
196 | } | 278 | } |
197 | 279 | ||
198 | toggleLineNumbers(): void { | 280 | toggleLintPanel(): void { |
199 | this.showLineNumbers = !this.showLineNumbers; | 281 | this.setLintPanelOpen(!this.showLintPanel); |
200 | } | 282 | } |
201 | } | 283 | } |