diff options
Diffstat (limited to 'language-web/src/main/js/editor/EditorStore.ts')
-rw-r--r-- | language-web/src/main/js/editor/EditorStore.ts | 294 |
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 @@ | |||
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 { lintKeymap } from '@codemirror/lint'; | ||
18 | import { bracketMatching } from '@codemirror/matchbrackets'; | ||
19 | import { rectangularSelection } from '@codemirror/rectangular-selection'; | ||
20 | import { searchConfig, searchKeymap } from '@codemirror/search'; | ||
21 | import { | ||
22 | EditorState, | ||
23 | StateCommand, | ||
24 | StateEffect, | ||
25 | Transaction, | ||
26 | TransactionSpec, | ||
27 | } from '@codemirror/state'; | ||
28 | import { | ||
29 | drawSelection, | ||
30 | EditorView, | ||
31 | highlightActiveLine, | ||
32 | highlightSpecialChars, | ||
33 | keymap, | ||
34 | } from '@codemirror/view'; | ||
2 | import { | 35 | import { |
3 | createAtom, | ||
4 | makeAutoObservable, | 36 | makeAutoObservable, |
5 | observable, | 37 | observable, |
6 | runInAction, | 38 | reaction, |
7 | } from 'mobx'; | 39 | } from 'mobx'; |
8 | import type { IXtextOptions, IXtextServices } from 'xtext/xtext-codemirror'; | ||
9 | 40 | ||
10 | import type { IEditorChunk } from './editor'; | ||
11 | import { getLogger } from '../logging'; | 41 | import { getLogger } from '../logging'; |
12 | import type { ThemeStore } from '../theme/ThemeStore'; | 42 | import type { ThemeStore } from '../theme/ThemeStore'; |
13 | 43 | ||
14 | const log = getLogger('EditorStore'); | 44 | const log = getLogger('EditorStore'); |
15 | 45 | ||
16 | const xtextLang = 'problem'; | ||
17 | |||
18 | const xtextOptions: IXtextOptions = { | ||
19 | xtextLang, | ||
20 | enableFormattingAction: true, | ||
21 | }; | ||
22 | |||
23 | const codeMirrorGlobalOptions: EditorConfiguration = { | ||
24 | mode: `xtext/${xtextLang}`, | ||
25 | indentUnit: 2, | ||
26 | styleActiveLine: true, | ||
27 | screenReaderLabel: 'Model source code', | ||
28 | inputStyle: 'contenteditable', | ||
29 | }; | ||
30 | |||
31 | export class EditorStore { | 46 | export 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 | } |