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