aboutsummaryrefslogtreecommitdiffstats
path: root/subprojects/frontend/src/editor/EditorStore.ts
diff options
context:
space:
mode:
Diffstat (limited to 'subprojects/frontend/src/editor/EditorStore.ts')
-rw-r--r--subprojects/frontend/src/editor/EditorStore.ts320
1 files changed, 134 insertions, 186 deletions
diff --git a/subprojects/frontend/src/editor/EditorStore.ts b/subprojects/frontend/src/editor/EditorStore.ts
index f75147a4..4bad68b3 100644
--- a/subprojects/frontend/src/editor/EditorStore.ts
+++ b/subprojects/frontend/src/editor/EditorStore.ts
@@ -1,58 +1,30 @@
1import { CompletionContext, CompletionResult } from '@codemirror/autocomplete';
2import { redo, redoDepth, undo, undoDepth } from '@codemirror/commands';
1import { 3import {
2 closeBrackets, 4 type Diagnostic,
3 closeBracketsKeymap, 5 setDiagnostics,
4 autocompletion, 6 closeLintPanel,
5 completionKeymap, 7 openLintPanel,
6} from '@codemirror/autocomplete'; 8 nextDiagnostic,
9} from '@codemirror/lint';
10import { closeSearchPanel, openSearchPanel } from '@codemirror/search';
7import { 11import {
8 defaultKeymap,
9 history,
10 historyKeymap,
11 indentWithTab,
12 redo,
13 redoDepth,
14 undo,
15 undoDepth,
16} from '@codemirror/commands';
17import {
18 bracketMatching,
19 foldGutter,
20 foldKeymap,
21 indentOnInput,
22 syntaxHighlighting,
23} from '@codemirror/language';
24import { type Diagnostic, lintKeymap, setDiagnostics } from '@codemirror/lint';
25import { search, searchKeymap } from '@codemirror/search';
26import {
27 EditorState,
28 type StateCommand, 12 type StateCommand,
29 StateEffect, 13 StateEffect,
30 type Transaction, 14 type Transaction,
31 type TransactionSpec, 15 type TransactionSpec,
16 type EditorState,
32} from '@codemirror/state'; 17} from '@codemirror/state';
33import { 18import { type Command, EditorView } from '@codemirror/view';
34 drawSelection, 19import { action, computed, makeObservable, observable } from 'mobx';
35 EditorView, 20
36 highlightActiveLine,
37 highlightActiveLineGutter,
38 highlightSpecialChars,
39 keymap,
40 lineNumbers,
41 rectangularSelection,
42} from '@codemirror/view';
43import { classHighlighter } from '@lezer/highlight';
44import { makeAutoObservable, observable, reaction } from 'mobx';
45
46import problemLanguageSupport from '../language/problemLanguageSupport';
47import type ThemeStore from '../theme/ThemeStore';
48import getLogger from '../utils/getLogger'; 21import getLogger from '../utils/getLogger';
49import XtextClient from '../xtext/XtextClient'; 22import XtextClient from '../xtext/XtextClient';
50 23
51import findOccurrences, { 24import PanelStore from './PanelStore';
52 type IOccurrence, 25import createEditorState from './createEditorState';
53 setOccurrences, 26import { type IOccurrence, setOccurrences } from './findOccurrences';
54} from './findOccurrences'; 27import {
55import semanticHighlighting, {
56 type IHighlightRange, 28 type IHighlightRange,
57 setSemanticHighlighting, 29 setSemanticHighlighting,
58} from './semanticHighlighting'; 30} from './semanticHighlighting';
@@ -60,17 +32,17 @@ import semanticHighlighting, {
60const log = getLogger('editor.EditorStore'); 32const log = getLogger('editor.EditorStore');
61 33
62export default class EditorStore { 34export default class EditorStore {
63 private readonly themeStore;
64
65 state: EditorState; 35 state: EditorState;
66 36
67 private readonly client: XtextClient; 37 private readonly client: XtextClient;
68 38
69 showLineNumbers = false; 39 view: EditorView | undefined;
70 40
71 showSearchPanel = false; 41 readonly searchPanel: PanelStore;
72 42
73 showLintPanel = false; 43 readonly lintPanel: PanelStore;
44
45 showLineNumbers = false;
74 46
75 errorCount = 0; 47 errorCount = 0;
76 48
@@ -78,116 +50,124 @@ export default class EditorStore {
78 50
79 infoCount = 0; 51 infoCount = 0;
80 52
81 private readonly defaultDispatcher = (tr: Transaction): void => { 53 constructor(initialValue: string) {
82 this.onTransaction(tr); 54 this.state = createEditorState(initialValue, this);
83 };
84
85 private dispatcher = this.defaultDispatcher;
86
87 constructor(initialValue: string, themeStore: ThemeStore) {
88 this.themeStore = themeStore;
89 this.state = EditorState.create({
90 doc: initialValue,
91 extensions: [
92 autocompletion({
93 activateOnTyping: true,
94 override: [(context) => this.client.contentAssist(context)],
95 }),
96 closeBrackets(),
97 bracketMatching(),
98 drawSelection(),
99 EditorState.allowMultipleSelections.of(true),
100 EditorView.theme(
101 {},
102 {
103 dark: this.themeStore.darkMode,
104 },
105 ),
106 findOccurrences,
107 highlightActiveLine(),
108 highlightActiveLineGutter(),
109 highlightSpecialChars(),
110 history(),
111 indentOnInput(),
112 rectangularSelection(),
113 search({
114 top: true,
115 caseSensitive: true,
116 }),
117 syntaxHighlighting(classHighlighter),
118 semanticHighlighting,
119 // We add the gutters to `extensions` in the order we want them to appear.
120 lineNumbers(),
121 foldGutter(),
122 keymap.of([
123 { key: 'Mod-Shift-f', run: () => this.formatText() },
124 ...closeBracketsKeymap,
125 ...completionKeymap,
126 ...foldKeymap,
127 ...historyKeymap,
128 indentWithTab,
129 // Override keys in `lintKeymap` to go through the `EditorStore`.
130 { key: 'Mod-Shift-m', run: () => this.setLintPanelOpen(true) },
131 ...lintKeymap,
132 // Override keys in `searchKeymap` to go through the `EditorStore`.
133 {
134 key: 'Mod-f',
135 run: () => this.setSearchPanelOpen(true),
136 scope: 'editor search-panel',
137 },
138 {
139 key: 'Escape',
140 run: () => this.setSearchPanelOpen(false),
141 scope: 'editor search-panel',
142 },
143 ...searchKeymap,
144 ...defaultKeymap,
145 ]),
146 problemLanguageSupport(),
147 ],
148 });
149 this.client = new XtextClient(this); 55 this.client = new XtextClient(this);
150 reaction( 56 this.searchPanel = new PanelStore(
151 () => this.themeStore.darkMode, 57 'search',
152 (darkMode) => { 58 openSearchPanel,
153 log.debug('Update editor dark mode', darkMode); 59 closeSearchPanel,
154 this.dispatch({ 60 this,
155 effects: [ 61 );
156 StateEffect.appendConfig.of( 62 this.lintPanel = new PanelStore(
157 EditorView.theme( 63 'panel-lint',
158 {}, 64 openLintPanel,
159 { 65 closeLintPanel,
160 dark: darkMode, 66 this,
161 },
162 ),
163 ),
164 ],
165 });
166 },
167 ); 67 );
168 makeAutoObservable(this, { 68 makeObservable(this, {
169 state: observable.ref, 69 state: observable.ref,
70 view: observable.ref,
71 showLineNumbers: observable,
72 errorCount: observable,
73 warningCount: observable,
74 infoCount: observable,
75 highestDiagnosticLevel: computed,
76 canUndo: computed,
77 canRedo: computed,
78 setDarkMode: action,
79 setEditorParent: action,
80 dispatch: action,
81 dispatchTransaction: action,
82 doCommand: action,
83 doStateCommand: action,
84 updateDiagnostics: action,
85 nextDiagnostic: action,
86 updateOccurrences: action,
87 updateSemanticHighlighting: action,
88 undo: action,
89 redo: action,
90 toggleLineNumbers: action,
170 }); 91 });
171 } 92 }
172 93
173 updateDispatcher(newDispatcher: ((tr: Transaction) => void) | null): void { 94 setDarkMode(darkMode: boolean): void {
174 this.dispatcher = newDispatcher || this.defaultDispatcher; 95 log.debug('Update editor dark mode', darkMode);
96 this.dispatch({
97 effects: [
98 StateEffect.appendConfig.of([EditorView.darkTheme.of(darkMode)]),
99 ],
100 });
175 } 101 }
176 102
177 onTransaction(tr: Transaction): void { 103 setEditorParent(editorParent: Element | null): void {
178 log.trace('Editor transaction', tr); 104 if (this.view !== undefined) {
179 this.state = tr.state; 105 this.view.destroy();
180 this.client.onTransaction(tr); 106 }
107 if (editorParent === null) {
108 this.view = undefined;
109 return;
110 }
111 const view = new EditorView({
112 state: this.state,
113 parent: editorParent,
114 dispatch: (transaction) => {
115 this.dispatchTransactionWithoutView(transaction);
116 view.update([transaction]);
117 if (view.state !== this.state) {
118 log.error(
119 'Failed to synchronize editor state - store state:',
120 this.state,
121 'view state:',
122 view.state,
123 );
124 }
125 },
126 });
127 this.view = view;
128 this.searchPanel.synchronizeStateToView();
129 this.lintPanel.synchronizeStateToView();
130
131 // Reported by Lighthouse 8.3.0.
132 const { contentDOM } = view;
133 contentDOM.removeAttribute('aria-expanded');
134 contentDOM.setAttribute('aria-label', 'Code editor');
135
136 log.info('Editor created');
181 } 137 }
182 138
183 dispatch(...specs: readonly TransactionSpec[]): void { 139 dispatch(...specs: readonly TransactionSpec[]): void {
184 this.dispatcher(this.state.update(...specs)); 140 const transaction = this.state.update(...specs);
141 this.dispatchTransaction(transaction);
142 }
143
144 dispatchTransaction(transaction: Transaction): void {
145 if (this.view === undefined) {
146 this.dispatchTransactionWithoutView(transaction);
147 } else {
148 this.view.dispatch(transaction);
149 }
150 }
151
152 private readonly dispatchTransactionWithoutView = action(
153 (tr: Transaction) => {
154 log.trace('Editor transaction', tr);
155 this.state = tr.state;
156 this.client.onTransaction(tr);
157 },
158 );
159
160 doCommand(command: Command): boolean {
161 if (this.view === undefined) {
162 return false;
163 }
164 return command(this.view);
185 } 165 }
186 166
187 doStateCommand(command: StateCommand): boolean { 167 doStateCommand(command: StateCommand): boolean {
188 return command({ 168 return command({
189 state: this.state, 169 state: this.state,
190 dispatch: this.dispatcher, 170 dispatch: (transaction) => this.dispatchTransaction(transaction),
191 }); 171 });
192 } 172 }
193 173
@@ -213,7 +193,11 @@ export default class EditorStore {
213 }); 193 });
214 } 194 }
215 195
216 get highestDiagnosticLevel(): Diagnostic['severity'] | null { 196 nextDiagnostic(): void {
197 this.doCommand(nextDiagnostic);
198 }
199
200 get highestDiagnosticLevel(): Diagnostic['severity'] | undefined {
217 if (this.errorCount > 0) { 201 if (this.errorCount > 0) {
218 return 'error'; 202 return 'error';
219 } 203 }
@@ -223,7 +207,7 @@ export default class EditorStore {
223 if (this.infoCount > 0) { 207 if (this.infoCount > 0) {
224 return 'info'; 208 return 'info';
225 } 209 }
226 return null; 210 return undefined;
227 } 211 }
228 212
229 updateSemanticHighlighting(ranges: IHighlightRange[]): void { 213 updateSemanticHighlighting(ranges: IHighlightRange[]): void {
@@ -234,6 +218,10 @@ export default class EditorStore {
234 this.dispatch(setOccurrences(write, read)); 218 this.dispatch(setOccurrences(write, read));
235 } 219 }
236 220
221 contentAssist(context: CompletionContext): Promise<CompletionResult> {
222 return this.client.contentAssist(context);
223 }
224
237 /** 225 /**
238 * @returns `true` if there is history to undo 226 * @returns `true` if there is history to undo
239 */ 227 */
@@ -241,7 +229,6 @@ export default class EditorStore {
241 return undoDepth(this.state) > 0; 229 return undoDepth(this.state) > 0;
242 } 230 }
243 231
244 // eslint-disable-next-line class-methods-use-this
245 undo(): void { 232 undo(): void {
246 log.debug('Undo', this.doStateCommand(undo)); 233 log.debug('Undo', this.doStateCommand(undo));
247 } 234 }
@@ -253,7 +240,6 @@ export default class EditorStore {
253 return redoDepth(this.state) > 0; 240 return redoDepth(this.state) > 0;
254 } 241 }
255 242
256 // eslint-disable-next-line class-methods-use-this
257 redo(): void { 243 redo(): void {
258 log.debug('Redo', this.doStateCommand(redo)); 244 log.debug('Redo', this.doStateCommand(redo));
259 } 245 }
@@ -263,44 +249,6 @@ export default class EditorStore {
263 log.debug('Show line numbers', this.showLineNumbers); 249 log.debug('Show line numbers', this.showLineNumbers);
264 } 250 }
265 251
266 /**
267 * Sets whether the CodeMirror search panel should be open.
268 *
269 * This method can be used as a CodeMirror command,
270 * because it returns `false` if it didn't execute,
271 * allowing other commands for the same keybind to run instead.
272 * This matches the behavior of the `openSearchPanel` and `closeSearchPanel`
273 * commands from `'@codemirror/search'`.
274 *
275 * @param newShowSearchPanel whether we should show the search panel
276 * @returns `true` if the state was changed, `false` otherwise
277 */
278 setSearchPanelOpen(newShowSearchPanel: boolean): boolean {
279 if (this.showSearchPanel === newShowSearchPanel) {
280 return false;
281 }
282 this.showSearchPanel = newShowSearchPanel;
283 log.debug('Show search panel', this.showSearchPanel);
284 return true;
285 }
286
287 toggleSearchPanel(): void {
288 this.setSearchPanelOpen(!this.showSearchPanel);
289 }
290
291 setLintPanelOpen(newShowLintPanel: boolean): boolean {
292 if (this.showLintPanel === newShowLintPanel) {
293 return false;
294 }
295 this.showLintPanel = newShowLintPanel;
296 log.debug('Show lint panel', this.showLintPanel);
297 return true;
298 }
299
300 toggleLintPanel(): void {
301 this.setLintPanelOpen(!this.showLintPanel);
302 }
303
304 formatText(): boolean { 252 formatText(): boolean {
305 this.client.formatText(); 253 this.client.formatText();
306 return true; 254 return true;