aboutsummaryrefslogtreecommitdiffstats
path: root/language-web/src/main/js/editor/EditorStore.ts
diff options
context:
space:
mode:
Diffstat (limited to 'language-web/src/main/js/editor/EditorStore.ts')
-rw-r--r--language-web/src/main/js/editor/EditorStore.ts358
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 @@
1import type { Editor, EditorConfiguration } from 'codemirror'; 1import { autocompletion, completionKeymap } from '@codemirror/autocomplete';
2import { closeBrackets, closeBracketsKeymap } from '@codemirror/closebrackets';
3import { defaultKeymap, indentWithTab } from '@codemirror/commands';
4import { commentKeymap } from '@codemirror/comment';
5import { foldGutter, foldKeymap } from '@codemirror/fold';
6import { highlightActiveLineGutter, lineNumbers } from '@codemirror/gutter';
7import { classHighlightStyle } from '@codemirror/highlight';
8import {
9 history,
10 historyKeymap,
11 redo,
12 redoDepth,
13 undo,
14 undoDepth,
15} from '@codemirror/history';
16import { indentOnInput } from '@codemirror/language';
17import {
18 Diagnostic,
19 lintKeymap,
20 setDiagnostics,
21} from '@codemirror/lint';
22import { bracketMatching } from '@codemirror/matchbrackets';
23import { rectangularSelection } from '@codemirror/rectangular-selection';
24import { searchConfig, searchKeymap } from '@codemirror/search';
25import {
26 EditorState,
27 StateCommand,
28 StateEffect,
29 Transaction,
30 TransactionSpec,
31} from '@codemirror/state';
32import {
33 drawSelection,
34 EditorView,
35 highlightActiveLine,
36 highlightSpecialChars,
37 keymap,
38} from '@codemirror/view';
2import { 39import {
3 createAtom,
4 makeAutoObservable, 40 makeAutoObservable,
5 observable, 41 observable,
6 runInAction, 42 reaction,
7} from 'mobx'; 43} from 'mobx';
8import type { IXtextOptions, IXtextServices } from 'xtext/xtext-codemirror';
9 44
10import type { IEditorChunk } from './editor'; 45import { findOccurrences, IOccurrence, setOccurrences } from './findOccurrences';
11import { getLogger } from '../logging'; 46import { problemLanguageSupport } from '../language/problemLanguageSupport';
47import {
48 IHighlightRange,
49 semanticHighlighting,
50 setSemanticHighlighting,
51} from './semanticHighlighting';
12import type { ThemeStore } from '../theme/ThemeStore'; 52import type { ThemeStore } from '../theme/ThemeStore';
53import { getLogger } from '../utils/logger';
54import { XtextClient } from '../xtext/XtextClient';
13 55
14const log = getLogger('EditorStore'); 56const log = getLogger('editor.EditorStore');
15 57
16const xtextLang = 'problem'; 58export class EditorStore {
59 private readonly themeStore;
17 60
18const xtextOptions: IXtextOptions = { 61 state: EditorState;
19 xtextLang,
20 enableFormattingAction: true,
21};
22 62
23const 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
31export 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}