diff options
author | Kristóf Marussy <kristof@marussy.com> | 2022-08-16 21:14:50 +0200 |
---|---|---|
committer | Kristóf Marussy <kristof@marussy.com> | 2022-08-16 21:14:50 +0200 |
commit | 19cd11118cde7160cd447c81bc965007c0437479 (patch) | |
tree | 5fea613e7a46d69380995368a68cc72f186078a4 /subprojects/frontend/src/editor/EditorStore.ts | |
parent | chore(deps): bump frontend dependencies (diff) | |
download | refinery-19cd11118cde7160cd447c81bc965007c0437479.tar.gz refinery-19cd11118cde7160cd447c81bc965007c0437479.tar.zst refinery-19cd11118cde7160cd447c81bc965007c0437479.zip |
refactor(frondend): improve editor store and theme
Also bumps frontend dependencies.
Diffstat (limited to 'subprojects/frontend/src/editor/EditorStore.ts')
-rw-r--r-- | subprojects/frontend/src/editor/EditorStore.ts | 320 |
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 @@ | |||
1 | import { CompletionContext, CompletionResult } from '@codemirror/autocomplete'; | ||
2 | import { redo, redoDepth, undo, undoDepth } from '@codemirror/commands'; | ||
1 | import { | 3 | import { |
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'; | ||
10 | import { closeSearchPanel, openSearchPanel } from '@codemirror/search'; | ||
7 | import { | 11 | import { |
8 | defaultKeymap, | ||
9 | history, | ||
10 | historyKeymap, | ||
11 | indentWithTab, | ||
12 | redo, | ||
13 | redoDepth, | ||
14 | undo, | ||
15 | undoDepth, | ||
16 | } from '@codemirror/commands'; | ||
17 | import { | ||
18 | bracketMatching, | ||
19 | foldGutter, | ||
20 | foldKeymap, | ||
21 | indentOnInput, | ||
22 | syntaxHighlighting, | ||
23 | } from '@codemirror/language'; | ||
24 | import { type Diagnostic, lintKeymap, setDiagnostics } from '@codemirror/lint'; | ||
25 | import { search, searchKeymap } from '@codemirror/search'; | ||
26 | import { | ||
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'; |
33 | import { | 18 | import { type Command, EditorView } from '@codemirror/view'; |
34 | drawSelection, | 19 | import { 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'; | ||
43 | import { classHighlighter } from '@lezer/highlight'; | ||
44 | import { makeAutoObservable, observable, reaction } from 'mobx'; | ||
45 | |||
46 | import problemLanguageSupport from '../language/problemLanguageSupport'; | ||
47 | import type ThemeStore from '../theme/ThemeStore'; | ||
48 | import getLogger from '../utils/getLogger'; | 21 | import getLogger from '../utils/getLogger'; |
49 | import XtextClient from '../xtext/XtextClient'; | 22 | import XtextClient from '../xtext/XtextClient'; |
50 | 23 | ||
51 | import findOccurrences, { | 24 | import PanelStore from './PanelStore'; |
52 | type IOccurrence, | 25 | import createEditorState from './createEditorState'; |
53 | setOccurrences, | 26 | import { type IOccurrence, setOccurrences } from './findOccurrences'; |
54 | } from './findOccurrences'; | 27 | import { |
55 | import semanticHighlighting, { | ||
56 | type IHighlightRange, | 28 | type IHighlightRange, |
57 | setSemanticHighlighting, | 29 | setSemanticHighlighting, |
58 | } from './semanticHighlighting'; | 30 | } from './semanticHighlighting'; |
@@ -60,17 +32,17 @@ import semanticHighlighting, { | |||
60 | const log = getLogger('editor.EditorStore'); | 32 | const log = getLogger('editor.EditorStore'); |
61 | 33 | ||
62 | export default class EditorStore { | 34 | export 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; |