aboutsummaryrefslogtreecommitdiffstats
path: root/subprojects
diff options
context:
space:
mode:
authorLibravatar Kristóf Marussy <kristof@marussy.com>2022-08-16 21:14:50 +0200
committerLibravatar Kristóf Marussy <kristof@marussy.com>2022-08-16 21:14:50 +0200
commit19cd11118cde7160cd447c81bc965007c0437479 (patch)
tree5fea613e7a46d69380995368a68cc72f186078a4 /subprojects
parentchore(deps): bump frontend dependencies (diff)
downloadrefinery-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')
-rw-r--r--subprojects/frontend/package.json15
-rw-r--r--subprojects/frontend/src/RootStore.tsx6
-rw-r--r--subprojects/frontend/src/TopBar.tsx4
-rw-r--r--subprojects/frontend/src/editor/EditorArea.tsx138
-rw-r--r--subprojects/frontend/src/editor/EditorButtons.tsx10
-rw-r--r--subprojects/frontend/src/editor/EditorParent.ts228
-rw-r--r--subprojects/frontend/src/editor/EditorStore.ts320
-rw-r--r--subprojects/frontend/src/editor/EditorTheme.ts342
-rw-r--r--subprojects/frontend/src/editor/GenerateButton.tsx2
-rw-r--r--subprojects/frontend/src/editor/PanelStore.ts90
-rw-r--r--subprojects/frontend/src/editor/createEditorState.ts121
-rw-r--r--subprojects/frontend/src/editor/editorClassNames.ts10
-rw-r--r--subprojects/frontend/src/language/problemLanguageSupport.ts8
-rw-r--r--subprojects/frontend/src/theme/EditorTheme.ts7
-rw-r--r--subprojects/frontend/src/theme/ThemeProvider.tsx85
-rw-r--r--subprojects/frontend/src/theme/ThemeStore.ts26
-rw-r--r--subprojects/frontend/tsconfig.base.json1
17 files changed, 784 insertions, 629 deletions
diff --git a/subprojects/frontend/package.json b/subprojects/frontend/package.json
index d438c104..69ff74c6 100644
--- a/subprojects/frontend/package.json
+++ b/subprojects/frontend/package.json
@@ -26,7 +26,7 @@
26 "@codemirror/commands": "^6.0.1", 26 "@codemirror/commands": "^6.0.1",
27 "@codemirror/language": "^6.2.1", 27 "@codemirror/language": "^6.2.1",
28 "@codemirror/lint": "^6.0.0", 28 "@codemirror/lint": "^6.0.0",
29 "@codemirror/search": "^6.0.1", 29 "@codemirror/search": "^6.1.0",
30 "@codemirror/state": "^6.1.1", 30 "@codemirror/state": "^6.1.1",
31 "@codemirror/view": "^6.2.0", 31 "@codemirror/view": "^6.2.0",
32 "@emotion/react": "^11.10.0", 32 "@emotion/react": "^11.10.0",
@@ -36,9 +36,10 @@
36 "@fontsource/roboto": "^4.5.8", 36 "@fontsource/roboto": "^4.5.8",
37 "@lezer/common": "^1.0.0", 37 "@lezer/common": "^1.0.0",
38 "@lezer/highlight": "^1.0.0", 38 "@lezer/highlight": "^1.0.0",
39 "@lezer/lr": "^1.2.2", 39 "@lezer/lr": "^1.2.3",
40 "@material-icons/svg": "^1.0.32",
40 "@mui/icons-material": "5.8.4", 41 "@mui/icons-material": "5.8.4",
41 "@mui/material": "5.10.0", 42 "@mui/material": "5.10.1",
42 "ansi-styles": "^6.1.0", 43 "ansi-styles": "^6.1.0",
43 "escape-string-regexp": "^5.0.0", 44 "escape-string-regexp": "^5.0.0",
44 "loglevel": "^1.8.0", 45 "loglevel": "^1.8.0",
@@ -53,12 +54,12 @@
53 "devDependencies": { 54 "devDependencies": {
54 "@lezer/generator": "^1.1.1", 55 "@lezer/generator": "^1.1.1",
55 "@types/eslint": "^8.4.5", 56 "@types/eslint": "^8.4.5",
56 "@types/node": "^18.7.4", 57 "@types/node": "^18.7.6",
57 "@types/prettier": "^2.7.0", 58 "@types/prettier": "^2.7.0",
58 "@types/react": "^18.0.17", 59 "@types/react": "^18.0.17",
59 "@types/react-dom": "^18.0.6", 60 "@types/react-dom": "^18.0.6",
60 "@typescript-eslint/eslint-plugin": "^5.33.0", 61 "@typescript-eslint/eslint-plugin": "^5.33.1",
61 "@typescript-eslint/parser": "^5.33.0", 62 "@typescript-eslint/parser": "^5.33.1",
62 "@vitejs/plugin-react": "^2.0.1", 63 "@vitejs/plugin-react": "^2.0.1",
63 "cross-env": "^7.0.3", 64 "cross-env": "^7.0.3",
64 "eslint": "^8.22.0", 65 "eslint": "^8.22.0",
@@ -73,7 +74,7 @@
73 "eslint-plugin-react-hooks": "^4.6.0", 74 "eslint-plugin-react-hooks": "^4.6.0",
74 "prettier": "^2.7.1", 75 "prettier": "^2.7.1",
75 "typescript": "~4.7.4", 76 "typescript": "~4.7.4",
76 "vite": "^3.0.7", 77 "vite": "^3.0.8",
77 "vite-plugin-inject-preload": "^1.0.1" 78 "vite-plugin-inject-preload": "^1.0.1"
78 } 79 }
79} 80}
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';
4import ThemeStore from './theme/ThemeStore'; 4import ThemeStore from './theme/ThemeStore';
5 5
6export default class RootStore { 6export 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 @@
1import { closeLintPanel, openLintPanel } from '@codemirror/lint'; 1import { useTheme } from '@mui/material/styles';
2import { closeSearchPanel, openSearchPanel } from '@codemirror/search';
3import { type Command, EditorView } from '@codemirror/view';
4import { observer } from 'mobx-react-lite'; 2import { observer } from 'mobx-react-lite';
5import React, { useCallback, useEffect, useRef, useState } from 'react'; 3import React, { useCallback, useEffect } from 'react';
6 4
7import { useRootStore } from '../RootStore'; 5import { useRootStore } from '../RootStore';
8import getLogger from '../utils/getLogger';
9 6
10import EditorParent from './EditorParent'; 7import EditorTheme from './EditorTheme';
11
12const log = getLogger('editor.EditorArea');
13
14function 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
62function 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
69function EditorArea(): JSX.Element { 9function 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
22function getLintIcon(severity: Diagnostic['severity'] | null) { 22function 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 @@
1import { alpha, styled } from '@mui/material/styles';
2
3export 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 @@
1import { CompletionContext, CompletionResult } from '@codemirror/autocomplete';
2import { redo, redoDepth, undo, undoDepth } from '@codemirror/commands';
1import { 3import {
2 closeBrackets, 4 type Diagnostic,
3 closeBracketsKeymap, 5 setDiagnostics,
4 autocompletion, 6 closeLintPanel,
5 completionKeymap, 7 openLintPanel,
6} from '@codemirror/autocomplete'; 8 nextDiagnostic,
9} from '@codemirror/lint';
10import { closeSearchPanel, openSearchPanel } from '@codemirror/search';
7import { 11import {
8 defaultKeymap,
9 history,
10 historyKeymap,
11 indentWithTab,
12 redo,
13 redoDepth,
14 undo,
15 undoDepth,
16} from '@codemirror/commands';
17import {
18 bracketMatching,
19 foldGutter,
20 foldKeymap,
21 indentOnInput,
22 syntaxHighlighting,
23} from '@codemirror/language';
24import { type Diagnostic, lintKeymap, setDiagnostics } from '@codemirror/lint';
25import { search, searchKeymap } from '@codemirror/search';
26import {
27 EditorState,
28 type StateCommand, 12 type StateCommand,
29 StateEffect, 13 StateEffect,
30 type Transaction, 14 type Transaction,
31 type TransactionSpec, 15 type TransactionSpec,
16 type EditorState,
32} from '@codemirror/state'; 17} from '@codemirror/state';
33import { 18import { type Command, EditorView } from '@codemirror/view';
34 drawSelection, 19import { action, computed, makeObservable, observable } from 'mobx';
35 EditorView, 20
36 highlightActiveLine,
37 highlightActiveLineGutter,
38 highlightSpecialChars,
39 keymap,
40 lineNumbers,
41 rectangularSelection,
42} from '@codemirror/view';
43import { classHighlighter } from '@lezer/highlight';
44import { makeAutoObservable, observable, reaction } from 'mobx';
45
46import problemLanguageSupport from '../language/problemLanguageSupport';
47import type ThemeStore from '../theme/ThemeStore';
48import getLogger from '../utils/getLogger'; 21import getLogger from '../utils/getLogger';
49import XtextClient from '../xtext/XtextClient'; 22import XtextClient from '../xtext/XtextClient';
50 23
51import findOccurrences, { 24import PanelStore from './PanelStore';
52 type IOccurrence, 25import createEditorState from './createEditorState';
53 setOccurrences, 26import { type IOccurrence, setOccurrences } from './findOccurrences';
54} from './findOccurrences'; 27import {
55import semanticHighlighting, {
56 type IHighlightRange, 28 type IHighlightRange,
57 setSemanticHighlighting, 29 setSemanticHighlighting,
58} from './semanticHighlighting'; 30} from './semanticHighlighting';
@@ -60,17 +32,17 @@ import semanticHighlighting, {
60const log = getLogger('editor.EditorStore'); 32const log = getLogger('editor.EditorStore');
61 33
62export default class EditorStore { 34export default class EditorStore {
63 private readonly themeStore;
64
65 state: EditorState; 35 state: EditorState;
66 36
67 private readonly client: XtextClient; 37 private readonly client: XtextClient;
68 38
69 showLineNumbers = false; 39 view: EditorView | undefined;
70 40
71 showSearchPanel = false; 41 readonly searchPanel: PanelStore;
72 42
73 showLintPanel = false; 43 readonly lintPanel: PanelStore;
44
45 showLineNumbers = false;
74 46
75 errorCount = 0; 47 errorCount = 0;
76 48
@@ -78,116 +50,124 @@ export default class EditorStore {
78 50
79 infoCount = 0; 51 infoCount = 0;
80 52
81 private readonly defaultDispatcher = (tr: Transaction): void => { 53 constructor(initialValue: string) {
82 this.onTransaction(tr); 54 this.state = createEditorState(initialValue, this);
83 };
84
85 private dispatcher = this.defaultDispatcher;
86
87 constructor(initialValue: string, themeStore: ThemeStore) {
88 this.themeStore = themeStore;
89 this.state = EditorState.create({
90 doc: initialValue,
91 extensions: [
92 autocompletion({
93 activateOnTyping: true,
94 override: [(context) => this.client.contentAssist(context)],
95 }),
96 closeBrackets(),
97 bracketMatching(),
98 drawSelection(),
99 EditorState.allowMultipleSelections.of(true),
100 EditorView.theme(
101 {},
102 {
103 dark: this.themeStore.darkMode,
104 },
105 ),
106 findOccurrences,
107 highlightActiveLine(),
108 highlightActiveLineGutter(),
109 highlightSpecialChars(),
110 history(),
111 indentOnInput(),
112 rectangularSelection(),
113 search({
114 top: true,
115 caseSensitive: true,
116 }),
117 syntaxHighlighting(classHighlighter),
118 semanticHighlighting,
119 // We add the gutters to `extensions` in the order we want them to appear.
120 lineNumbers(),
121 foldGutter(),
122 keymap.of([
123 { key: 'Mod-Shift-f', run: () => this.formatText() },
124 ...closeBracketsKeymap,
125 ...completionKeymap,
126 ...foldKeymap,
127 ...historyKeymap,
128 indentWithTab,
129 // Override keys in `lintKeymap` to go through the `EditorStore`.
130 { key: 'Mod-Shift-m', run: () => this.setLintPanelOpen(true) },
131 ...lintKeymap,
132 // Override keys in `searchKeymap` to go through the `EditorStore`.
133 {
134 key: 'Mod-f',
135 run: () => this.setSearchPanelOpen(true),
136 scope: 'editor search-panel',
137 },
138 {
139 key: 'Escape',
140 run: () => this.setSearchPanelOpen(false),
141 scope: 'editor search-panel',
142 },
143 ...searchKeymap,
144 ...defaultKeymap,
145 ]),
146 problemLanguageSupport(),
147 ],
148 });
149 this.client = new XtextClient(this); 55 this.client = new XtextClient(this);
150 reaction( 56 this.searchPanel = new PanelStore(
151 () => this.themeStore.darkMode, 57 'search',
152 (darkMode) => { 58 openSearchPanel,
153 log.debug('Update editor dark mode', darkMode); 59 closeSearchPanel,
154 this.dispatch({ 60 this,
155 effects: [ 61 );
156 StateEffect.appendConfig.of( 62 this.lintPanel = new PanelStore(
157 EditorView.theme( 63 'panel-lint',
158 {}, 64 openLintPanel,
159 { 65 closeLintPanel,
160 dark: darkMode, 66 this,
161 },
162 ),
163 ),
164 ],
165 });
166 },
167 ); 67 );
168 makeAutoObservable(this, { 68 makeObservable(this, {
169 state: observable.ref, 69 state: observable.ref,
70 view: observable.ref,
71 showLineNumbers: observable,
72 errorCount: observable,
73 warningCount: observable,
74 infoCount: observable,
75 highestDiagnosticLevel: computed,
76 canUndo: computed,
77 canRedo: computed,
78 setDarkMode: action,
79 setEditorParent: action,
80 dispatch: action,
81 dispatchTransaction: action,
82 doCommand: action,
83 doStateCommand: action,
84 updateDiagnostics: action,
85 nextDiagnostic: action,
86 updateOccurrences: action,
87 updateSemanticHighlighting: action,
88 undo: action,
89 redo: action,
90 toggleLineNumbers: action,
170 }); 91 });
171 } 92 }
172 93
173 updateDispatcher(newDispatcher: ((tr: Transaction) => void) | null): void { 94 setDarkMode(darkMode: boolean): void {
174 this.dispatcher = newDispatcher || this.defaultDispatcher; 95 log.debug('Update editor dark mode', darkMode);
96 this.dispatch({
97 effects: [
98 StateEffect.appendConfig.of([EditorView.darkTheme.of(darkMode)]),
99 ],
100 });
175 } 101 }
176 102
177 onTransaction(tr: Transaction): void { 103 setEditorParent(editorParent: Element | null): void {
178 log.trace('Editor transaction', tr); 104 if (this.view !== undefined) {
179 this.state = tr.state; 105 this.view.destroy();
180 this.client.onTransaction(tr); 106 }
107 if (editorParent === null) {
108 this.view = undefined;
109 return;
110 }
111 const view = new EditorView({
112 state: this.state,
113 parent: editorParent,
114 dispatch: (transaction) => {
115 this.dispatchTransactionWithoutView(transaction);
116 view.update([transaction]);
117 if (view.state !== this.state) {
118 log.error(
119 'Failed to synchronize editor state - store state:',
120 this.state,
121 'view state:',
122 view.state,
123 );
124 }
125 },
126 });
127 this.view = view;
128 this.searchPanel.synchronizeStateToView();
129 this.lintPanel.synchronizeStateToView();
130
131 // Reported by Lighthouse 8.3.0.
132 const { contentDOM } = view;
133 contentDOM.removeAttribute('aria-expanded');
134 contentDOM.setAttribute('aria-label', 'Code editor');
135
136 log.info('Editor created');
181 } 137 }
182 138
183 dispatch(...specs: readonly TransactionSpec[]): void { 139 dispatch(...specs: readonly TransactionSpec[]): void {
184 this.dispatcher(this.state.update(...specs)); 140 const transaction = this.state.update(...specs);
141 this.dispatchTransaction(transaction);
142 }
143
144 dispatchTransaction(transaction: Transaction): void {
145 if (this.view === undefined) {
146 this.dispatchTransactionWithoutView(transaction);
147 } else {
148 this.view.dispatch(transaction);
149 }
150 }
151
152 private readonly dispatchTransactionWithoutView = action(
153 (tr: Transaction) => {
154 log.trace('Editor transaction', tr);
155 this.state = tr.state;
156 this.client.onTransaction(tr);
157 },
158 );
159
160 doCommand(command: Command): boolean {
161 if (this.view === undefined) {
162 return false;
163 }
164 return command(this.view);
185 } 165 }
186 166
187 doStateCommand(command: StateCommand): boolean { 167 doStateCommand(command: StateCommand): boolean {
188 return command({ 168 return command({
189 state: this.state, 169 state: this.state,
190 dispatch: this.dispatcher, 170 dispatch: (transaction) => this.dispatchTransaction(transaction),
191 }); 171 });
192 } 172 }
193 173
@@ -213,7 +193,11 @@ export default class EditorStore {
213 }); 193 });
214 } 194 }
215 195
216 get highestDiagnosticLevel(): Diagnostic['severity'] | null { 196 nextDiagnostic(): void {
197 this.doCommand(nextDiagnostic);
198 }
199
200 get highestDiagnosticLevel(): Diagnostic['severity'] | undefined {
217 if (this.errorCount > 0) { 201 if (this.errorCount > 0) {
218 return 'error'; 202 return 'error';
219 } 203 }
@@ -223,7 +207,7 @@ export default class EditorStore {
223 if (this.infoCount > 0) { 207 if (this.infoCount > 0) {
224 return 'info'; 208 return 'info';
225 } 209 }
226 return null; 210 return undefined;
227 } 211 }
228 212
229 updateSemanticHighlighting(ranges: IHighlightRange[]): void { 213 updateSemanticHighlighting(ranges: IHighlightRange[]): void {
@@ -234,6 +218,10 @@ export default class EditorStore {
234 this.dispatch(setOccurrences(write, read)); 218 this.dispatch(setOccurrences(write, read));
235 } 219 }
236 220
221 contentAssist(context: CompletionContext): Promise<CompletionResult> {
222 return this.client.contentAssist(context);
223 }
224
237 /** 225 /**
238 * @returns `true` if there is history to undo 226 * @returns `true` if there is history to undo
239 */ 227 */
@@ -241,7 +229,6 @@ export default class EditorStore {
241 return undoDepth(this.state) > 0; 229 return undoDepth(this.state) > 0;
242 } 230 }
243 231
244 // eslint-disable-next-line class-methods-use-this
245 undo(): void { 232 undo(): void {
246 log.debug('Undo', this.doStateCommand(undo)); 233 log.debug('Undo', this.doStateCommand(undo));
247 } 234 }
@@ -253,7 +240,6 @@ export default class EditorStore {
253 return redoDepth(this.state) > 0; 240 return redoDepth(this.state) > 0;
254 } 241 }
255 242
256 // eslint-disable-next-line class-methods-use-this
257 redo(): void { 243 redo(): void {
258 log.debug('Redo', this.doStateCommand(redo)); 244 log.debug('Redo', this.doStateCommand(redo));
259 } 245 }
@@ -263,44 +249,6 @@ export default class EditorStore {
263 log.debug('Show line numbers', this.showLineNumbers); 249 log.debug('Show line numbers', this.showLineNumbers);
264 } 250 }
265 251
266 /**
267 * Sets whether the CodeMirror search panel should be open.
268 *
269 * This method can be used as a CodeMirror command,
270 * because it returns `false` if it didn't execute,
271 * allowing other commands for the same keybind to run instead.
272 * This matches the behavior of the `openSearchPanel` and `closeSearchPanel`
273 * commands from `'@codemirror/search'`.
274 *
275 * @param newShowSearchPanel whether we should show the search panel
276 * @returns `true` if the state was changed, `false` otherwise
277 */
278 setSearchPanelOpen(newShowSearchPanel: boolean): boolean {
279 if (this.showSearchPanel === newShowSearchPanel) {
280 return false;
281 }
282 this.showSearchPanel = newShowSearchPanel;
283 log.debug('Show search panel', this.showSearchPanel);
284 return true;
285 }
286
287 toggleSearchPanel(): void {
288 this.setSearchPanelOpen(!this.showSearchPanel);
289 }
290
291 setLintPanelOpen(newShowLintPanel: boolean): boolean {
292 if (this.showLintPanel === newShowLintPanel) {
293 return false;
294 }
295 this.showLintPanel = newShowLintPanel;
296 log.debug('Show lint panel', this.showLintPanel);
297 return true;
298 }
299
300 toggleLintPanel(): void {
301 this.setLintPanelOpen(!this.showLintPanel);
302 }
303
304 formatText(): boolean { 252 formatText(): boolean {
305 this.client.formatText(); 253 this.client.formatText();
306 return true; 254 return true;
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 @@
1import errorSVG from '@material-icons/svg/svg/error/baseline.svg?raw';
2import expandMoreSVG from '@material-icons/svg/svg/expand_more/baseline.svg?raw';
3import infoSVG from '@material-icons/svg/svg/info/baseline.svg?raw';
4import warningSVG from '@material-icons/svg/svg/warning/baseline.svg?raw';
5import { alpha, styled } from '@mui/material/styles';
6
7import editorClassNames from './editorClassNames';
8
9function svgURL(svg: string): string {
10 return `url('data:image/svg+xml;utf8,${svg}')`;
11}
12
13export 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 @@
1import type { Command } from '@codemirror/view';
2import { action, makeObservable, observable } from 'mobx';
3
4import getLogger from '../utils/getLogger';
5
6import type EditorStore from './EditorStore';
7
8const log = getLogger('editor.PanelStore');
9
10export 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 @@
1import {
2 closeBrackets,
3 closeBracketsKeymap,
4 autocompletion,
5 completionKeymap,
6} from '@codemirror/autocomplete';
7import {
8 defaultKeymap,
9 history,
10 historyKeymap,
11 indentWithTab,
12} from '@codemirror/commands';
13import {
14 bracketMatching,
15 codeFolding,
16 foldGutter,
17 foldKeymap,
18 indentOnInput,
19 syntaxHighlighting,
20} from '@codemirror/language';
21import { lintKeymap, lintGutter } from '@codemirror/lint';
22import { search, searchKeymap } from '@codemirror/search';
23import { EditorState } from '@codemirror/state';
24import {
25 drawSelection,
26 highlightActiveLine,
27 highlightActiveLineGutter,
28 highlightSpecialChars,
29 keymap,
30 lineNumbers,
31 rectangularSelection,
32} from '@codemirror/view';
33import { classHighlighter } from '@lezer/highlight';
34
35import problemLanguageSupport from '../language/problemLanguageSupport';
36
37import type EditorStore from './EditorStore';
38import editorClassNames from './editorClassNames';
39import findOccurrences from './findOccurrences';
40import semanticHighlighting from './semanticHighlighting';
41
42export 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 @@
1const PREFIX = 'problem-editor-';
2
3const 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
10export 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 @@
1enum EditorTheme {
2 Light,
3 Dark,
4 Default = EditorTheme.Dark,
5}
6
7export 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
12import { useRootStore } from '../RootStore'; 12import { useRootStore } from '../RootStore';
13 13
14import EditorTheme from './EditorTheme'; 14interface OuterPalette {
15 background: string;
16 border: string;
17}
15 18
16interface HighlightStyles { 19interface 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
27declare module '@mui/material/styles' { 31declare 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
41function getMUIThemeOptions(currentTheme: EditorTheme): ThemeOptions { 43function 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
136function ThemeProvider({ children }: { children?: ReactNode }) { 133function 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 @@
1import { makeAutoObservable } from 'mobx'; 1import { action, makeObservable, observable } from 'mobx';
2
3import EditorTheme from './EditorTheme';
4 2
5export default class ThemeStore { 3export 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}
diff --git a/subprojects/frontend/tsconfig.base.json b/subprojects/frontend/tsconfig.base.json
index e33e330e..9cc8ace4 100644
--- a/subprojects/frontend/tsconfig.base.json
+++ b/subprojects/frontend/tsconfig.base.json
@@ -5,6 +5,7 @@
5 "moduleResolution": "Node", 5 "moduleResolution": "Node",
6 "esModuleInterop": true, 6 "esModuleInterop": true,
7 "allowSyntheticDefaultImports": true, 7 "allowSyntheticDefaultImports": true,
8 "useDefineForClassFields": true,
8 "strict": true, 9 "strict": true,
9 "noImplicitOverride": true, 10 "noImplicitOverride": true,
10 "noImplicitReturns": true, 11 "noImplicitReturns": true,