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 | |
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')
-rw-r--r-- | subprojects/frontend/src/RootStore.tsx | 6 | ||||
-rw-r--r-- | subprojects/frontend/src/TopBar.tsx | 4 | ||||
-rw-r--r-- | subprojects/frontend/src/editor/EditorArea.tsx | 138 | ||||
-rw-r--r-- | subprojects/frontend/src/editor/EditorButtons.tsx | 10 | ||||
-rw-r--r-- | subprojects/frontend/src/editor/EditorParent.ts | 228 | ||||
-rw-r--r-- | subprojects/frontend/src/editor/EditorStore.ts | 320 | ||||
-rw-r--r-- | subprojects/frontend/src/editor/EditorTheme.ts | 342 | ||||
-rw-r--r-- | subprojects/frontend/src/editor/GenerateButton.tsx | 2 | ||||
-rw-r--r-- | subprojects/frontend/src/editor/PanelStore.ts | 90 | ||||
-rw-r--r-- | subprojects/frontend/src/editor/createEditorState.ts | 121 | ||||
-rw-r--r-- | subprojects/frontend/src/editor/editorClassNames.ts | 10 | ||||
-rw-r--r-- | subprojects/frontend/src/language/problemLanguageSupport.ts | 8 | ||||
-rw-r--r-- | subprojects/frontend/src/theme/EditorTheme.ts | 7 | ||||
-rw-r--r-- | subprojects/frontend/src/theme/ThemeProvider.tsx | 85 | ||||
-rw-r--r-- | subprojects/frontend/src/theme/ThemeStore.ts | 26 |
15 files changed, 775 insertions, 622 deletions
diff --git a/subprojects/frontend/src/RootStore.tsx b/subprojects/frontend/src/RootStore.tsx index a7406d7b..4a267b0e 100644 --- a/subprojects/frontend/src/RootStore.tsx +++ b/subprojects/frontend/src/RootStore.tsx | |||
@@ -4,13 +4,13 @@ import EditorStore from './editor/EditorStore'; | |||
4 | import ThemeStore from './theme/ThemeStore'; | 4 | import ThemeStore from './theme/ThemeStore'; |
5 | 5 | ||
6 | export default class RootStore { | 6 | export default class RootStore { |
7 | editorStore; | 7 | readonly editorStore: EditorStore; |
8 | 8 | ||
9 | themeStore; | 9 | readonly themeStore: ThemeStore; |
10 | 10 | ||
11 | constructor(initialValue: string) { | 11 | constructor(initialValue: string) { |
12 | this.editorStore = new EditorStore(initialValue); | ||
12 | this.themeStore = new ThemeStore(); | 13 | this.themeStore = new ThemeStore(); |
13 | this.editorStore = new EditorStore(initialValue, this.themeStore); | ||
14 | } | 14 | } |
15 | } | 15 | } |
16 | 16 | ||
diff --git a/subprojects/frontend/src/TopBar.tsx b/subprojects/frontend/src/TopBar.tsx index af571a1e..5ad80d40 100644 --- a/subprojects/frontend/src/TopBar.tsx +++ b/subprojects/frontend/src/TopBar.tsx | |||
@@ -12,8 +12,8 @@ export default function TopBar(): JSX.Element { | |||
12 | elevation={0} | 12 | elevation={0} |
13 | color="transparent" | 13 | color="transparent" |
14 | sx={(theme) => ({ | 14 | sx={(theme) => ({ |
15 | background: theme.palette.highlight.activeLine, | 15 | background: theme.palette.outer.background, |
16 | borderBottom: `1px solid ${theme.palette.divider2}`, | 16 | borderBottom: `1px solid ${theme.palette.outer.border}`, |
17 | })} | 17 | })} |
18 | > | 18 | > |
19 | <Toolbar> | 19 | <Toolbar> |
diff --git a/subprojects/frontend/src/editor/EditorArea.tsx b/subprojects/frontend/src/editor/EditorArea.tsx index d4305610..e5712461 100644 --- a/subprojects/frontend/src/editor/EditorArea.tsx +++ b/subprojects/frontend/src/editor/EditorArea.tsx | |||
@@ -1,139 +1,31 @@ | |||
1 | import { closeLintPanel, openLintPanel } from '@codemirror/lint'; | 1 | import { useTheme } from '@mui/material/styles'; |
2 | import { closeSearchPanel, openSearchPanel } from '@codemirror/search'; | ||
3 | import { type Command, EditorView } from '@codemirror/view'; | ||
4 | import { observer } from 'mobx-react-lite'; | 2 | import { observer } from 'mobx-react-lite'; |
5 | import React, { useCallback, useEffect, useRef, useState } from 'react'; | 3 | import React, { useCallback, useEffect } from 'react'; |
6 | 4 | ||
7 | import { useRootStore } from '../RootStore'; | 5 | import { useRootStore } from '../RootStore'; |
8 | import getLogger from '../utils/getLogger'; | ||
9 | 6 | ||
10 | import EditorParent from './EditorParent'; | 7 | import EditorTheme from './EditorTheme'; |
11 | |||
12 | const log = getLogger('editor.EditorArea'); | ||
13 | |||
14 | function usePanel( | ||
15 | panelId: string, | ||
16 | stateToSet: boolean, | ||
17 | editorView: EditorView | null, | ||
18 | openCommand: Command, | ||
19 | closeCommand: Command, | ||
20 | closeCallback: () => void, | ||
21 | ) { | ||
22 | const [cachedViewState, setCachedViewState] = useState<boolean>(false); | ||
23 | useEffect(() => { | ||
24 | if (editorView === null || cachedViewState === stateToSet) { | ||
25 | return; | ||
26 | } | ||
27 | if (stateToSet) { | ||
28 | openCommand(editorView); | ||
29 | const buttonQuery = `.cm-${panelId}.cm-panel button[name="close"]`; | ||
30 | const closeButton = editorView.dom.querySelector(buttonQuery); | ||
31 | if (closeButton) { | ||
32 | log.debug('Addig close button callback to', panelId, 'panel'); | ||
33 | // We must remove the event listener added by CodeMirror from the button | ||
34 | // that dispatches a transaction without going through `EditorStorre`. | ||
35 | // Cloning a DOM node removes event listeners, | ||
36 | // see https://stackoverflow.com/a/9251864 | ||
37 | const closeButtonWithoutListeners = closeButton.cloneNode(true); | ||
38 | closeButtonWithoutListeners.addEventListener('click', (event) => { | ||
39 | closeCallback(); | ||
40 | event.preventDefault(); | ||
41 | }); | ||
42 | closeButton.replaceWith(closeButtonWithoutListeners); | ||
43 | } else { | ||
44 | log.error('Opened', panelId, 'panel has no close button'); | ||
45 | } | ||
46 | } else { | ||
47 | closeCommand(editorView); | ||
48 | } | ||
49 | setCachedViewState(stateToSet); | ||
50 | }, [ | ||
51 | stateToSet, | ||
52 | editorView, | ||
53 | cachedViewState, | ||
54 | panelId, | ||
55 | openCommand, | ||
56 | closeCommand, | ||
57 | closeCallback, | ||
58 | ]); | ||
59 | return setCachedViewState; | ||
60 | } | ||
61 | |||
62 | function fixCodeMirrorAccessibility(editorView: EditorView) { | ||
63 | // Reported by Lighthouse 8.3.0. | ||
64 | const { contentDOM } = editorView; | ||
65 | contentDOM.removeAttribute('aria-expanded'); | ||
66 | contentDOM.setAttribute('aria-label', 'Code editor'); | ||
67 | } | ||
68 | 8 | ||
69 | function EditorArea(): JSX.Element { | 9 | function EditorArea(): JSX.Element { |
70 | const { editorStore } = useRootStore(); | 10 | const { editorStore } = useRootStore(); |
71 | const editorParentRef = useRef<HTMLDivElement | null>(null); | 11 | const { |
72 | const [editorViewState, setEditorViewState] = useState<EditorView | null>( | 12 | palette: { mode: paletteMode }, |
73 | null, | 13 | } = useTheme(); |
74 | ); | ||
75 | 14 | ||
76 | const setSearchPanelOpen = usePanel( | 15 | useEffect( |
77 | 'search', | 16 | () => editorStore.setDarkMode(paletteMode === 'dark'), |
78 | editorStore.showSearchPanel, | 17 | [editorStore, paletteMode], |
79 | editorViewState, | ||
80 | openSearchPanel, | ||
81 | closeSearchPanel, | ||
82 | useCallback(() => editorStore.setSearchPanelOpen(false), [editorStore]), | ||
83 | ); | 18 | ); |
84 | 19 | ||
85 | const setLintPanelOpen = usePanel( | 20 | const editorParentRef = useCallback( |
86 | 'panel-lint', | 21 | (editorParent: HTMLDivElement | null) => { |
87 | editorStore.showLintPanel, | 22 | editorStore.setEditorParent(editorParent); |
88 | editorViewState, | 23 | }, |
89 | openLintPanel, | 24 | [editorStore], |
90 | closeLintPanel, | ||
91 | useCallback(() => editorStore.setLintPanelOpen(false), [editorStore]), | ||
92 | ); | 25 | ); |
93 | 26 | ||
94 | useEffect(() => { | ||
95 | if (editorParentRef.current === null) { | ||
96 | return () => { | ||
97 | // Nothing to clean up. | ||
98 | }; | ||
99 | } | ||
100 | |||
101 | const editorView = new EditorView({ | ||
102 | state: editorStore.state, | ||
103 | parent: editorParentRef.current, | ||
104 | dispatch: (transaction) => { | ||
105 | editorStore.onTransaction(transaction); | ||
106 | editorView.update([transaction]); | ||
107 | if (editorView.state !== editorStore.state) { | ||
108 | log.error( | ||
109 | 'Failed to synchronize editor state - store state:', | ||
110 | editorStore.state, | ||
111 | 'view state:', | ||
112 | editorView.state, | ||
113 | ); | ||
114 | } | ||
115 | }, | ||
116 | }); | ||
117 | fixCodeMirrorAccessibility(editorView); | ||
118 | setEditorViewState(editorView); | ||
119 | setSearchPanelOpen(false); | ||
120 | setLintPanelOpen(false); | ||
121 | // `dispatch` is bound to the view instance, | ||
122 | // so it does not have to be called as a method. | ||
123 | // eslint-disable-next-line @typescript-eslint/unbound-method | ||
124 | editorStore.updateDispatcher(editorView.dispatch); | ||
125 | log.info('Editor created'); | ||
126 | |||
127 | return () => { | ||
128 | editorStore.updateDispatcher(null); | ||
129 | editorView.destroy(); | ||
130 | log.info('Editor destroyed'); | ||
131 | }; | ||
132 | }, [editorStore, setSearchPanelOpen, setLintPanelOpen]); | ||
133 | |||
134 | return ( | 27 | return ( |
135 | <EditorParent | 28 | <EditorTheme |
136 | className="dark" | ||
137 | showLineNumbers={editorStore.showLineNumbers} | 29 | showLineNumbers={editorStore.showLineNumbers} |
138 | ref={editorParentRef} | 30 | ref={editorParentRef} |
139 | /> | 31 | /> |
diff --git a/subprojects/frontend/src/editor/EditorButtons.tsx b/subprojects/frontend/src/editor/EditorButtons.tsx index 1412a314..34b64751 100644 --- a/subprojects/frontend/src/editor/EditorButtons.tsx +++ b/subprojects/frontend/src/editor/EditorButtons.tsx | |||
@@ -19,7 +19,7 @@ import { useRootStore } from '../RootStore'; | |||
19 | 19 | ||
20 | // Exhastive switch as proven by TypeScript. | 20 | // Exhastive switch as proven by TypeScript. |
21 | // eslint-disable-next-line consistent-return | 21 | // eslint-disable-next-line consistent-return |
22 | function getLintIcon(severity: Diagnostic['severity'] | null) { | 22 | function getLintIcon(severity: Diagnostic['severity'] | undefined) { |
23 | switch (severity) { | 23 | switch (severity) { |
24 | case 'error': | 24 | case 'error': |
25 | return <ErrorIcon fontSize="small" />; | 25 | return <ErrorIcon fontSize="small" />; |
@@ -61,16 +61,16 @@ function EditorButtons(): JSX.Element { | |||
61 | <FormatListNumberedIcon fontSize="small" /> | 61 | <FormatListNumberedIcon fontSize="small" /> |
62 | </ToggleButton> | 62 | </ToggleButton> |
63 | <ToggleButton | 63 | <ToggleButton |
64 | selected={editorStore.showSearchPanel} | 64 | selected={editorStore.searchPanel.state} |
65 | onClick={() => editorStore.toggleSearchPanel()} | 65 | onClick={() => editorStore.searchPanel.toggle()} |
66 | aria-label="Show find/replace" | 66 | aria-label="Show find/replace" |
67 | value="show-search-panel" | 67 | value="show-search-panel" |
68 | > | 68 | > |
69 | <SearchIcon fontSize="small" /> | 69 | <SearchIcon fontSize="small" /> |
70 | </ToggleButton> | 70 | </ToggleButton> |
71 | <ToggleButton | 71 | <ToggleButton |
72 | selected={editorStore.showLintPanel} | 72 | selected={editorStore.lintPanel.state} |
73 | onClick={() => editorStore.toggleLintPanel()} | 73 | onClick={() => editorStore.lintPanel.toggle()} |
74 | aria-label="Show diagnostics panel" | 74 | aria-label="Show diagnostics panel" |
75 | value="show-lint-panel" | 75 | value="show-lint-panel" |
76 | > | 76 | > |
diff --git a/subprojects/frontend/src/editor/EditorParent.ts b/subprojects/frontend/src/editor/EditorParent.ts deleted file mode 100644 index 3742b89c..00000000 --- a/subprojects/frontend/src/editor/EditorParent.ts +++ /dev/null | |||
@@ -1,228 +0,0 @@ | |||
1 | import { alpha, styled } from '@mui/material/styles'; | ||
2 | |||
3 | export default styled('div', { | ||
4 | name: 'EditorParent', | ||
5 | shouldForwardProp: (propName) => propName !== 'showLineNumbers', | ||
6 | })<{ showLineNumbers: boolean }>(({ theme, showLineNumbers }) => { | ||
7 | const codeMirrorLintStyle: Record<string, unknown> = {}; | ||
8 | (['error', 'warning', 'info'] as const).forEach((severity) => { | ||
9 | const color = theme.palette[severity].main; | ||
10 | codeMirrorLintStyle[`.cm-diagnostic-${severity}`] = { | ||
11 | borderLeftColor: color, | ||
12 | }; | ||
13 | codeMirrorLintStyle[`.cm-lintRange-${severity}`] = { | ||
14 | backgroundImage: 'none', | ||
15 | textDecoration: `underline wavy ${color}`, | ||
16 | textDecorationSkipInk: 'none', | ||
17 | }; | ||
18 | }); | ||
19 | |||
20 | return { | ||
21 | background: theme.palette.background.default, | ||
22 | '&, .cm-editor': { | ||
23 | height: '100%', | ||
24 | }, | ||
25 | '.cm-content': { | ||
26 | padding: 0, | ||
27 | }, | ||
28 | '.cm-scroller, .cm-tooltip-autocomplete, .cm-completionLabel, .cm-completionDetail': | ||
29 | { | ||
30 | ...theme.typography.body1, | ||
31 | fontFamily: '"JetBrains MonoVariable", "JetBrains Mono", monospace', | ||
32 | fontFeatureSettings: '"liga", "calt"', | ||
33 | letterSpacing: 0, | ||
34 | textRendering: 'optimizeLegibility', | ||
35 | }, | ||
36 | '.cm-scroller': { | ||
37 | color: theme.palette.text.secondary, | ||
38 | }, | ||
39 | '.cm-gutters': { | ||
40 | background: 'transparent', | ||
41 | color: theme.palette.text.disabled, | ||
42 | border: 'none', | ||
43 | }, | ||
44 | '.cm-specialChar': { | ||
45 | color: theme.palette.secondary.main, | ||
46 | }, | ||
47 | '.cm-activeLine': { | ||
48 | background: theme.palette.highlight.activeLine, | ||
49 | }, | ||
50 | '.cm-foldGutter': { | ||
51 | color: alpha(theme.palette.text.primary, 0), | ||
52 | transition: theme.transitions.create('color', { | ||
53 | duration: theme.transitions.duration.short, | ||
54 | }), | ||
55 | '@media (hover: none)': { | ||
56 | color: theme.palette.text.primary, | ||
57 | }, | ||
58 | }, | ||
59 | '.cm-gutters:hover .cm-foldGutter': { | ||
60 | color: theme.palette.text.primary, | ||
61 | }, | ||
62 | '.cm-activeLineGutter': { | ||
63 | background: 'transparent', | ||
64 | }, | ||
65 | '.cm-lineNumbers': { | ||
66 | ...(!showLineNumbers && { | ||
67 | display: 'none !important', | ||
68 | }), | ||
69 | '.cm-activeLineGutter': { | ||
70 | color: theme.palette.text.primary, | ||
71 | }, | ||
72 | }, | ||
73 | '.cm-cursor, .cm-cursor-primary': { | ||
74 | borderLeft: `2px solid ${theme.palette.primary.main}`, | ||
75 | }, | ||
76 | '.cm-selectionBackground': { | ||
77 | background: theme.palette.selection.main, | ||
78 | }, | ||
79 | '.cm-focused': { | ||
80 | outline: 'none', | ||
81 | '.cm-selectionBackground': { | ||
82 | background: theme.palette.selection.main, | ||
83 | }, | ||
84 | }, | ||
85 | '.cm-panels-top': { | ||
86 | color: theme.palette.text.secondary, | ||
87 | }, | ||
88 | '.cm-panel': { | ||
89 | '&, & button, & input': { | ||
90 | fontFamily: theme.typography.fontFamily, | ||
91 | }, | ||
92 | background: theme.palette.background.default, | ||
93 | borderTop: `1px solid ${theme.palette.divider2}`, | ||
94 | 'button[name="close"]': { | ||
95 | background: 'transparent', | ||
96 | color: theme.palette.text.secondary, | ||
97 | cursor: 'pointer', | ||
98 | }, | ||
99 | }, | ||
100 | '.cm-panel.cm-panel-lint': { | ||
101 | boderBottom: 'none', | ||
102 | 'button[name="close"]': { | ||
103 | // Close button interferes with scrollbar, so we better hide it. | ||
104 | // The panel can still be closed from the toolbar. | ||
105 | display: 'none', | ||
106 | }, | ||
107 | ul: { | ||
108 | li: { | ||
109 | cursor: 'pointer', | ||
110 | color: theme.palette.text.primary, | ||
111 | }, | ||
112 | '[aria-selected], &:focus [aria-selected]': { | ||
113 | background: theme.palette.selection.main, | ||
114 | color: theme.palette.selection.contrastText, | ||
115 | }, | ||
116 | }, | ||
117 | }, | ||
118 | '.cm-foldPlaceholder': { | ||
119 | color: theme.palette.text.secondary, | ||
120 | backgroundColor: alpha(theme.palette.text.secondary, 0), | ||
121 | border: `1px solid ${alpha(theme.palette.text.secondary, 0.5)}`, | ||
122 | borderRadius: theme.shape.borderRadius, | ||
123 | transition: theme.transitions.create( | ||
124 | ['background-color', 'border-color', 'color'], | ||
125 | { | ||
126 | duration: theme.transitions.duration.short, | ||
127 | }, | ||
128 | ), | ||
129 | '&:hover': { | ||
130 | backgroundColor: alpha( | ||
131 | theme.palette.text.secondary, | ||
132 | theme.palette.action.hoverOpacity, | ||
133 | ), | ||
134 | borderColor: theme.palette.text.secondary, | ||
135 | '@media (hover: none)': { | ||
136 | backgroundColor: 'transparent', | ||
137 | }, | ||
138 | }, | ||
139 | }, | ||
140 | '.tok-comment': { | ||
141 | fontStyle: 'italic', | ||
142 | color: theme.palette.highlight.comment, | ||
143 | }, | ||
144 | '.tok-number': { | ||
145 | color: theme.palette.highlight.number, | ||
146 | }, | ||
147 | '.tok-string': { | ||
148 | color: theme.palette.secondary.dark, | ||
149 | }, | ||
150 | '.tok-keyword': { | ||
151 | color: theme.palette.primary.main, | ||
152 | }, | ||
153 | '.tok-typeName, .tok-macroName, .tok-atom': { | ||
154 | color: theme.palette.text.primary, | ||
155 | }, | ||
156 | '.tok-variableName': { | ||
157 | color: theme.palette.highlight.parameter, | ||
158 | }, | ||
159 | '.tok-problem-node': { | ||
160 | '&, & .tok-variableName': { | ||
161 | color: theme.palette.text.secondary, | ||
162 | }, | ||
163 | }, | ||
164 | '.tok-problem-individual': { | ||
165 | '&, & .tok-variableName': { | ||
166 | color: theme.palette.text.primary, | ||
167 | }, | ||
168 | }, | ||
169 | '.tok-problem-abstract, .tok-problem-new': { | ||
170 | fontStyle: 'italic', | ||
171 | }, | ||
172 | '.tok-problem-containment': { | ||
173 | fontWeight: 700, | ||
174 | }, | ||
175 | '.tok-problem-error': { | ||
176 | '&, & .tok-typeName': { | ||
177 | color: theme.palette.error.main, | ||
178 | }, | ||
179 | }, | ||
180 | '.tok-problem-builtin': { | ||
181 | '&, & .tok-typeName, & .tok-atom, & .tok-variableName': { | ||
182 | color: theme.palette.primary.main, | ||
183 | fontWeight: 400, | ||
184 | fontStyle: 'normal', | ||
185 | }, | ||
186 | }, | ||
187 | '.cm-tooltip-autocomplete': { | ||
188 | background: theme.palette.background.paper, | ||
189 | ...(theme.palette.mode === 'dark' && { | ||
190 | overflow: 'hidden', | ||
191 | borderRadius: theme.shape.borderRadius, | ||
192 | // https://github.com/mui/material-ui/blob/10c72729c7d03bab8cdce6eb422642684c56dca2/packages/mui-material/src/Paper/Paper.js#L18 | ||
193 | backgroundImage: | ||
194 | 'linear-gradient(rgba(255, 255, 255, 0.09), rgba(255, 255, 255, 0.09))', | ||
195 | }), | ||
196 | boxShadow: theme.shadows[4], | ||
197 | '.cm-completionIcon': { | ||
198 | color: theme.palette.text.secondary, | ||
199 | }, | ||
200 | '.cm-completionLabel': { | ||
201 | color: theme.palette.text.primary, | ||
202 | }, | ||
203 | '.cm-completionDetail': { | ||
204 | color: theme.palette.text.secondary, | ||
205 | fontStyle: 'normal', | ||
206 | }, | ||
207 | '[aria-selected]': { | ||
208 | background: `${theme.palette.primary.main} !important`, | ||
209 | '.cm-completionIcon, .cm-completionLabel, .cm-completionDetail': { | ||
210 | color: theme.palette.primary.contrastText, | ||
211 | }, | ||
212 | }, | ||
213 | }, | ||
214 | '.cm-completionIcon': { | ||
215 | width: 16, | ||
216 | padding: 0, | ||
217 | marginRight: '0.5em', | ||
218 | textAlign: 'center', | ||
219 | }, | ||
220 | ...codeMirrorLintStyle, | ||
221 | '.cm-problem-read': { | ||
222 | background: theme.palette.highlight.occurences.read, | ||
223 | }, | ||
224 | '.cm-problem-write': { | ||
225 | background: theme.palette.highlight.occurences.write, | ||
226 | }, | ||
227 | }; | ||
228 | }); | ||
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; |
diff --git a/subprojects/frontend/src/editor/EditorTheme.ts b/subprojects/frontend/src/editor/EditorTheme.ts new file mode 100644 index 00000000..8d98e832 --- /dev/null +++ b/subprojects/frontend/src/editor/EditorTheme.ts | |||
@@ -0,0 +1,342 @@ | |||
1 | import errorSVG from '@material-icons/svg/svg/error/baseline.svg?raw'; | ||
2 | import expandMoreSVG from '@material-icons/svg/svg/expand_more/baseline.svg?raw'; | ||
3 | import infoSVG from '@material-icons/svg/svg/info/baseline.svg?raw'; | ||
4 | import warningSVG from '@material-icons/svg/svg/warning/baseline.svg?raw'; | ||
5 | import { alpha, styled } from '@mui/material/styles'; | ||
6 | |||
7 | import editorClassNames from './editorClassNames'; | ||
8 | |||
9 | function svgURL(svg: string): string { | ||
10 | return `url('data:image/svg+xml;utf8,${svg}')`; | ||
11 | } | ||
12 | |||
13 | export default styled('div', { | ||
14 | name: 'EditorTheme', | ||
15 | shouldForwardProp: (propName) => propName !== 'showLineNumbers', | ||
16 | })<{ showLineNumbers: boolean }>(({ theme, showLineNumbers }) => { | ||
17 | let codeMirrorLintStyle: Record<string, unknown> = {}; | ||
18 | ( | ||
19 | [ | ||
20 | { | ||
21 | severity: 'error', | ||
22 | icon: errorSVG, | ||
23 | }, | ||
24 | { | ||
25 | severity: 'warning', | ||
26 | icon: warningSVG, | ||
27 | }, | ||
28 | { | ||
29 | severity: 'info', | ||
30 | icon: infoSVG, | ||
31 | }, | ||
32 | ] as const | ||
33 | ).forEach(({ severity, icon }) => { | ||
34 | const palette = theme.palette[severity]; | ||
35 | const color = palette.main; | ||
36 | const iconStyle = { | ||
37 | background: color, | ||
38 | maskImage: svgURL(icon), | ||
39 | maskSize: '16px 16px', | ||
40 | height: 16, | ||
41 | width: 16, | ||
42 | }; | ||
43 | const tooltipColor = | ||
44 | theme.palette.mode === 'dark' ? palette.main : palette.light; | ||
45 | codeMirrorLintStyle = { | ||
46 | ...codeMirrorLintStyle, | ||
47 | [`.cm-lintRange-${severity}`]: { | ||
48 | backgroundImage: 'none', | ||
49 | textDecoration: `underline wavy ${color}`, | ||
50 | textDecorationSkipInk: 'none', | ||
51 | }, | ||
52 | [`.cm-diagnostic-${severity}`]: { | ||
53 | marginLeft: 0, | ||
54 | padding: '4px 8px 4px 32px', | ||
55 | borderLeft: 'none', | ||
56 | position: 'relative', | ||
57 | '::before': { | ||
58 | ...iconStyle, | ||
59 | content: '" "', | ||
60 | position: 'absolute', | ||
61 | top: 6, | ||
62 | left: 8, | ||
63 | }, | ||
64 | }, | ||
65 | [`.cm-tooltip .cm-diagnostic-${severity}::before`]: { | ||
66 | background: tooltipColor, | ||
67 | }, | ||
68 | [`.cm-lint-marker-${severity}`]: { | ||
69 | ...iconStyle, | ||
70 | display: 'block', | ||
71 | margin: '4px 0', | ||
72 | // Remove original CodeMirror icon. | ||
73 | content: '""', | ||
74 | '::before': { | ||
75 | // Remove original CodeMirror icon. | ||
76 | content: '""', | ||
77 | display: 'none', | ||
78 | }, | ||
79 | }, | ||
80 | }; | ||
81 | }); | ||
82 | |||
83 | return { | ||
84 | background: theme.palette.background.default, | ||
85 | '&, .cm-editor': { | ||
86 | height: '100%', | ||
87 | }, | ||
88 | '.cm-content': { | ||
89 | padding: 0, | ||
90 | }, | ||
91 | '.cm-scroller': { | ||
92 | color: theme.palette.text.secondary, | ||
93 | }, | ||
94 | '.cm-scroller, .cm-tooltip-autocomplete, .cm-completionLabel, .cm-completionDetail': | ||
95 | { | ||
96 | ...theme.typography.body1, | ||
97 | fontFamily: '"JetBrains MonoVariable", "JetBrains Mono", monospace', | ||
98 | fontFeatureSettings: '"liga", "calt"', | ||
99 | letterSpacing: 0, | ||
100 | textRendering: 'optimizeLegibility', | ||
101 | }, | ||
102 | '.cm-gutters': { | ||
103 | background: 'transparent', | ||
104 | color: theme.palette.text.disabled, | ||
105 | border: 'none', | ||
106 | }, | ||
107 | '.cm-specialChar': { | ||
108 | color: theme.palette.secondary.main, | ||
109 | }, | ||
110 | '.cm-activeLine': { | ||
111 | background: theme.palette.highlight.activeLine, | ||
112 | }, | ||
113 | '.cm-gutter-lint': { | ||
114 | width: 16, | ||
115 | '.cm-gutterElement': { | ||
116 | padding: 0, | ||
117 | }, | ||
118 | }, | ||
119 | '.cm-foldGutter': { | ||
120 | opacity: 0, | ||
121 | width: 16, | ||
122 | transition: theme.transitions.create('opacity', { | ||
123 | duration: theme.transitions.duration.short, | ||
124 | }), | ||
125 | '@media (hover: none)': { | ||
126 | opacity: 1, | ||
127 | }, | ||
128 | }, | ||
129 | '.cm-gutters:hover .cm-foldGutter': { | ||
130 | opacity: 1, | ||
131 | }, | ||
132 | [`.${editorClassNames.foldMarker}`]: { | ||
133 | display: 'block', | ||
134 | margin: '4px 0', | ||
135 | padding: 0, | ||
136 | maskImage: svgURL(expandMoreSVG), | ||
137 | maskSize: '16px 16px', | ||
138 | height: 16, | ||
139 | width: 16, | ||
140 | background: theme.palette.text.primary, | ||
141 | border: 'none', | ||
142 | cursor: 'pointer', | ||
143 | }, | ||
144 | [`.${editorClassNames.foldMarkerClosed}`]: { | ||
145 | transform: 'rotate(-90deg)', | ||
146 | }, | ||
147 | '.cm-activeLineGutter': { | ||
148 | background: 'transparent', | ||
149 | }, | ||
150 | '.cm-lineNumbers': { | ||
151 | ...(!showLineNumbers && { | ||
152 | display: 'none !important', | ||
153 | }), | ||
154 | '.cm-activeLineGutter': { | ||
155 | color: theme.palette.text.primary, | ||
156 | }, | ||
157 | }, | ||
158 | '.cm-cursor, .cm-cursor-primary': { | ||
159 | borderLeft: `2px solid ${theme.palette.primary.main}`, | ||
160 | }, | ||
161 | '.cm-selectionBackground': { | ||
162 | background: theme.palette.highlight.selection, | ||
163 | }, | ||
164 | '.cm-focused': { | ||
165 | outline: 'none', | ||
166 | '.cm-selectionBackground': { | ||
167 | background: theme.palette.highlight.selection, | ||
168 | }, | ||
169 | }, | ||
170 | '.cm-panels-top': { | ||
171 | color: theme.palette.text.secondary, | ||
172 | borderBottom: `1px solid ${theme.palette.outer.border}`, | ||
173 | marginBottom: theme.spacing(1), | ||
174 | }, | ||
175 | '.cm-panel': { | ||
176 | position: 'relative', | ||
177 | overflow: 'hidden', | ||
178 | background: theme.palette.outer.background, | ||
179 | borderTop: `1px solid ${theme.palette.outer.border}`, | ||
180 | '&, & button, & input': { | ||
181 | fontFamily: theme.typography.fontFamily, | ||
182 | }, | ||
183 | 'button[name="close"]': { | ||
184 | background: 'transparent', | ||
185 | color: theme.palette.text.secondary, | ||
186 | cursor: 'pointer', | ||
187 | }, | ||
188 | }, | ||
189 | '.cm-panel.cm-panel-lint': { | ||
190 | borderTop: `1px solid ${theme.palette.outer.border}`, | ||
191 | borderBottom: 'none', | ||
192 | 'button[name="close"]': { | ||
193 | // Close button interferes with scrollbar, so we better hide it. | ||
194 | // The panel can still be closed from the toolbar. | ||
195 | display: 'none', | ||
196 | }, | ||
197 | ul: { | ||
198 | maxHeight: 'max(112px, 20vh)', | ||
199 | li: { | ||
200 | cursor: 'pointer', | ||
201 | color: theme.palette.text.primary, | ||
202 | }, | ||
203 | '.cm-diagnostic': { | ||
204 | ...theme.typography.body2, | ||
205 | '&[aria-selected="true"]': { | ||
206 | color: theme.palette.text.primary, | ||
207 | background: 'transparent', | ||
208 | fontWeight: 700, | ||
209 | }, | ||
210 | ':hover': { | ||
211 | background: alpha( | ||
212 | theme.palette.text.primary, | ||
213 | theme.palette.action.hoverOpacity, | ||
214 | ), | ||
215 | }, | ||
216 | }, | ||
217 | }, | ||
218 | }, | ||
219 | [`.${editorClassNames.foldPlaceholder}`]: { | ||
220 | ...theme.typography.body1, | ||
221 | padding: 0, | ||
222 | fontFamily: 'inherit', | ||
223 | fontFeatureSettings: '"liga", "calt"', | ||
224 | color: theme.palette.text.secondary, | ||
225 | backgroundColor: alpha( | ||
226 | theme.palette.text.secondary, | ||
227 | theme.palette.action.focusOpacity, | ||
228 | ), | ||
229 | border: 'none', | ||
230 | cursor: 'pointer', | ||
231 | transition: theme.transitions.create(['background-color', 'color'], { | ||
232 | duration: theme.transitions.duration.short, | ||
233 | }), | ||
234 | '&:hover': { | ||
235 | color: theme.palette.text.primary, | ||
236 | backgroundColor: alpha( | ||
237 | theme.palette.text.secondary, | ||
238 | theme.palette.action.focusOpacity + theme.palette.action.hoverOpacity, | ||
239 | ), | ||
240 | }, | ||
241 | }, | ||
242 | '.tok-comment': { | ||
243 | fontStyle: 'italic', | ||
244 | color: theme.palette.highlight.comment, | ||
245 | }, | ||
246 | '.tok-number': { | ||
247 | color: theme.palette.highlight.number, | ||
248 | }, | ||
249 | '.tok-string': { | ||
250 | color: theme.palette.secondary, | ||
251 | }, | ||
252 | '.tok-keyword': { | ||
253 | color: theme.palette.primary.main, | ||
254 | }, | ||
255 | '.tok-typeName, .tok-atom': { | ||
256 | color: theme.palette.text.primary, | ||
257 | }, | ||
258 | '.tok-variableName': { | ||
259 | color: theme.palette.highlight.parameter, | ||
260 | }, | ||
261 | '.tok-problem-node': { | ||
262 | '&, & .tok-variableName': { | ||
263 | color: theme.palette.text.secondary, | ||
264 | }, | ||
265 | }, | ||
266 | '.tok-problem-individual': { | ||
267 | '&, & .tok-variableName': { | ||
268 | color: theme.palette.text.primary, | ||
269 | }, | ||
270 | }, | ||
271 | '.tok-problem-abstract, .tok-problem-new': { | ||
272 | fontStyle: 'italic', | ||
273 | }, | ||
274 | '.tok-problem-containment': { | ||
275 | fontWeight: 700, | ||
276 | }, | ||
277 | '.tok-problem-error': { | ||
278 | '&, & .tok-typeName': { | ||
279 | color: theme.palette.error.main, | ||
280 | }, | ||
281 | }, | ||
282 | '.tok-problem-builtin': { | ||
283 | '&, & .tok-typeName, & .tok-atom, & .tok-variableName': { | ||
284 | color: theme.palette.primary.main, | ||
285 | fontWeight: 400, | ||
286 | fontStyle: 'normal', | ||
287 | }, | ||
288 | }, | ||
289 | '.cm-tooltip.cm-tooltip-autocomplete': { | ||
290 | background: theme.palette.background.paper, | ||
291 | borderRadius: theme.shape.borderRadius, | ||
292 | overflow: 'hidden', | ||
293 | ...(theme.palette.mode === 'dark' && { | ||
294 | // https://github.com/mui/material-ui/blob/10c72729c7d03bab8cdce6eb422642684c56dca2/packages/mui-material/src/Paper/Paper.js#L18 | ||
295 | backgroundImage: | ||
296 | 'linear-gradient(rgba(255, 255, 255, 0.07), rgba(255, 255, 255, 0.07))', | ||
297 | }), | ||
298 | boxShadow: theme.shadows[2], | ||
299 | '.cm-completionIcon': { | ||
300 | color: theme.palette.text.secondary, | ||
301 | }, | ||
302 | '.cm-completionLabel': { | ||
303 | color: theme.palette.text.primary, | ||
304 | }, | ||
305 | '.cm-completionDetail': { | ||
306 | color: theme.palette.text.secondary, | ||
307 | fontStyle: 'normal', | ||
308 | }, | ||
309 | 'li[aria-selected="true"]': { | ||
310 | background: alpha( | ||
311 | theme.palette.text.primary, | ||
312 | theme.palette.action.focusOpacity, | ||
313 | ), | ||
314 | '.cm-completionIcon, .cm-completionLabel, .cm-completionDetail': { | ||
315 | color: theme.palette.text.primary, | ||
316 | }, | ||
317 | }, | ||
318 | }, | ||
319 | '.cm-tooltip.cm-tooltip-hover, .cm-tooltip.cm-tooltip-lint': { | ||
320 | ...theme.typography.body2, | ||
321 | // https://github.com/mui/material-ui/blob/dee9529f7a298c54ae760761112c3ae9ba082137/packages/mui-material/src/Tooltip/Tooltip.js#L121-L125 | ||
322 | background: alpha(theme.palette.grey[700], 0.92), | ||
323 | borderRadius: theme.shape.borderRadius, | ||
324 | color: theme.palette.common.white, | ||
325 | overflow: 'hidden', | ||
326 | maxWidth: 400, | ||
327 | }, | ||
328 | '.cm-completionIcon': { | ||
329 | width: 16, | ||
330 | padding: 0, | ||
331 | marginRight: '0.5em', | ||
332 | textAlign: 'center', | ||
333 | }, | ||
334 | ...codeMirrorLintStyle, | ||
335 | '.cm-problem-read': { | ||
336 | background: theme.palette.highlight.occurences.read, | ||
337 | }, | ||
338 | '.cm-problem-write': { | ||
339 | background: theme.palette.highlight.occurences.write, | ||
340 | }, | ||
341 | }; | ||
342 | }); | ||
diff --git a/subprojects/frontend/src/editor/GenerateButton.tsx b/subprojects/frontend/src/editor/GenerateButton.tsx index 4d5c4e44..0eed129e 100644 --- a/subprojects/frontend/src/editor/GenerateButton.tsx +++ b/subprojects/frontend/src/editor/GenerateButton.tsx | |||
@@ -22,7 +22,7 @@ function GenerateButton(): JSX.Element { | |||
22 | 22 | ||
23 | if (errorCount > 0) { | 23 | if (errorCount > 0) { |
24 | return ( | 24 | return ( |
25 | <Button color="error" onClick={() => editorStore.toggleLintPanel()}> | 25 | <Button color="error" onClick={() => editorStore.nextDiagnostic()}> |
26 | {summary} | 26 | {summary} |
27 | </Button> | 27 | </Button> |
28 | ); | 28 | ); |
diff --git a/subprojects/frontend/src/editor/PanelStore.ts b/subprojects/frontend/src/editor/PanelStore.ts new file mode 100644 index 00000000..653d309c --- /dev/null +++ b/subprojects/frontend/src/editor/PanelStore.ts | |||
@@ -0,0 +1,90 @@ | |||
1 | import type { Command } from '@codemirror/view'; | ||
2 | import { action, makeObservable, observable } from 'mobx'; | ||
3 | |||
4 | import getLogger from '../utils/getLogger'; | ||
5 | |||
6 | import type EditorStore from './EditorStore'; | ||
7 | |||
8 | const log = getLogger('editor.PanelStore'); | ||
9 | |||
10 | export default class PanelStore { | ||
11 | state = false; | ||
12 | |||
13 | constructor( | ||
14 | private readonly panelId: string, | ||
15 | private readonly openCommand: Command, | ||
16 | private readonly closeCommand: Command, | ||
17 | private readonly store: EditorStore, | ||
18 | ) { | ||
19 | makeObservable(this, { | ||
20 | state: observable, | ||
21 | open: action, | ||
22 | close: action, | ||
23 | toggle: action, | ||
24 | synchronizeStateToView: action, | ||
25 | }); | ||
26 | } | ||
27 | |||
28 | open(): boolean { | ||
29 | return this.setState(true); | ||
30 | } | ||
31 | |||
32 | close(): boolean { | ||
33 | return this.setState(false); | ||
34 | } | ||
35 | |||
36 | toggle(): void { | ||
37 | this.setState(!this.state); | ||
38 | } | ||
39 | |||
40 | private setState(newState: boolean): boolean { | ||
41 | if (this.state === newState) { | ||
42 | return false; | ||
43 | } | ||
44 | log.debug('Show', this.panelId, 'panel', newState); | ||
45 | if (newState) { | ||
46 | this.doOpen(); | ||
47 | } else { | ||
48 | this.doClose(); | ||
49 | } | ||
50 | this.state = newState; | ||
51 | return true; | ||
52 | } | ||
53 | |||
54 | synchronizeStateToView(): void { | ||
55 | this.doClose(); | ||
56 | if (this.state) { | ||
57 | this.doOpen(); | ||
58 | } | ||
59 | } | ||
60 | |||
61 | private doOpen(): void { | ||
62 | if (!this.store.doCommand(this.openCommand)) { | ||
63 | return; | ||
64 | } | ||
65 | const { view } = this.store; | ||
66 | if (view === undefined) { | ||
67 | return; | ||
68 | } | ||
69 | const buttonQuery = `.cm-${this.panelId}.cm-panel button[name="close"]`; | ||
70 | const closeButton = view.dom.querySelector(buttonQuery); | ||
71 | if (closeButton !== null) { | ||
72 | log.debug('Addig close button callback to', this.panelId, 'panel'); | ||
73 | // We must remove the event listener from the button that dispatches a transaction | ||
74 | // without going through `EditorStore`. This listened is added by CodeMirror, | ||
75 | // and we can only remove it by cloning the DOM node: https://stackoverflow.com/a/9251864 | ||
76 | const closeButtonWithoutListeners = closeButton.cloneNode(true); | ||
77 | closeButtonWithoutListeners.addEventListener('click', (event) => { | ||
78 | this.close(); | ||
79 | event.preventDefault(); | ||
80 | }); | ||
81 | closeButton.replaceWith(closeButtonWithoutListeners); | ||
82 | } else { | ||
83 | log.error('Opened', this.panelId, 'panel has no close button'); | ||
84 | } | ||
85 | } | ||
86 | |||
87 | private doClose(): void { | ||
88 | this.store.doCommand(this.closeCommand); | ||
89 | } | ||
90 | } | ||
diff --git a/subprojects/frontend/src/editor/createEditorState.ts b/subprojects/frontend/src/editor/createEditorState.ts new file mode 100644 index 00000000..33346c05 --- /dev/null +++ b/subprojects/frontend/src/editor/createEditorState.ts | |||
@@ -0,0 +1,121 @@ | |||
1 | import { | ||
2 | closeBrackets, | ||
3 | closeBracketsKeymap, | ||
4 | autocompletion, | ||
5 | completionKeymap, | ||
6 | } from '@codemirror/autocomplete'; | ||
7 | import { | ||
8 | defaultKeymap, | ||
9 | history, | ||
10 | historyKeymap, | ||
11 | indentWithTab, | ||
12 | } from '@codemirror/commands'; | ||
13 | import { | ||
14 | bracketMatching, | ||
15 | codeFolding, | ||
16 | foldGutter, | ||
17 | foldKeymap, | ||
18 | indentOnInput, | ||
19 | syntaxHighlighting, | ||
20 | } from '@codemirror/language'; | ||
21 | import { lintKeymap, lintGutter } from '@codemirror/lint'; | ||
22 | import { search, searchKeymap } from '@codemirror/search'; | ||
23 | import { EditorState } from '@codemirror/state'; | ||
24 | import { | ||
25 | drawSelection, | ||
26 | highlightActiveLine, | ||
27 | highlightActiveLineGutter, | ||
28 | highlightSpecialChars, | ||
29 | keymap, | ||
30 | lineNumbers, | ||
31 | rectangularSelection, | ||
32 | } from '@codemirror/view'; | ||
33 | import { classHighlighter } from '@lezer/highlight'; | ||
34 | |||
35 | import problemLanguageSupport from '../language/problemLanguageSupport'; | ||
36 | |||
37 | import type EditorStore from './EditorStore'; | ||
38 | import editorClassNames from './editorClassNames'; | ||
39 | import findOccurrences from './findOccurrences'; | ||
40 | import semanticHighlighting from './semanticHighlighting'; | ||
41 | |||
42 | export default function createEditorState( | ||
43 | initialValue: string, | ||
44 | store: EditorStore, | ||
45 | ): EditorState { | ||
46 | return EditorState.create({ | ||
47 | doc: initialValue, | ||
48 | extensions: [ | ||
49 | autocompletion({ | ||
50 | activateOnTyping: true, | ||
51 | override: [(context) => store.contentAssist(context)], | ||
52 | }), | ||
53 | closeBrackets(), | ||
54 | bracketMatching(), | ||
55 | drawSelection(), | ||
56 | EditorState.allowMultipleSelections.of(true), | ||
57 | findOccurrences, | ||
58 | highlightActiveLine(), | ||
59 | highlightActiveLineGutter(), | ||
60 | highlightSpecialChars(), | ||
61 | history(), | ||
62 | indentOnInput(), | ||
63 | rectangularSelection(), | ||
64 | search({ top: true }), | ||
65 | syntaxHighlighting(classHighlighter), | ||
66 | semanticHighlighting, | ||
67 | // We add the gutters to `extensions` in the order we want them to appear. | ||
68 | lintGutter(), | ||
69 | lineNumbers(), | ||
70 | codeFolding({ | ||
71 | placeholderDOM(_view, onClick) { | ||
72 | const button = document.createElement('button'); | ||
73 | button.className = editorClassNames.foldPlaceholder; | ||
74 | button.ariaLabel = 'Unfold lines'; | ||
75 | button.innerText = '...'; | ||
76 | button.onclick = onClick; | ||
77 | return button; | ||
78 | }, | ||
79 | }), | ||
80 | foldGutter({ | ||
81 | markerDOM(open) { | ||
82 | const button = document.createElement('button'); | ||
83 | button.className = [ | ||
84 | editorClassNames.foldMarker, | ||
85 | open | ||
86 | ? editorClassNames.foldMarkerOpen | ||
87 | : editorClassNames.foldMarkerClosed, | ||
88 | ].join(' '); | ||
89 | button.ariaPressed = open ? 'true' : 'false'; | ||
90 | button.ariaLabel = 'Fold lines'; | ||
91 | return button; | ||
92 | }, | ||
93 | }), | ||
94 | keymap.of([ | ||
95 | { key: 'Mod-Shift-f', run: () => store.formatText() }, | ||
96 | ...closeBracketsKeymap, | ||
97 | ...completionKeymap, | ||
98 | ...foldKeymap, | ||
99 | ...historyKeymap, | ||
100 | indentWithTab, | ||
101 | // Override keys in `lintKeymap` to go through the `EditorStore`. | ||
102 | { key: 'Mod-Shift-m', run: () => store.lintPanel.open() }, | ||
103 | ...lintKeymap, | ||
104 | // Override keys in `searchKeymap` to go through the `EditorStore`. | ||
105 | { | ||
106 | key: 'Mod-f', | ||
107 | run: () => store.searchPanel.open(), | ||
108 | scope: 'editor search-panel', | ||
109 | }, | ||
110 | { | ||
111 | key: 'Escape', | ||
112 | run: () => store.searchPanel.close(), | ||
113 | scope: 'editor search-panel', | ||
114 | }, | ||
115 | ...searchKeymap, | ||
116 | ...defaultKeymap, | ||
117 | ]), | ||
118 | problemLanguageSupport(), | ||
119 | ], | ||
120 | }); | ||
121 | } | ||
diff --git a/subprojects/frontend/src/editor/editorClassNames.ts b/subprojects/frontend/src/editor/editorClassNames.ts new file mode 100644 index 00000000..5584e8c2 --- /dev/null +++ b/subprojects/frontend/src/editor/editorClassNames.ts | |||
@@ -0,0 +1,10 @@ | |||
1 | const PREFIX = 'problem-editor-'; | ||
2 | |||
3 | const editorClassNames = { | ||
4 | foldPlaceholder: `${PREFIX}fold-placeholder`, | ||
5 | foldMarker: `${PREFIX}fold-marker`, | ||
6 | foldMarkerClosed: `${PREFIX}fold-marker-closed`, | ||
7 | foldMarkerOpen: `${PREFIX}fold-marker-open`, | ||
8 | }; | ||
9 | |||
10 | export default editorClassNames; | ||
diff --git a/subprojects/frontend/src/language/problemLanguageSupport.ts b/subprojects/frontend/src/language/problemLanguageSupport.ts index 246135d8..07a884e7 100644 --- a/subprojects/frontend/src/language/problemLanguageSupport.ts +++ b/subprojects/frontend/src/language/problemLanguageSupport.ts | |||
@@ -28,18 +28,18 @@ const parserWithMetadata = parser.configure({ | |||
28 | BlockComment: t.blockComment, | 28 | BlockComment: t.blockComment, |
29 | 'problem class enum pred rule indiv scope': t.definitionKeyword, | 29 | 'problem class enum pred rule indiv scope': t.definitionKeyword, |
30 | 'abstract extends refers contains opposite error default': t.modifier, | 30 | 'abstract extends refers contains opposite error default': t.modifier, |
31 | 'true false unknown error': t.keyword, | 31 | 'true false unknown error': t.operatorKeyword, |
32 | 'may must current count': t.operatorKeyword, | 32 | 'may must current count': t.operatorKeyword, |
33 | 'new delete': t.operatorKeyword, | 33 | 'new delete': t.keyword, |
34 | NotOp: t.operator, | 34 | NotOp: t.operator, |
35 | UnknownOp: t.operator, | 35 | UnknownOp: t.operator, |
36 | OrOp: t.punctuation, | 36 | OrOp: t.separator, |
37 | StarArgument: t.keyword, | 37 | StarArgument: t.keyword, |
38 | 'IntMult StarMult Real': t.number, | 38 | 'IntMult StarMult Real': t.number, |
39 | StarMult: t.number, | 39 | StarMult: t.number, |
40 | String: t.string, | 40 | String: t.string, |
41 | 'RelationName/QualifiedName': t.typeName, | 41 | 'RelationName/QualifiedName': t.typeName, |
42 | 'RuleName/QualifiedName': t.macroName, | 42 | 'RuleName/QualifiedName': t.typeName, |
43 | 'IndividualNodeName/QualifiedName': t.atom, | 43 | 'IndividualNodeName/QualifiedName': t.atom, |
44 | 'VariableName/QualifiedName': t.variableName, | 44 | 'VariableName/QualifiedName': t.variableName, |
45 | '{ }': t.brace, | 45 | '{ }': t.brace, |
diff --git a/subprojects/frontend/src/theme/EditorTheme.ts b/subprojects/frontend/src/theme/EditorTheme.ts deleted file mode 100644 index a16b4c3b..00000000 --- a/subprojects/frontend/src/theme/EditorTheme.ts +++ /dev/null | |||
@@ -1,7 +0,0 @@ | |||
1 | enum EditorTheme { | ||
2 | Light, | ||
3 | Dark, | ||
4 | Default = EditorTheme.Dark, | ||
5 | } | ||
6 | |||
7 | export default EditorTheme; | ||
diff --git a/subprojects/frontend/src/theme/ThemeProvider.tsx b/subprojects/frontend/src/theme/ThemeProvider.tsx index 9a8fdd44..dd4f5bb8 100644 --- a/subprojects/frontend/src/theme/ThemeProvider.tsx +++ b/subprojects/frontend/src/theme/ThemeProvider.tsx | |||
@@ -11,13 +11,17 @@ import React, { type ReactNode } from 'react'; | |||
11 | 11 | ||
12 | import { useRootStore } from '../RootStore'; | 12 | import { useRootStore } from '../RootStore'; |
13 | 13 | ||
14 | import EditorTheme from './EditorTheme'; | 14 | interface OuterPalette { |
15 | background: string; | ||
16 | border: string; | ||
17 | } | ||
15 | 18 | ||
16 | interface HighlightStyles { | 19 | interface HighlightPalette { |
17 | number: string; | 20 | number: string; |
18 | parameter: string; | 21 | parameter: string; |
19 | comment: string; | 22 | comment: string; |
20 | activeLine: string; | 23 | activeLine: string; |
24 | selection: string; | ||
21 | occurences: { | 25 | occurences: { |
22 | read: string; | 26 | read: string; |
23 | write: string; | 27 | write: string; |
@@ -26,19 +30,17 @@ interface HighlightStyles { | |||
26 | 30 | ||
27 | declare module '@mui/material/styles' { | 31 | declare module '@mui/material/styles' { |
28 | interface Palette { | 32 | interface Palette { |
29 | divider2: string; | 33 | outer: OuterPalette; |
30 | selection: Palette['primary']; | 34 | highlight: HighlightPalette; |
31 | highlight: HighlightStyles; | ||
32 | } | 35 | } |
33 | 36 | ||
34 | interface PaletteOptions { | 37 | interface PaletteOptions { |
35 | divider2: string; | 38 | outer: OuterPalette; |
36 | selection: PaletteOptions['primary']; | 39 | highlight: HighlightPalette; |
37 | highlight: HighlightStyles; | ||
38 | } | 40 | } |
39 | } | 41 | } |
40 | 42 | ||
41 | function getMUIThemeOptions(currentTheme: EditorTheme): ThemeOptions { | 43 | function getMUIThemeOptions(darkMode: boolean): ThemeOptions { |
42 | const components: Components = { | 44 | const components: Components = { |
43 | MuiButton: { | 45 | MuiButton: { |
44 | styleOverrides: { | 46 | styleOverrides: { |
@@ -67,32 +69,8 @@ function getMUIThemeOptions(currentTheme: EditorTheme): ThemeOptions { | |||
67 | }, | 69 | }, |
68 | }; | 70 | }; |
69 | 71 | ||
70 | switch (currentTheme) { | 72 | return darkMode |
71 | case EditorTheme.Light: | 73 | ? { |
72 | return { | ||
73 | components, | ||
74 | palette: { | ||
75 | mode: 'light', | ||
76 | primary: { main: '#0097a7' }, | ||
77 | selection: { | ||
78 | main: '#c8e4fb', | ||
79 | contrastText: '#000', | ||
80 | }, | ||
81 | divider2: '#d7d7d7', | ||
82 | highlight: { | ||
83 | number: '#1976d2', | ||
84 | parameter: '#6a3e3e', | ||
85 | comment: alpha('#000', 0.38), | ||
86 | activeLine: '#f5f5f5', | ||
87 | occurences: { | ||
88 | read: '#ceccf7', | ||
89 | write: '#f0d8a8', | ||
90 | }, | ||
91 | }, | ||
92 | }, | ||
93 | }; | ||
94 | case EditorTheme.Dark: | ||
95 | return { | ||
96 | components, | 74 | components, |
97 | palette: { | 75 | palette: { |
98 | mode: 'dark', | 76 | mode: 'dark', |
@@ -111,34 +89,53 @@ function getMUIThemeOptions(currentTheme: EditorTheme): ThemeOptions { | |||
111 | disabled: '#4b5263', | 89 | disabled: '#4b5263', |
112 | }, | 90 | }, |
113 | divider: alpha('#abb2bf', 0.16), | 91 | divider: alpha('#abb2bf', 0.16), |
114 | divider2: '#181a1f', | 92 | outer: { |
115 | selection: { | 93 | background: '#21252b', |
116 | main: '#3e4453', | 94 | border: '#181a1f', |
117 | contrastText: '#fff', | ||
118 | }, | 95 | }, |
119 | highlight: { | 96 | highlight: { |
120 | number: '#6188a6', | 97 | number: '#6188a6', |
121 | parameter: '#c8ae9d', | 98 | parameter: '#c8ae9d', |
122 | comment: '#6b717d', | 99 | comment: '#6b717d', |
123 | activeLine: '#21252b', | 100 | activeLine: '#21252b', |
101 | selection: '#3e4453', | ||
124 | occurences: { | 102 | occurences: { |
125 | read: 'rgba(255, 255, 255, 0.15)', | 103 | read: 'rgba(255, 255, 255, 0.15)', |
126 | write: 'rgba(255, 255, 128, 0.4)', | 104 | write: 'rgba(255, 255, 128, 0.4)', |
127 | }, | 105 | }, |
128 | }, | 106 | }, |
129 | }, | 107 | }, |
108 | } | ||
109 | : { | ||
110 | components, | ||
111 | palette: { | ||
112 | mode: 'light', | ||
113 | primary: { main: '#0097a7' }, | ||
114 | outer: { | ||
115 | background: '#f5f5f5', | ||
116 | border: '#d7d7d7', | ||
117 | }, | ||
118 | highlight: { | ||
119 | number: '#1976d2', | ||
120 | parameter: '#6a3e3e', | ||
121 | comment: alpha('#000', 0.38), | ||
122 | activeLine: '#f5f5f5', | ||
123 | selection: '#c8e4fb', | ||
124 | occurences: { | ||
125 | read: '#ceccf7', | ||
126 | write: '#f0d8a8', | ||
127 | }, | ||
128 | }, | ||
129 | }, | ||
130 | }; | 130 | }; |
131 | default: | ||
132 | throw new Error(`Unknown theme: ${currentTheme}`); | ||
133 | } | ||
134 | } | 131 | } |
135 | 132 | ||
136 | function ThemeProvider({ children }: { children?: ReactNode }) { | 133 | function ThemeProvider({ children }: { children?: ReactNode }) { |
137 | const { | 134 | const { |
138 | themeStore: { currentTheme }, | 135 | themeStore: { darkMode }, |
139 | } = useRootStore(); | 136 | } = useRootStore(); |
140 | 137 | ||
141 | const themeOptions = getMUIThemeOptions(currentTheme); | 138 | const themeOptions = getMUIThemeOptions(darkMode); |
142 | const theme = responsiveFontSizes(createTheme(themeOptions)); | 139 | const theme = responsiveFontSizes(createTheme(themeOptions)); |
143 | 140 | ||
144 | return ( | 141 | return ( |
diff --git a/subprojects/frontend/src/theme/ThemeStore.ts b/subprojects/frontend/src/theme/ThemeStore.ts index ded1f29a..11391b06 100644 --- a/subprojects/frontend/src/theme/ThemeStore.ts +++ b/subprojects/frontend/src/theme/ThemeStore.ts | |||
@@ -1,28 +1,16 @@ | |||
1 | import { makeAutoObservable } from 'mobx'; | 1 | import { action, makeObservable, observable } from 'mobx'; |
2 | |||
3 | import EditorTheme from './EditorTheme'; | ||
4 | 2 | ||
5 | export default class ThemeStore { | 3 | export default class ThemeStore { |
6 | currentTheme: EditorTheme = EditorTheme.Default; | 4 | darkMode = true; |
7 | 5 | ||
8 | constructor() { | 6 | constructor() { |
9 | makeAutoObservable(this); | 7 | makeObservable(this, { |
8 | darkMode: observable, | ||
9 | toggleDarkMode: action, | ||
10 | }); | ||
10 | } | 11 | } |
11 | 12 | ||
12 | toggleDarkMode(): void { | 13 | toggleDarkMode(): void { |
13 | switch (this.currentTheme) { | 14 | this.darkMode = !this.darkMode; |
14 | case EditorTheme.Light: | ||
15 | this.currentTheme = EditorTheme.Dark; | ||
16 | break; | ||
17 | case EditorTheme.Dark: | ||
18 | this.currentTheme = EditorTheme.Light; | ||
19 | break; | ||
20 | default: | ||
21 | throw new Error(`Unknown theme: ${this.currentTheme}`); | ||
22 | } | ||
23 | } | ||
24 | |||
25 | get darkMode(): boolean { | ||
26 | return this.currentTheme === EditorTheme.Dark; | ||
27 | } | 15 | } |
28 | } | 16 | } |