aboutsummaryrefslogtreecommitdiffstats
path: root/subprojects/frontend/src/editor
diff options
context:
space:
mode:
authorLibravatar Kristóf Marussy <kristof@marussy.com>2021-12-13 02:07:04 +0100
committerLibravatar Kristóf Marussy <kristof@marussy.com>2021-12-14 02:14:23 +0100
commita96c52b21e7e590bbdd70b80896780a446fa2e8b (patch)
tree663619baa254577bb2f5342192e80bca692ad91d /subprojects/frontend/src/editor
parentbuild: move modules into subproject directory (diff)
downloadrefinery-a96c52b21e7e590bbdd70b80896780a446fa2e8b.tar.gz
refinery-a96c52b21e7e590bbdd70b80896780a446fa2e8b.tar.zst
refinery-a96c52b21e7e590bbdd70b80896780a446fa2e8b.zip
build: separate module for frontend
This allows us to simplify the webpack configuration and the gradle build scripts.
Diffstat (limited to 'subprojects/frontend/src/editor')
-rw-r--r--subprojects/frontend/src/editor/EditorArea.tsx152
-rw-r--r--subprojects/frontend/src/editor/EditorButtons.tsx98
-rw-r--r--subprojects/frontend/src/editor/EditorParent.ts205
-rw-r--r--subprojects/frontend/src/editor/EditorStore.ts289
-rw-r--r--subprojects/frontend/src/editor/GenerateButton.tsx44
-rw-r--r--subprojects/frontend/src/editor/decorationSetExtension.ts39
-rw-r--r--subprojects/frontend/src/editor/findOccurrences.ts35
-rw-r--r--subprojects/frontend/src/editor/semanticHighlighting.ts24
8 files changed, 886 insertions, 0 deletions
diff --git a/subprojects/frontend/src/editor/EditorArea.tsx b/subprojects/frontend/src/editor/EditorArea.tsx
new file mode 100644
index 00000000..dba20f6e
--- /dev/null
+++ b/subprojects/frontend/src/editor/EditorArea.tsx
@@ -0,0 +1,152 @@
1import { Command, EditorView } from '@codemirror/view';
2import { closeSearchPanel, openSearchPanel } from '@codemirror/search';
3import { closeLintPanel, openLintPanel } from '@codemirror/lint';
4import { observer } from 'mobx-react-lite';
5import React, {
6 useCallback,
7 useEffect,
8 useRef,
9 useState,
10} from 'react';
11
12import { EditorParent } from './EditorParent';
13import { useRootStore } from '../RootStore';
14import { getLogger } from '../utils/logger';
15
16const log = getLogger('editor.EditorArea');
17
18function usePanel(
19 panelId: string,
20 stateToSet: boolean,
21 editorView: EditorView | null,
22 openCommand: Command,
23 closeCommand: Command,
24 closeCallback: () => void,
25) {
26 const [cachedViewState, setCachedViewState] = useState<boolean>(false);
27 useEffect(() => {
28 if (editorView === null || cachedViewState === stateToSet) {
29 return;
30 }
31 if (stateToSet) {
32 openCommand(editorView);
33 const buttonQuery = `.cm-${panelId}.cm-panel button[name="close"]`;
34 const closeButton = editorView.dom.querySelector(buttonQuery);
35 if (closeButton) {
36 log.debug('Addig close button callback to', panelId, 'panel');
37 // We must remove the event listener added by CodeMirror from the button
38 // that dispatches a transaction without going through `EditorStorre`.
39 // Cloning a DOM node removes event listeners,
40 // see https://stackoverflow.com/a/9251864
41 const closeButtonWithoutListeners = closeButton.cloneNode(true);
42 closeButtonWithoutListeners.addEventListener('click', (event) => {
43 closeCallback();
44 event.preventDefault();
45 });
46 closeButton.replaceWith(closeButtonWithoutListeners);
47 } else {
48 log.error('Opened', panelId, 'panel has no close button');
49 }
50 } else {
51 closeCommand(editorView);
52 }
53 setCachedViewState(stateToSet);
54 }, [
55 stateToSet,
56 editorView,
57 cachedViewState,
58 panelId,
59 openCommand,
60 closeCommand,
61 closeCallback,
62 ]);
63 return setCachedViewState;
64}
65
66function fixCodeMirrorAccessibility(editorView: EditorView) {
67 // Reported by Lighthouse 8.3.0.
68 const { contentDOM } = editorView;
69 contentDOM.removeAttribute('aria-expanded');
70 contentDOM.setAttribute('aria-label', 'Code editor');
71}
72
73export const EditorArea = observer(() => {
74 const { editorStore } = useRootStore();
75 const editorParentRef = useRef<HTMLDivElement | null>(null);
76 const [editorViewState, setEditorViewState] = useState<EditorView | null>(null);
77
78 const setSearchPanelOpen = usePanel(
79 'search',
80 editorStore.showSearchPanel,
81 editorViewState,
82 openSearchPanel,
83 closeSearchPanel,
84 useCallback(() => editorStore.setSearchPanelOpen(false), [editorStore]),
85 );
86
87 const setLintPanelOpen = usePanel(
88 'panel-lint',
89 editorStore.showLintPanel,
90 editorViewState,
91 openLintPanel,
92 closeLintPanel,
93 useCallback(() => editorStore.setLintPanelOpen(false), [editorStore]),
94 );
95
96 useEffect(() => {
97 if (editorParentRef.current === null) {
98 return () => {
99 // Nothing to clean up.
100 };
101 }
102
103 const editorView = new EditorView({
104 state: editorStore.state,
105 parent: editorParentRef.current,
106 dispatch: (transaction) => {
107 editorStore.onTransaction(transaction);
108 editorView.update([transaction]);
109 if (editorView.state !== editorStore.state) {
110 log.error(
111 'Failed to synchronize editor state - store state:',
112 editorStore.state,
113 'view state:',
114 editorView.state,
115 );
116 }
117 },
118 });
119 fixCodeMirrorAccessibility(editorView);
120 setEditorViewState(editorView);
121 setSearchPanelOpen(false);
122 setLintPanelOpen(false);
123 // `dispatch` is bound to the view instance,
124 // so it does not have to be called as a method.
125 // eslint-disable-next-line @typescript-eslint/unbound-method
126 editorStore.updateDispatcher(editorView.dispatch);
127 log.info('Editor created');
128
129 return () => {
130 editorStore.updateDispatcher(null);
131 editorView.destroy();
132 log.info('Editor destroyed');
133 };
134 }, [
135 editorParentRef,
136 editorStore,
137 setSearchPanelOpen,
138 setLintPanelOpen,
139 ]);
140
141 return (
142 <EditorParent
143 className="dark"
144 sx={{
145 '.cm-lineNumbers': editorStore.showLineNumbers ? {} : {
146 display: 'none !important',
147 },
148 }}
149 ref={editorParentRef}
150 />
151 );
152});
diff --git a/subprojects/frontend/src/editor/EditorButtons.tsx b/subprojects/frontend/src/editor/EditorButtons.tsx
new file mode 100644
index 00000000..150aa00d
--- /dev/null
+++ b/subprojects/frontend/src/editor/EditorButtons.tsx
@@ -0,0 +1,98 @@
1import type { Diagnostic } from '@codemirror/lint';
2import { observer } from 'mobx-react-lite';
3import IconButton from '@mui/material/IconButton';
4import Stack from '@mui/material/Stack';
5import ToggleButton from '@mui/material/ToggleButton';
6import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
7import CheckIcon from '@mui/icons-material/Check';
8import ErrorIcon from '@mui/icons-material/Error';
9import FormatListNumberedIcon from '@mui/icons-material/FormatListNumbered';
10import FormatPaint from '@mui/icons-material/FormatPaint';
11import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
12import RedoIcon from '@mui/icons-material/Redo';
13import SearchIcon from '@mui/icons-material/Search';
14import UndoIcon from '@mui/icons-material/Undo';
15import WarningIcon from '@mui/icons-material/Warning';
16import React from 'react';
17
18import { useRootStore } from '../RootStore';
19
20// Exhastive switch as proven by TypeScript.
21// eslint-disable-next-line consistent-return
22function getLintIcon(severity: Diagnostic['severity'] | null) {
23 switch (severity) {
24 case 'error':
25 return <ErrorIcon fontSize="small" />;
26 case 'warning':
27 return <WarningIcon fontSize="small" />;
28 case 'info':
29 return <InfoOutlinedIcon fontSize="small" />;
30 case null:
31 return <CheckIcon fontSize="small" />;
32 }
33}
34
35export const EditorButtons = observer(() => {
36 const { editorStore } = useRootStore();
37
38 return (
39 <Stack
40 direction="row"
41 spacing={1}
42 >
43 <Stack
44 direction="row"
45 alignItems="center"
46 >
47 <IconButton
48 disabled={!editorStore.canUndo}
49 onClick={() => editorStore.undo()}
50 aria-label="Undo"
51 >
52 <UndoIcon fontSize="small" />
53 </IconButton>
54 <IconButton
55 disabled={!editorStore.canRedo}
56 onClick={() => editorStore.redo()}
57 aria-label="Redo"
58 >
59 <RedoIcon fontSize="small" />
60 </IconButton>
61 </Stack>
62 <ToggleButtonGroup
63 size="small"
64 >
65 <ToggleButton
66 selected={editorStore.showLineNumbers}
67 onClick={() => editorStore.toggleLineNumbers()}
68 aria-label="Show line numbers"
69 value="show-line-numbers"
70 >
71 <FormatListNumberedIcon fontSize="small" />
72 </ToggleButton>
73 <ToggleButton
74 selected={editorStore.showSearchPanel}
75 onClick={() => editorStore.toggleSearchPanel()}
76 aria-label="Show find/replace"
77 value="show-search-panel"
78 >
79 <SearchIcon fontSize="small" />
80 </ToggleButton>
81 <ToggleButton
82 selected={editorStore.showLintPanel}
83 onClick={() => editorStore.toggleLintPanel()}
84 aria-label="Show diagnostics panel"
85 value="show-lint-panel"
86 >
87 {getLintIcon(editorStore.highestDiagnosticLevel)}
88 </ToggleButton>
89 </ToggleButtonGroup>
90 <IconButton
91 onClick={() => editorStore.formatText()}
92 aria-label="Automatic format"
93 >
94 <FormatPaint fontSize="small" />
95 </IconButton>
96 </Stack>
97 );
98});
diff --git a/subprojects/frontend/src/editor/EditorParent.ts b/subprojects/frontend/src/editor/EditorParent.ts
new file mode 100644
index 00000000..94ca24ea
--- /dev/null
+++ b/subprojects/frontend/src/editor/EditorParent.ts
@@ -0,0 +1,205 @@
1import { styled } from '@mui/material/styles';
2
3/**
4 * Returns a squiggly underline background image encoded as a CSS `url()` data URI with Base64.
5 *
6 * Based on
7 * https://github.com/codemirror/lint/blob/f524b4a53b0183bb343ac1e32b228d28030d17af/src/lint.ts#L501
8 *
9 * @param color the color of the underline
10 * @returns the CSS `url()`
11 */
12function underline(color: string) {
13 const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="6" height="3">
14 <path d="m0 3 l2 -2 l1 0 l2 2 l1 0" stroke="${color}" fill="none" stroke-width=".7"/>
15 </svg>`;
16 const svgBase64 = window.btoa(svg);
17 return `url('data:image/svg+xml;base64,${svgBase64}')`;
18}
19
20export const EditorParent = styled('div')(({ theme }) => {
21 const codeMirrorLintStyle: Record<string, unknown> = {};
22 (['error', 'warning', 'info'] as const).forEach((severity) => {
23 const color = theme.palette[severity].main;
24 codeMirrorLintStyle[`.cm-diagnostic-${severity}`] = {
25 borderLeftColor: color,
26 };
27 codeMirrorLintStyle[`.cm-lintRange-${severity}`] = {
28 backgroundImage: underline(color),
29 };
30 });
31
32 return {
33 background: theme.palette.background.default,
34 '&, .cm-editor': {
35 height: '100%',
36 },
37 '.cm-content': {
38 padding: 0,
39 },
40 '.cm-scroller, .cm-tooltip-autocomplete, .cm-completionLabel, .cm-completionDetail': {
41 fontSize: 16,
42 fontFamily: '"JetBrains MonoVariable", "JetBrains Mono", monospace',
43 fontFeatureSettings: '"liga", "calt"',
44 fontWeight: 400,
45 letterSpacing: 0,
46 textRendering: 'optimizeLegibility',
47 },
48 '.cm-scroller': {
49 color: theme.palette.text.secondary,
50 },
51 '.cm-gutters': {
52 background: 'rgba(255, 255, 255, 0.1)',
53 color: theme.palette.text.disabled,
54 border: 'none',
55 },
56 '.cm-specialChar': {
57 color: theme.palette.secondary.main,
58 },
59 '.cm-activeLine': {
60 background: 'rgba(0, 0, 0, 0.3)',
61 },
62 '.cm-activeLineGutter': {
63 background: 'transparent',
64 },
65 '.cm-lineNumbers .cm-activeLineGutter': {
66 color: theme.palette.text.primary,
67 },
68 '.cm-cursor, .cm-cursor-primary': {
69 borderColor: theme.palette.primary.main,
70 background: theme.palette.common.black,
71 },
72 '.cm-selectionBackground': {
73 background: '#3e4453',
74 },
75 '.cm-focused': {
76 outline: 'none',
77 '.cm-selectionBackground': {
78 background: '#3e4453',
79 },
80 },
81 '.cm-panels-top': {
82 color: theme.palette.text.secondary,
83 },
84 '.cm-panel': {
85 '&, & button, & input': {
86 fontFamily: '"Roboto","Helvetica","Arial",sans-serif',
87 },
88 background: theme.palette.background.paper,
89 borderTop: `1px solid ${theme.palette.divider}`,
90 'button[name="close"]': {
91 background: 'transparent',
92 color: theme.palette.text.secondary,
93 cursor: 'pointer',
94 },
95 },
96 '.cm-panel.cm-panel-lint': {
97 'button[name="close"]': {
98 // Close button interferes with scrollbar, so we better hide it.
99 // The panel can still be closed from the toolbar.
100 display: 'none',
101 },
102 ul: {
103 li: {
104 borderBottom: `1px solid ${theme.palette.divider}`,
105 cursor: 'pointer',
106 },
107 '[aria-selected]': {
108 background: '#3e4453',
109 color: theme.palette.text.primary,
110 },
111 '&:focus [aria-selected]': {
112 background: theme.palette.primary.main,
113 color: theme.palette.primary.contrastText,
114 },
115 },
116 },
117 '.cm-foldPlaceholder': {
118 background: theme.palette.background.paper,
119 borderColor: theme.palette.text.disabled,
120 color: theme.palette.text.secondary,
121 },
122 '.cmt-comment': {
123 fontStyle: 'italic',
124 color: theme.palette.text.disabled,
125 },
126 '.cmt-number': {
127 color: '#6188a6',
128 },
129 '.cmt-string': {
130 color: theme.palette.secondary.dark,
131 },
132 '.cmt-keyword': {
133 color: theme.palette.primary.main,
134 },
135 '.cmt-typeName, .cmt-macroName, .cmt-atom': {
136 color: theme.palette.text.primary,
137 },
138 '.cmt-variableName': {
139 color: '#c8ae9d',
140 },
141 '.cmt-problem-node': {
142 '&, & .cmt-variableName': {
143 color: theme.palette.text.secondary,
144 },
145 },
146 '.cmt-problem-individual': {
147 '&, & .cmt-variableName': {
148 color: theme.palette.text.primary,
149 },
150 },
151 '.cmt-problem-abstract, .cmt-problem-new': {
152 fontStyle: 'italic',
153 },
154 '.cmt-problem-containment': {
155 fontWeight: 700,
156 },
157 '.cmt-problem-error': {
158 '&, & .cmt-typeName': {
159 color: theme.palette.error.main,
160 },
161 },
162 '.cmt-problem-builtin': {
163 '&, & .cmt-typeName, & .cmt-atom, & .cmt-variableName': {
164 color: theme.palette.primary.main,
165 fontWeight: 400,
166 fontStyle: 'normal',
167 },
168 },
169 '.cm-tooltip-autocomplete': {
170 background: theme.palette.background.paper,
171 boxShadow: `0px 2px 4px -1px rgb(0 0 0 / 20%),
172 0px 4px 5px 0px rgb(0 0 0 / 14%),
173 0px 1px 10px 0px rgb(0 0 0 / 12%)`,
174 '.cm-completionIcon': {
175 color: theme.palette.text.secondary,
176 },
177 '.cm-completionLabel': {
178 color: theme.palette.text.primary,
179 },
180 '.cm-completionDetail': {
181 color: theme.palette.text.secondary,
182 fontStyle: 'normal',
183 },
184 '[aria-selected]': {
185 background: `${theme.palette.primary.main} !important`,
186 '.cm-completionIcon, .cm-completionLabel, .cm-completionDetail': {
187 color: theme.palette.primary.contrastText,
188 },
189 },
190 },
191 '.cm-completionIcon': {
192 width: 16,
193 padding: 0,
194 marginRight: '0.5em',
195 textAlign: 'center',
196 },
197 ...codeMirrorLintStyle,
198 '.cm-problem-write': {
199 background: 'rgba(255, 255, 128, 0.3)',
200 },
201 '.cm-problem-read': {
202 background: 'rgba(255, 255, 255, 0.15)',
203 },
204 };
205});
diff --git a/subprojects/frontend/src/editor/EditorStore.ts b/subprojects/frontend/src/editor/EditorStore.ts
new file mode 100644
index 00000000..5760de28
--- /dev/null
+++ b/subprojects/frontend/src/editor/EditorStore.ts
@@ -0,0 +1,289 @@
1import { autocompletion, completionKeymap } from '@codemirror/autocomplete';
2import { closeBrackets, closeBracketsKeymap } from '@codemirror/closebrackets';
3import { defaultKeymap, indentWithTab } from '@codemirror/commands';
4import { commentKeymap } from '@codemirror/comment';
5import { foldGutter, foldKeymap } from '@codemirror/fold';
6import { highlightActiveLineGutter, lineNumbers } from '@codemirror/gutter';
7import { classHighlightStyle } from '@codemirror/highlight';
8import {
9 history,
10 historyKeymap,
11 redo,
12 redoDepth,
13 undo,
14 undoDepth,
15} from '@codemirror/history';
16import { indentOnInput } from '@codemirror/language';
17import {
18 Diagnostic,
19 lintKeymap,
20 setDiagnostics,
21} from '@codemirror/lint';
22import { bracketMatching } from '@codemirror/matchbrackets';
23import { rectangularSelection } from '@codemirror/rectangular-selection';
24import { searchConfig, searchKeymap } from '@codemirror/search';
25import {
26 EditorState,
27 StateCommand,
28 StateEffect,
29 Transaction,
30 TransactionSpec,
31} from '@codemirror/state';
32import {
33 drawSelection,
34 EditorView,
35 highlightActiveLine,
36 highlightSpecialChars,
37 keymap,
38} from '@codemirror/view';
39import {
40 makeAutoObservable,
41 observable,
42 reaction,
43} from 'mobx';
44
45import { findOccurrences, IOccurrence, setOccurrences } from './findOccurrences';
46import { problemLanguageSupport } from '../language/problemLanguageSupport';
47import {
48 IHighlightRange,
49 semanticHighlighting,
50 setSemanticHighlighting,
51} from './semanticHighlighting';
52import type { ThemeStore } from '../theme/ThemeStore';
53import { getLogger } from '../utils/logger';
54import { XtextClient } from '../xtext/XtextClient';
55
56const log = getLogger('editor.EditorStore');
57
58export class EditorStore {
59 private readonly themeStore;
60
61 state: EditorState;
62
63 private readonly client: XtextClient;
64
65 showLineNumbers = false;
66
67 showSearchPanel = false;
68
69 showLintPanel = false;
70
71 errorCount = 0;
72
73 warningCount = 0;
74
75 infoCount = 0;
76
77 private readonly defaultDispatcher = (tr: Transaction): void => {
78 this.onTransaction(tr);
79 };
80
81 private dispatcher = this.defaultDispatcher;
82
83 constructor(initialValue: string, themeStore: ThemeStore) {
84 this.themeStore = themeStore;
85 this.state = EditorState.create({
86 doc: initialValue,
87 extensions: [
88 autocompletion({
89 activateOnTyping: true,
90 override: [
91 (context) => this.client.contentAssist(context),
92 ],
93 }),
94 classHighlightStyle.extension,
95 closeBrackets(),
96 bracketMatching(),
97 drawSelection(),
98 EditorState.allowMultipleSelections.of(true),
99 EditorView.theme({}, {
100 dark: this.themeStore.darkMode,
101 }),
102 findOccurrences,
103 highlightActiveLine(),
104 highlightActiveLineGutter(),
105 highlightSpecialChars(),
106 history(),
107 indentOnInput(),
108 rectangularSelection(),
109 searchConfig({
110 top: true,
111 matchCase: true,
112 }),
113 semanticHighlighting,
114 // We add the gutters to `extensions` in the order we want them to appear.
115 lineNumbers(),
116 foldGutter(),
117 keymap.of([
118 { key: 'Mod-Shift-f', run: () => this.formatText() },
119 ...closeBracketsKeymap,
120 ...commentKeymap,
121 ...completionKeymap,
122 ...foldKeymap,
123 ...historyKeymap,
124 indentWithTab,
125 // Override keys in `lintKeymap` to go through the `EditorStore`.
126 { key: 'Mod-Shift-m', run: () => this.setLintPanelOpen(true) },
127 ...lintKeymap,
128 // Override keys in `searchKeymap` to go through the `EditorStore`.
129 { key: 'Mod-f', run: () => this.setSearchPanelOpen(true), scope: 'editor search-panel' },
130 { key: 'Escape', run: () => this.setSearchPanelOpen(false), scope: 'editor search-panel' },
131 ...searchKeymap,
132 ...defaultKeymap,
133 ]),
134 problemLanguageSupport(),
135 ],
136 });
137 this.client = new XtextClient(this);
138 reaction(
139 () => this.themeStore.darkMode,
140 (darkMode) => {
141 log.debug('Update editor dark mode', darkMode);
142 this.dispatch({
143 effects: [
144 StateEffect.appendConfig.of(EditorView.theme({}, {
145 dark: darkMode,
146 })),
147 ],
148 });
149 },
150 );
151 makeAutoObservable(this, {
152 state: observable.ref,
153 });
154 }
155
156 updateDispatcher(newDispatcher: ((tr: Transaction) => void) | null): void {
157 this.dispatcher = newDispatcher || this.defaultDispatcher;
158 }
159
160 onTransaction(tr: Transaction): void {
161 log.trace('Editor transaction', tr);
162 this.state = tr.state;
163 this.client.onTransaction(tr);
164 }
165
166 dispatch(...specs: readonly TransactionSpec[]): void {
167 this.dispatcher(this.state.update(...specs));
168 }
169
170 doStateCommand(command: StateCommand): boolean {
171 return command({
172 state: this.state,
173 dispatch: this.dispatcher,
174 });
175 }
176
177 updateDiagnostics(diagnostics: Diagnostic[]): void {
178 this.dispatch(setDiagnostics(this.state, diagnostics));
179 this.errorCount = 0;
180 this.warningCount = 0;
181 this.infoCount = 0;
182 diagnostics.forEach(({ severity }) => {
183 switch (severity) {
184 case 'error':
185 this.errorCount += 1;
186 break;
187 case 'warning':
188 this.warningCount += 1;
189 break;
190 case 'info':
191 this.infoCount += 1;
192 break;
193 }
194 });
195 }
196
197 get highestDiagnosticLevel(): Diagnostic['severity'] | null {
198 if (this.errorCount > 0) {
199 return 'error';
200 }
201 if (this.warningCount > 0) {
202 return 'warning';
203 }
204 if (this.infoCount > 0) {
205 return 'info';
206 }
207 return null;
208 }
209
210 updateSemanticHighlighting(ranges: IHighlightRange[]): void {
211 this.dispatch(setSemanticHighlighting(ranges));
212 }
213
214 updateOccurrences(write: IOccurrence[], read: IOccurrence[]): void {
215 this.dispatch(setOccurrences(write, read));
216 }
217
218 /**
219 * @returns `true` if there is history to undo
220 */
221 get canUndo(): boolean {
222 return undoDepth(this.state) > 0;
223 }
224
225 // eslint-disable-next-line class-methods-use-this
226 undo(): void {
227 log.debug('Undo', this.doStateCommand(undo));
228 }
229
230 /**
231 * @returns `true` if there is history to redo
232 */
233 get canRedo(): boolean {
234 return redoDepth(this.state) > 0;
235 }
236
237 // eslint-disable-next-line class-methods-use-this
238 redo(): void {
239 log.debug('Redo', this.doStateCommand(redo));
240 }
241
242 toggleLineNumbers(): void {
243 this.showLineNumbers = !this.showLineNumbers;
244 log.debug('Show line numbers', this.showLineNumbers);
245 }
246
247 /**
248 * Sets whether the CodeMirror search panel should be open.
249 *
250 * This method can be used as a CodeMirror command,
251 * because it returns `false` if it didn't execute,
252 * allowing other commands for the same keybind to run instead.
253 * This matches the behavior of the `openSearchPanel` and `closeSearchPanel`
254 * commands from `'@codemirror/search'`.
255 *
256 * @param newShosSearchPanel whether we should show the search panel
257 * @returns `true` if the state was changed, `false` otherwise
258 */
259 setSearchPanelOpen(newShowSearchPanel: boolean): boolean {
260 if (this.showSearchPanel === newShowSearchPanel) {
261 return false;
262 }
263 this.showSearchPanel = newShowSearchPanel;
264 log.debug('Show search panel', this.showSearchPanel);
265 return true;
266 }
267
268 toggleSearchPanel(): void {
269 this.setSearchPanelOpen(!this.showSearchPanel);
270 }
271
272 setLintPanelOpen(newShowLintPanel: boolean): boolean {
273 if (this.showLintPanel === newShowLintPanel) {
274 return false;
275 }
276 this.showLintPanel = newShowLintPanel;
277 log.debug('Show lint panel', this.showLintPanel);
278 return true;
279 }
280
281 toggleLintPanel(): void {
282 this.setLintPanelOpen(!this.showLintPanel);
283 }
284
285 formatText(): boolean {
286 this.client.formatText();
287 return true;
288 }
289}
diff --git a/subprojects/frontend/src/editor/GenerateButton.tsx b/subprojects/frontend/src/editor/GenerateButton.tsx
new file mode 100644
index 00000000..3834cec4
--- /dev/null
+++ b/subprojects/frontend/src/editor/GenerateButton.tsx
@@ -0,0 +1,44 @@
1import { observer } from 'mobx-react-lite';
2import Button from '@mui/material/Button';
3import PlayArrowIcon from '@mui/icons-material/PlayArrow';
4import React from 'react';
5
6import { useRootStore } from '../RootStore';
7
8const GENERATE_LABEL = 'Generate';
9
10export const GenerateButton = observer(() => {
11 const { editorStore } = useRootStore();
12 const { errorCount, warningCount } = editorStore;
13
14 const diagnostics: string[] = [];
15 if (errorCount > 0) {
16 diagnostics.push(`${errorCount} error${errorCount === 1 ? '' : 's'}`);
17 }
18 if (warningCount > 0) {
19 diagnostics.push(`${warningCount} warning${warningCount === 1 ? '' : 's'}`);
20 }
21 const summary = diagnostics.join(' and ');
22
23 if (errorCount > 0) {
24 return (
25 <Button
26 variant="outlined"
27 color="error"
28 onClick={() => editorStore.toggleLintPanel()}
29 >
30 {summary}
31 </Button>
32 );
33 }
34
35 return (
36 <Button
37 variant="outlined"
38 color={warningCount > 0 ? 'warning' : 'primary'}
39 startIcon={<PlayArrowIcon />}
40 >
41 {summary === '' ? GENERATE_LABEL : `${GENERATE_LABEL} (${summary})`}
42 </Button>
43 );
44});
diff --git a/subprojects/frontend/src/editor/decorationSetExtension.ts b/subprojects/frontend/src/editor/decorationSetExtension.ts
new file mode 100644
index 00000000..2d630c20
--- /dev/null
+++ b/subprojects/frontend/src/editor/decorationSetExtension.ts
@@ -0,0 +1,39 @@
1import { StateEffect, StateField, TransactionSpec } from '@codemirror/state';
2import { EditorView, Decoration, DecorationSet } from '@codemirror/view';
3
4export type TransactionSpecFactory = (decorations: DecorationSet) => TransactionSpec;
5
6export function decorationSetExtension(): [TransactionSpecFactory, StateField<DecorationSet>] {
7 const setEffect = StateEffect.define<DecorationSet>();
8 const field = StateField.define<DecorationSet>({
9 create() {
10 return Decoration.none;
11 },
12 update(currentDecorations, transaction) {
13 let newDecorations: DecorationSet | null = null;
14 transaction.effects.forEach((effect) => {
15 if (effect.is(setEffect)) {
16 newDecorations = effect.value;
17 }
18 });
19 if (newDecorations === null) {
20 if (transaction.docChanged) {
21 return currentDecorations.map(transaction.changes);
22 }
23 return currentDecorations;
24 }
25 return newDecorations;
26 },
27 provide: (f) => EditorView.decorations.from(f),
28 });
29
30 function transactionSpecFactory(decorations: DecorationSet) {
31 return {
32 effects: [
33 setEffect.of(decorations),
34 ],
35 };
36 }
37
38 return [transactionSpecFactory, field];
39}
diff --git a/subprojects/frontend/src/editor/findOccurrences.ts b/subprojects/frontend/src/editor/findOccurrences.ts
new file mode 100644
index 00000000..92102746
--- /dev/null
+++ b/subprojects/frontend/src/editor/findOccurrences.ts
@@ -0,0 +1,35 @@
1import { Range, RangeSet } from '@codemirror/rangeset';
2import type { TransactionSpec } from '@codemirror/state';
3import { Decoration } from '@codemirror/view';
4
5import { decorationSetExtension } from './decorationSetExtension';
6
7export interface IOccurrence {
8 from: number;
9
10 to: number;
11}
12
13const [setOccurrencesInteral, findOccurrences] = decorationSetExtension();
14
15const writeDecoration = Decoration.mark({
16 class: 'cm-problem-write',
17});
18
19const readDecoration = Decoration.mark({
20 class: 'cm-problem-read',
21});
22
23export function setOccurrences(write: IOccurrence[], read: IOccurrence[]): TransactionSpec {
24 const decorations: Range<Decoration>[] = [];
25 write.forEach(({ from, to }) => {
26 decorations.push(writeDecoration.range(from, to));
27 });
28 read.forEach(({ from, to }) => {
29 decorations.push(readDecoration.range(from, to));
30 });
31 const rangeSet = RangeSet.of(decorations, true);
32 return setOccurrencesInteral(rangeSet);
33}
34
35export { findOccurrences };
diff --git a/subprojects/frontend/src/editor/semanticHighlighting.ts b/subprojects/frontend/src/editor/semanticHighlighting.ts
new file mode 100644
index 00000000..2aed421b
--- /dev/null
+++ b/subprojects/frontend/src/editor/semanticHighlighting.ts
@@ -0,0 +1,24 @@
1import { RangeSet } from '@codemirror/rangeset';
2import type { TransactionSpec } from '@codemirror/state';
3import { Decoration } from '@codemirror/view';
4
5import { decorationSetExtension } from './decorationSetExtension';
6
7export interface IHighlightRange {
8 from: number;
9
10 to: number;
11
12 classes: string[];
13}
14
15const [setSemanticHighlightingInternal, semanticHighlighting] = decorationSetExtension();
16
17export function setSemanticHighlighting(ranges: IHighlightRange[]): TransactionSpec {
18 const rangeSet = RangeSet.of(ranges.map(({ from, to, classes }) => Decoration.mark({
19 class: classes.map((c) => `cmt-problem-${c}`).join(' '),
20 }).range(from, to)), true);
21 return setSemanticHighlightingInternal(rangeSet);
22}
23
24export { semanticHighlighting };