diff options
Diffstat (limited to 'subprojects/frontend/src/editor')
-rw-r--r-- | subprojects/frontend/src/editor/EditorArea.tsx | 152 | ||||
-rw-r--r-- | subprojects/frontend/src/editor/EditorButtons.tsx | 98 | ||||
-rw-r--r-- | subprojects/frontend/src/editor/EditorParent.ts | 205 | ||||
-rw-r--r-- | subprojects/frontend/src/editor/EditorStore.ts | 289 | ||||
-rw-r--r-- | subprojects/frontend/src/editor/GenerateButton.tsx | 44 | ||||
-rw-r--r-- | subprojects/frontend/src/editor/decorationSetExtension.ts | 39 | ||||
-rw-r--r-- | subprojects/frontend/src/editor/findOccurrences.ts | 35 | ||||
-rw-r--r-- | subprojects/frontend/src/editor/semanticHighlighting.ts | 24 |
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 @@ | |||
1 | import { Command, EditorView } from '@codemirror/view'; | ||
2 | import { closeSearchPanel, openSearchPanel } from '@codemirror/search'; | ||
3 | import { closeLintPanel, openLintPanel } from '@codemirror/lint'; | ||
4 | import { observer } from 'mobx-react-lite'; | ||
5 | import React, { | ||
6 | useCallback, | ||
7 | useEffect, | ||
8 | useRef, | ||
9 | useState, | ||
10 | } from 'react'; | ||
11 | |||
12 | import { EditorParent } from './EditorParent'; | ||
13 | import { useRootStore } from '../RootStore'; | ||
14 | import { getLogger } from '../utils/logger'; | ||
15 | |||
16 | const log = getLogger('editor.EditorArea'); | ||
17 | |||
18 | function 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 | |||
66 | function 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 | |||
73 | export 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 @@ | |||
1 | import type { Diagnostic } from '@codemirror/lint'; | ||
2 | import { observer } from 'mobx-react-lite'; | ||
3 | import IconButton from '@mui/material/IconButton'; | ||
4 | import Stack from '@mui/material/Stack'; | ||
5 | import ToggleButton from '@mui/material/ToggleButton'; | ||
6 | import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; | ||
7 | import CheckIcon from '@mui/icons-material/Check'; | ||
8 | import ErrorIcon from '@mui/icons-material/Error'; | ||
9 | import FormatListNumberedIcon from '@mui/icons-material/FormatListNumbered'; | ||
10 | import FormatPaint from '@mui/icons-material/FormatPaint'; | ||
11 | import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; | ||
12 | import RedoIcon from '@mui/icons-material/Redo'; | ||
13 | import SearchIcon from '@mui/icons-material/Search'; | ||
14 | import UndoIcon from '@mui/icons-material/Undo'; | ||
15 | import WarningIcon from '@mui/icons-material/Warning'; | ||
16 | import React from 'react'; | ||
17 | |||
18 | import { useRootStore } from '../RootStore'; | ||
19 | |||
20 | // Exhastive switch as proven by TypeScript. | ||
21 | // eslint-disable-next-line consistent-return | ||
22 | function 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 | |||
35 | export 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 @@ | |||
1 | import { 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 | */ | ||
12 | function 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 | |||
20 | export 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 @@ | |||
1 | import { autocompletion, completionKeymap } from '@codemirror/autocomplete'; | ||
2 | import { closeBrackets, closeBracketsKeymap } from '@codemirror/closebrackets'; | ||
3 | import { defaultKeymap, indentWithTab } from '@codemirror/commands'; | ||
4 | import { commentKeymap } from '@codemirror/comment'; | ||
5 | import { foldGutter, foldKeymap } from '@codemirror/fold'; | ||
6 | import { highlightActiveLineGutter, lineNumbers } from '@codemirror/gutter'; | ||
7 | import { classHighlightStyle } from '@codemirror/highlight'; | ||
8 | import { | ||
9 | history, | ||
10 | historyKeymap, | ||
11 | redo, | ||
12 | redoDepth, | ||
13 | undo, | ||
14 | undoDepth, | ||
15 | } from '@codemirror/history'; | ||
16 | import { indentOnInput } from '@codemirror/language'; | ||
17 | import { | ||
18 | Diagnostic, | ||
19 | lintKeymap, | ||
20 | setDiagnostics, | ||
21 | } from '@codemirror/lint'; | ||
22 | import { bracketMatching } from '@codemirror/matchbrackets'; | ||
23 | import { rectangularSelection } from '@codemirror/rectangular-selection'; | ||
24 | import { searchConfig, searchKeymap } from '@codemirror/search'; | ||
25 | import { | ||
26 | EditorState, | ||
27 | StateCommand, | ||
28 | StateEffect, | ||
29 | Transaction, | ||
30 | TransactionSpec, | ||
31 | } from '@codemirror/state'; | ||
32 | import { | ||
33 | drawSelection, | ||
34 | EditorView, | ||
35 | highlightActiveLine, | ||
36 | highlightSpecialChars, | ||
37 | keymap, | ||
38 | } from '@codemirror/view'; | ||
39 | import { | ||
40 | makeAutoObservable, | ||
41 | observable, | ||
42 | reaction, | ||
43 | } from 'mobx'; | ||
44 | |||
45 | import { findOccurrences, IOccurrence, setOccurrences } from './findOccurrences'; | ||
46 | import { problemLanguageSupport } from '../language/problemLanguageSupport'; | ||
47 | import { | ||
48 | IHighlightRange, | ||
49 | semanticHighlighting, | ||
50 | setSemanticHighlighting, | ||
51 | } from './semanticHighlighting'; | ||
52 | import type { ThemeStore } from '../theme/ThemeStore'; | ||
53 | import { getLogger } from '../utils/logger'; | ||
54 | import { XtextClient } from '../xtext/XtextClient'; | ||
55 | |||
56 | const log = getLogger('editor.EditorStore'); | ||
57 | |||
58 | export 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 @@ | |||
1 | import { observer } from 'mobx-react-lite'; | ||
2 | import Button from '@mui/material/Button'; | ||
3 | import PlayArrowIcon from '@mui/icons-material/PlayArrow'; | ||
4 | import React from 'react'; | ||
5 | |||
6 | import { useRootStore } from '../RootStore'; | ||
7 | |||
8 | const GENERATE_LABEL = 'Generate'; | ||
9 | |||
10 | export 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 @@ | |||
1 | import { StateEffect, StateField, TransactionSpec } from '@codemirror/state'; | ||
2 | import { EditorView, Decoration, DecorationSet } from '@codemirror/view'; | ||
3 | |||
4 | export type TransactionSpecFactory = (decorations: DecorationSet) => TransactionSpec; | ||
5 | |||
6 | export 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 @@ | |||
1 | import { Range, RangeSet } from '@codemirror/rangeset'; | ||
2 | import type { TransactionSpec } from '@codemirror/state'; | ||
3 | import { Decoration } from '@codemirror/view'; | ||
4 | |||
5 | import { decorationSetExtension } from './decorationSetExtension'; | ||
6 | |||
7 | export interface IOccurrence { | ||
8 | from: number; | ||
9 | |||
10 | to: number; | ||
11 | } | ||
12 | |||
13 | const [setOccurrencesInteral, findOccurrences] = decorationSetExtension(); | ||
14 | |||
15 | const writeDecoration = Decoration.mark({ | ||
16 | class: 'cm-problem-write', | ||
17 | }); | ||
18 | |||
19 | const readDecoration = Decoration.mark({ | ||
20 | class: 'cm-problem-read', | ||
21 | }); | ||
22 | |||
23 | export 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 | |||
35 | export { 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 @@ | |||
1 | import { RangeSet } from '@codemirror/rangeset'; | ||
2 | import type { TransactionSpec } from '@codemirror/state'; | ||
3 | import { Decoration } from '@codemirror/view'; | ||
4 | |||
5 | import { decorationSetExtension } from './decorationSetExtension'; | ||
6 | |||
7 | export interface IHighlightRange { | ||
8 | from: number; | ||
9 | |||
10 | to: number; | ||
11 | |||
12 | classes: string[]; | ||
13 | } | ||
14 | |||
15 | const [setSemanticHighlightingInternal, semanticHighlighting] = decorationSetExtension(); | ||
16 | |||
17 | export 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 | |||
24 | export { semanticHighlighting }; | ||