aboutsummaryrefslogtreecommitdiffstats
path: root/subprojects
diff options
context:
space:
mode:
authorLibravatar Kristóf Marussy <kristof@marussy.com>2022-08-17 21:43:29 +0200
committerLibravatar Kristóf Marussy <kristof@marussy.com>2022-08-17 21:43:29 +0200
commitbb900e1bd40a6b7efd7a538114d985ea7f7e3e88 (patch)
treebb15a937ade92313dc654a640bc1de925442eff2 /subprojects
parentrefactor(frondend): improve editor store and theme (diff)
downloadrefinery-bb900e1bd40a6b7efd7a538114d985ea7f7e3e88.tar.gz
refinery-bb900e1bd40a6b7efd7a538114d985ea7f7e3e88.tar.zst
refinery-bb900e1bd40a6b7efd7a538114d985ea7f7e3e88.zip
feat(frontend): custom search panel
Also improves editor styling (to enable panel styling).
Diffstat (limited to 'subprojects')
-rw-r--r--subprojects/frontend/.eslintrc.cjs2
-rw-r--r--subprojects/frontend/src/TopBar.tsx7
-rw-r--r--subprojects/frontend/src/editor/EditorArea.tsx14
-rw-r--r--subprojects/frontend/src/editor/EditorButtons.tsx7
-rw-r--r--subprojects/frontend/src/editor/EditorStore.ts44
-rw-r--r--subprojects/frontend/src/editor/EditorTheme.ts433
-rw-r--r--subprojects/frontend/src/editor/GenerateButton.tsx6
-rw-r--r--subprojects/frontend/src/editor/LintPanelStore.ts10
-rw-r--r--subprojects/frontend/src/editor/PanelStore.ts41
-rw-r--r--subprojects/frontend/src/editor/SearchPanel.ts32
-rw-r--r--subprojects/frontend/src/editor/SearchPanelStore.ts108
-rw-r--r--subprojects/frontend/src/editor/SearchToolbar.tsx198
-rw-r--r--subprojects/frontend/src/editor/createEditorState.ts13
-rw-r--r--subprojects/frontend/src/theme/ThemeProvider.tsx104
14 files changed, 776 insertions, 243 deletions
diff --git a/subprojects/frontend/.eslintrc.cjs b/subprojects/frontend/.eslintrc.cjs
index e6be4d65..68636f4e 100644
--- a/subprojects/frontend/.eslintrc.cjs
+++ b/subprojects/frontend/.eslintrc.cjs
@@ -53,6 +53,8 @@ module.exports = {
53 'newlines-between': 'always', 53 'newlines-between': 'always',
54 }, 54 },
55 ], 55 ],
56 // Use prop spreading to conditionally add props with `exactOptionalPropertyTypes`.
57 'react/jsx-props-no-spreading': 'off',
56 }, 58 },
57 overrides: [ 59 overrides: [
58 { 60 {
diff --git a/subprojects/frontend/src/TopBar.tsx b/subprojects/frontend/src/TopBar.tsx
index 5ad80d40..b414712e 100644
--- a/subprojects/frontend/src/TopBar.tsx
+++ b/subprojects/frontend/src/TopBar.tsx
@@ -17,7 +17,12 @@ export default function TopBar(): JSX.Element {
17 })} 17 })}
18 > 18 >
19 <Toolbar> 19 <Toolbar>
20 <Typography variant="h6" component="h1" flexGrow={1}> 20 <Typography
21 variant="h6"
22 component="h1"
23 flexGrow={1}
24 color={(theme) => theme.palette.text.primary}
25 >
21 Refinery 26 Refinery
22 </Typography> 27 </Typography>
23 <ToggleDarkModeButton /> 28 <ToggleDarkModeButton />
diff --git a/subprojects/frontend/src/editor/EditorArea.tsx b/subprojects/frontend/src/editor/EditorArea.tsx
index e5712461..915ec657 100644
--- a/subprojects/frontend/src/editor/EditorArea.tsx
+++ b/subprojects/frontend/src/editor/EditorArea.tsx
@@ -1,3 +1,4 @@
1import Portal from '@mui/material/Portal';
1import { useTheme } from '@mui/material/styles'; 2import { useTheme } from '@mui/material/styles';
2import { observer } from 'mobx-react-lite'; 3import { observer } from 'mobx-react-lite';
3import React, { useCallback, useEffect } from 'react'; 4import React, { useCallback, useEffect } from 'react';
@@ -5,9 +6,12 @@ import React, { useCallback, useEffect } from 'react';
5import { useRootStore } from '../RootStore'; 6import { useRootStore } from '../RootStore';
6 7
7import EditorTheme from './EditorTheme'; 8import EditorTheme from './EditorTheme';
9import SearchToolbar from './SearchToolbar';
8 10
9function EditorArea(): JSX.Element { 11function EditorArea(): JSX.Element {
10 const { editorStore } = useRootStore(); 12 const { editorStore } = useRootStore();
13 const { searchPanel: searchPanelStore } = editorStore;
14 const { element: searchPanelContainer } = searchPanelStore;
11 const { 15 const {
12 palette: { mode: paletteMode }, 16 palette: { mode: paletteMode },
13 } = useTheme(); 17 } = useTheme();
@@ -19,7 +23,7 @@ function EditorArea(): JSX.Element {
19 23
20 const editorParentRef = useCallback( 24 const editorParentRef = useCallback(
21 (editorParent: HTMLDivElement | null) => { 25 (editorParent: HTMLDivElement | null) => {
22 editorStore.setEditorParent(editorParent); 26 editorStore.setEditorParent(editorParent ?? undefined);
23 }, 27 },
24 [editorStore], 28 [editorStore],
25 ); 29 );
@@ -28,7 +32,13 @@ function EditorArea(): JSX.Element {
28 <EditorTheme 32 <EditorTheme
29 showLineNumbers={editorStore.showLineNumbers} 33 showLineNumbers={editorStore.showLineNumbers}
30 ref={editorParentRef} 34 ref={editorParentRef}
31 /> 35 >
36 {searchPanelContainer !== undefined && (
37 <Portal container={searchPanelContainer}>
38 <SearchToolbar store={searchPanelStore} />
39 </Portal>
40 )}
41 </EditorTheme>
32 ); 42 );
33} 43}
34 44
diff --git a/subprojects/frontend/src/editor/EditorButtons.tsx b/subprojects/frontend/src/editor/EditorButtons.tsx
index 34b64751..95da52c8 100644
--- a/subprojects/frontend/src/editor/EditorButtons.tsx
+++ b/subprojects/frontend/src/editor/EditorButtons.tsx
@@ -56,6 +56,7 @@ function EditorButtons(): JSX.Element {
56 selected={editorStore.showLineNumbers} 56 selected={editorStore.showLineNumbers}
57 onClick={() => editorStore.toggleLineNumbers()} 57 onClick={() => editorStore.toggleLineNumbers()}
58 aria-label="Show line numbers" 58 aria-label="Show line numbers"
59 aria-controls={editorStore.lineNumbersId}
59 value="show-line-numbers" 60 value="show-line-numbers"
60 > 61 >
61 <FormatListNumberedIcon fontSize="small" /> 62 <FormatListNumberedIcon fontSize="small" />
@@ -64,6 +65,9 @@ function EditorButtons(): JSX.Element {
64 selected={editorStore.searchPanel.state} 65 selected={editorStore.searchPanel.state}
65 onClick={() => editorStore.searchPanel.toggle()} 66 onClick={() => editorStore.searchPanel.toggle()}
66 aria-label="Show find/replace" 67 aria-label="Show find/replace"
68 {...(editorStore.searchPanel.state && {
69 'aria-controls': editorStore.searchPanel.id,
70 })}
67 value="show-search-panel" 71 value="show-search-panel"
68 > 72 >
69 <SearchIcon fontSize="small" /> 73 <SearchIcon fontSize="small" />
@@ -72,6 +76,9 @@ function EditorButtons(): JSX.Element {
72 selected={editorStore.lintPanel.state} 76 selected={editorStore.lintPanel.state}
73 onClick={() => editorStore.lintPanel.toggle()} 77 onClick={() => editorStore.lintPanel.toggle()}
74 aria-label="Show diagnostics panel" 78 aria-label="Show diagnostics panel"
79 {...(editorStore.lintPanel.state && {
80 'aria-controls': editorStore.lintPanel.id,
81 })}
75 value="show-lint-panel" 82 value="show-lint-panel"
76 > 83 >
77 {getLintIcon(editorStore.highestDiagnosticLevel)} 84 {getLintIcon(editorStore.highestDiagnosticLevel)}
diff --git a/subprojects/frontend/src/editor/EditorStore.ts b/subprojects/frontend/src/editor/EditorStore.ts
index 4bad68b3..2ed7f5ce 100644
--- a/subprojects/frontend/src/editor/EditorStore.ts
+++ b/subprojects/frontend/src/editor/EditorStore.ts
@@ -3,11 +3,8 @@ import { redo, redoDepth, undo, undoDepth } from '@codemirror/commands';
3import { 3import {
4 type Diagnostic, 4 type Diagnostic,
5 setDiagnostics, 5 setDiagnostics,
6 closeLintPanel,
7 openLintPanel,
8 nextDiagnostic, 6 nextDiagnostic,
9} from '@codemirror/lint'; 7} from '@codemirror/lint';
10import { closeSearchPanel, openSearchPanel } from '@codemirror/search';
11import { 8import {
12 type StateCommand, 9 type StateCommand,
13 StateEffect, 10 StateEffect,
@@ -17,11 +14,13 @@ import {
17} from '@codemirror/state'; 14} from '@codemirror/state';
18import { type Command, EditorView } from '@codemirror/view'; 15import { type Command, EditorView } from '@codemirror/view';
19import { action, computed, makeObservable, observable } from 'mobx'; 16import { action, computed, makeObservable, observable } from 'mobx';
17import { nanoid } from 'nanoid';
20 18
21import getLogger from '../utils/getLogger'; 19import getLogger from '../utils/getLogger';
22import XtextClient from '../xtext/XtextClient'; 20import XtextClient from '../xtext/XtextClient';
23 21
24import PanelStore from './PanelStore'; 22import LintPanelStore from './LintPanelStore';
23import SearchPanelStore from './SearchPanelStore';
25import createEditorState from './createEditorState'; 24import createEditorState from './createEditorState';
26import { type IOccurrence, setOccurrences } from './findOccurrences'; 25import { type IOccurrence, setOccurrences } from './findOccurrences';
27import { 26import {
@@ -32,15 +31,17 @@ import {
32const log = getLogger('editor.EditorStore'); 31const log = getLogger('editor.EditorStore');
33 32
34export default class EditorStore { 33export default class EditorStore {
34 readonly id: string;
35
35 state: EditorState; 36 state: EditorState;
36 37
37 private readonly client: XtextClient; 38 private readonly client: XtextClient;
38 39
39 view: EditorView | undefined; 40 view: EditorView | undefined;
40 41
41 readonly searchPanel: PanelStore; 42 readonly searchPanel: SearchPanelStore;
42 43
43 readonly lintPanel: PanelStore; 44 readonly lintPanel: LintPanelStore;
44 45
45 showLineNumbers = false; 46 showLineNumbers = false;
46 47
@@ -51,20 +52,11 @@ export default class EditorStore {
51 infoCount = 0; 52 infoCount = 0;
52 53
53 constructor(initialValue: string) { 54 constructor(initialValue: string) {
55 this.id = nanoid();
54 this.state = createEditorState(initialValue, this); 56 this.state = createEditorState(initialValue, this);
55 this.client = new XtextClient(this); 57 this.client = new XtextClient(this);
56 this.searchPanel = new PanelStore( 58 this.searchPanel = new SearchPanelStore(this);
57 'search', 59 this.lintPanel = new LintPanelStore(this);
58 openSearchPanel,
59 closeSearchPanel,
60 this,
61 );
62 this.lintPanel = new PanelStore(
63 'panel-lint',
64 openLintPanel,
65 closeLintPanel,
66 this,
67 );
68 makeObservable(this, { 60 makeObservable(this, {
69 state: observable.ref, 61 state: observable.ref,
70 view: observable.ref, 62 view: observable.ref,
@@ -100,11 +92,11 @@ export default class EditorStore {
100 }); 92 });
101 } 93 }
102 94
103 setEditorParent(editorParent: Element | null): void { 95 setEditorParent(editorParent: Element | undefined): void {
104 if (this.view !== undefined) { 96 if (this.view !== undefined) {
105 this.view.destroy(); 97 this.view.destroy();
106 } 98 }
107 if (editorParent === null) { 99 if (editorParent === undefined) {
108 this.view = undefined; 100 this.view = undefined;
109 return; 101 return;
110 } 102 }
@@ -129,9 +121,15 @@ export default class EditorStore {
129 this.lintPanel.synchronizeStateToView(); 121 this.lintPanel.synchronizeStateToView();
130 122
131 // Reported by Lighthouse 8.3.0. 123 // Reported by Lighthouse 8.3.0.
132 const { contentDOM } = view; 124 const { contentDOM, dom: containerDOM } = view;
133 contentDOM.removeAttribute('aria-expanded'); 125 contentDOM.removeAttribute('aria-expanded');
134 contentDOM.setAttribute('aria-label', 'Code editor'); 126 contentDOM.setAttribute('aria-label', 'Code editor');
127 const lineNumbersGutter = containerDOM.querySelector('.cm-lineNumbers');
128 if (lineNumbersGutter === null) {
129 log.error('No line numbers in editor');
130 } else {
131 lineNumbersGutter.id = this.lineNumbersId;
132 }
135 133
136 log.info('Editor created'); 134 log.info('Editor created');
137 } 135 }
@@ -244,6 +242,10 @@ export default class EditorStore {
244 log.debug('Redo', this.doStateCommand(redo)); 242 log.debug('Redo', this.doStateCommand(redo));
245 } 243 }
246 244
245 get lineNumbersId(): string {
246 return `${this.id}-lineNumbers`;
247 }
248
247 toggleLineNumbers(): void { 249 toggleLineNumbers(): void {
248 this.showLineNumbers = !this.showLineNumbers; 250 this.showLineNumbers = !this.showLineNumbers;
249 log.debug('Show line numbers', this.showLineNumbers); 251 log.debug('Show line numbers', this.showLineNumbers);
diff --git a/subprojects/frontend/src/editor/EditorTheme.ts b/subprojects/frontend/src/editor/EditorTheme.ts
index 8d98e832..c983a378 100644
--- a/subprojects/frontend/src/editor/EditorTheme.ts
+++ b/subprojects/frontend/src/editor/EditorTheme.ts
@@ -2,7 +2,7 @@ import errorSVG from '@material-icons/svg/svg/error/baseline.svg?raw';
2import expandMoreSVG from '@material-icons/svg/svg/expand_more/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'; 3import infoSVG from '@material-icons/svg/svg/info/baseline.svg?raw';
4import warningSVG from '@material-icons/svg/svg/warning/baseline.svg?raw'; 4import warningSVG from '@material-icons/svg/svg/warning/baseline.svg?raw';
5import { alpha, styled } from '@mui/material/styles'; 5import { alpha, styled, type CSSObject } from '@mui/material/styles';
6 6
7import editorClassNames from './editorClassNames'; 7import editorClassNames from './editorClassNames';
8 8
@@ -14,36 +14,178 @@ export default styled('div', {
14 name: 'EditorTheme', 14 name: 'EditorTheme',
15 shouldForwardProp: (propName) => propName !== 'showLineNumbers', 15 shouldForwardProp: (propName) => propName !== 'showLineNumbers',
16})<{ showLineNumbers: boolean }>(({ theme, showLineNumbers }) => { 16})<{ showLineNumbers: boolean }>(({ theme, showLineNumbers }) => {
17 let codeMirrorLintStyle: Record<string, unknown> = {}; 17 const generalStyle: CSSObject = {
18 ( 18 background: theme.palette.background.default,
19 [ 19 '&, .cm-editor': {
20 { 20 height: '100%',
21 severity: 'error', 21 },
22 icon: errorSVG, 22 '.cm-scroller': {
23 ...theme.typography.editor,
24 color: theme.palette.text.secondary,
25 },
26 '.cm-gutters': {
27 background: 'transparent',
28 border: 'none',
29 },
30 '.cm-content': {
31 padding: 0,
32 },
33 '.cm-activeLine': {
34 background: theme.palette.highlight.activeLine,
35 },
36 '.cm-activeLineGutter': {
37 background: 'transparent',
38 },
39 '.cm-cursor, .cm-cursor-primary': {
40 borderLeft: `2px solid ${theme.palette.primary.main}`,
41 },
42 '.cm-selectionBackground': {
43 background: theme.palette.highlight.selection,
44 },
45 '.cm-focused': {
46 outline: 'none',
47 '.cm-selectionBackground': {
48 background: theme.palette.highlight.selection,
49 },
50 },
51 };
52
53 const highlightingStyle: CSSObject = {
54 '.cm-specialChar': {
55 color: theme.palette.secondary.main,
56 },
57 '.tok-comment': {
58 fontStyle: 'italic',
59 color: theme.palette.highlight.comment,
60 },
61 '.tok-number': {
62 color: theme.palette.highlight.number,
63 },
64 '.tok-string': {
65 color: theme.palette.secondary,
66 },
67 '.tok-keyword': {
68 color: theme.palette.primary.main,
69 },
70 '.tok-typeName, .tok-atom': {
71 color: theme.palette.text.primary,
72 },
73 '.tok-variableName': {
74 color: theme.palette.highlight.parameter,
75 },
76 '.tok-problem-node': {
77 '&, & .tok-variableName': {
78 color: theme.palette.text.secondary,
79 },
80 },
81 '.tok-problem-individual': {
82 '&, & .tok-variableName': {
83 color: theme.palette.text.primary,
23 }, 84 },
24 { 85 },
25 severity: 'warning', 86 '.tok-problem-abstract, .tok-problem-new': {
26 icon: warningSVG, 87 fontStyle: 'italic',
88 },
89 '.tok-problem-containment': {
90 fontWeight: 700,
91 },
92 '.tok-problem-error': {
93 '&, & .tok-typeName': {
94 color: theme.palette.error.main,
27 }, 95 },
28 { 96 },
29 severity: 'info', 97 '.tok-problem-builtin': {
30 icon: infoSVG, 98 '&, & .tok-typeName, & .tok-atom, & .tok-variableName': {
99 color: theme.palette.primary.main,
100 fontWeight: 400,
101 fontStyle: 'normal',
31 }, 102 },
32 ] as const 103 },
33 ).forEach(({ severity, icon }) => { 104 };
105
106 const matchingStyle: CSSObject = {
107 '.cm-problem-read': {
108 background: theme.palette.highlight.occurences.read,
109 },
110 '.cm-problem-write': {
111 background: theme.palette.highlight.occurences.write,
112 },
113 '.cm-matchingBracket, .cm-nonmatchingBracket': {
114 background: 'transparent',
115 },
116 '.cm-focused .cm-matchingBracket': {
117 background: 'transparent',
118 outline: `1px solid ${alpha(theme.palette.text.primary, 0.5)}`,
119 outlineOffset: -1,
120 },
121 '.cm-focused .cm-nonmatchingBracket': {
122 background: theme.palette.error.main,
123 '&, span': {
124 color: theme.palette.error.contrastText,
125 },
126 },
127 '.cm-searchMatch': {
128 opacity: 1,
129 background: theme.palette.highlight.search.match,
130 '&, span': {
131 color: theme.palette.highlight.search.contrastText,
132 },
133 },
134 '.cm-searchMatch-selected': {
135 background: theme.palette.highlight.search.selected,
136 },
137 };
138
139 const lineNumberStyle: CSSObject = {
140 '.cm-lineNumbers': {
141 color: theme.palette.highlight.lineNumber,
142 ...(!showLineNumbers && {
143 display: 'none !important',
144 }),
145 '.cm-gutterElement': {
146 padding: '0 2px 0 6px',
147 },
148 '.cm-activeLineGutter': {
149 color: theme.palette.text.primary,
150 },
151 },
152 };
153
154 const panelStyle: CSSObject = {
155 '.cm-panels-top': {
156 color: theme.palette.text.primary,
157 borderBottom: `1px solid ${theme.palette.outer.border}`,
158 marginBottom: theme.spacing(1),
159 },
160 '.cm-panel, .cm-panel.cm-search': {
161 color: theme.palette.text.primary,
162 background: theme.palette.outer.background,
163 borderTop: `1px solid ${theme.palette.outer.border}`,
164 margin: 0,
165 padding: 0,
166 'button[name="close"]': {
167 background: 'transparent',
168 color: theme.palette.text.secondary,
169 cursor: 'pointer',
170 },
171 },
172 };
173
174 function lintSeverityStyle(
175 severity: 'error' | 'warning' | 'info',
176 icon: string,
177 ): CSSObject {
34 const palette = theme.palette[severity]; 178 const palette = theme.palette[severity];
35 const color = palette.main; 179 const color = palette.main;
36 const iconStyle = { 180 const tooltipColor = theme.palette.mode === 'dark' ? color : palette.light;
181 const iconStyle: CSSObject = {
37 background: color, 182 background: color,
38 maskImage: svgURL(icon), 183 maskImage: svgURL(icon),
39 maskSize: '16px 16px', 184 maskSize: '16px 16px',
40 height: 16, 185 height: 16,
41 width: 16, 186 width: 16,
42 }; 187 };
43 const tooltipColor = 188 return {
44 theme.palette.mode === 'dark' ? palette.main : palette.light;
45 codeMirrorLintStyle = {
46 ...codeMirrorLintStyle,
47 [`.cm-lintRange-${severity}`]: { 189 [`.cm-lintRange-${severity}`]: {
48 backgroundImage: 'none', 190 backgroundImage: 'none',
49 textDecoration: `underline wavy ${color}`, 191 textDecoration: `underline wavy ${color}`,
@@ -78,113 +220,23 @@ export default styled('div', {
78 }, 220 },
79 }, 221 },
80 }; 222 };
81 }); 223 }
82 224
83 return { 225 const lintStyle: CSSObject = {
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': { 226 '.cm-gutter-lint': {
114 width: 16, 227 width: 16,
115 '.cm-gutterElement': { 228 '.cm-gutterElement': {
116 padding: 0, 229 padding: 0,
117 }, 230 },
118 }, 231 },
119 '.cm-foldGutter': { 232 '.cm-tooltip.cm-tooltip-hover, .cm-tooltip.cm-tooltip-lint': {
120 opacity: 0, 233 ...((theme.components?.MuiTooltip?.styleOverrides?.tooltip as
121 width: 16, 234 | CSSObject
122 transition: theme.transitions.create('opacity', { 235 | undefined) || {}),
123 duration: theme.transitions.duration.short, 236 ...theme.typography.body2,
124 }), 237 borderRadius: theme.shape.borderRadius,
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', 238 overflow: 'hidden',
178 background: theme.palette.outer.background, 239 maxWidth: 400,
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 }, 240 },
189 '.cm-panel.cm-panel-lint': { 241 '.cm-panel.cm-panel-lint': {
190 borderTop: `1px solid ${theme.palette.outer.border}`, 242 borderTop: `1px solid ${theme.palette.outer.border}`,
@@ -195,7 +247,7 @@ export default styled('div', {
195 display: 'none', 247 display: 'none',
196 }, 248 },
197 ul: { 249 ul: {
198 maxHeight: 'max(112px, 20vh)', 250 maxHeight: `max(${28 * 4}px, 20vh)`,
199 li: { 251 li: {
200 cursor: 'pointer', 252 cursor: 'pointer',
201 color: theme.palette.text.primary, 253 color: theme.palette.text.primary,
@@ -216,77 +268,71 @@ export default styled('div', {
216 }, 268 },
217 }, 269 },
218 }, 270 },
219 [`.${editorClassNames.foldPlaceholder}`]: { 271 '.cm-lintRange-active': {
220 ...theme.typography.body1, 272 background: theme.palette.highlight.activeLintRange,
221 padding: 0, 273 },
222 fontFamily: 'inherit', 274 ...lintSeverityStyle('error', errorSVG),
223 fontFeatureSettings: '"liga", "calt"', 275 ...lintSeverityStyle('warning', warningSVG),
224 color: theme.palette.text.secondary, 276 ...lintSeverityStyle('info', infoSVG),
225 backgroundColor: alpha( 277 };
226 theme.palette.text.secondary, 278
227 theme.palette.action.focusOpacity, 279 const foldStyle = {
228 ), 280 '.cm-foldGutter': {
229 border: 'none', 281 opacity: 0,
230 cursor: 'pointer', 282 width: 16,
231 transition: theme.transitions.create(['background-color', 'color'], { 283 transition: theme.transitions.create('opacity', {
232 duration: theme.transitions.duration.short, 284 duration: theme.transitions.duration.short,
233 }), 285 }),
234 '&:hover': { 286 '@media (hover: none)': {
235 color: theme.palette.text.primary, 287 opacity: 1,
236 backgroundColor: alpha(
237 theme.palette.text.secondary,
238 theme.palette.action.focusOpacity + theme.palette.action.hoverOpacity,
239 ),
240 }, 288 },
241 }, 289 },
242 '.tok-comment': { 290 '.cm-gutters:hover .cm-foldGutter': {
243 fontStyle: 'italic', 291 opacity: 1,
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 }, 292 },
255 '.tok-typeName, .tok-atom': { 293 [`.${editorClassNames.foldMarker}`]: {
256 color: theme.palette.text.primary, 294 display: 'block',
295 margin: '4px 0',
296 padding: 0,
297 maskImage: svgURL(expandMoreSVG),
298 maskSize: '16px 16px',
299 height: 16,
300 width: 16,
301 background: theme.palette.text.primary,
302 border: 'none',
303 cursor: 'pointer',
257 }, 304 },
258 '.tok-variableName': { 305 [`.${editorClassNames.foldMarkerClosed}`]: {
259 color: theme.palette.highlight.parameter, 306 transform: 'rotate(-90deg)',
260 }, 307 },
261 '.tok-problem-node': { 308 [`.${editorClassNames.foldPlaceholder}`]: {
262 '&, & .tok-variableName': { 309 ...theme.typography.editor,
310 padding: 0,
311 fontFamily: 'inherit',
312 background: 'transparent',
313 border: 'none',
314 cursor: 'pointer',
315 // Use an inner `span` element to match the height of other text highlights.
316 span: {
263 color: theme.palette.text.secondary, 317 color: theme.palette.text.secondary,
318 backgroundColor: 'transparent',
319 backgroundImage: `linear-gradient(${theme.palette.highlight.foldPlaceholder}, ${theme.palette.highlight.foldPlaceholder})`,
320 transition: theme.transitions.create('background-color', {
321 duration: theme.transitions.duration.short,
322 }),
264 }, 323 },
265 }, 324 '&:hover span': {
266 '.tok-problem-individual': { 325 backgroundColor: alpha(
267 '&, & .tok-variableName': { 326 theme.palette.highlight.foldPlaceholder,
268 color: theme.palette.text.primary, 327 theme.palette.action.hoverOpacity,
269 }, 328 ),
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 }, 329 },
288 }, 330 },
331 };
332
333 const completionStyle: CSSObject = {
289 '.cm-tooltip.cm-tooltip-autocomplete': { 334 '.cm-tooltip.cm-tooltip-autocomplete': {
335 ...theme.typography.editor,
290 background: theme.palette.background.paper, 336 background: theme.palette.background.paper,
291 borderRadius: theme.shape.borderRadius, 337 borderRadius: theme.shape.borderRadius,
292 overflow: 'hidden', 338 overflow: 'hidden',
@@ -300,9 +346,11 @@ export default styled('div', {
300 color: theme.palette.text.secondary, 346 color: theme.palette.text.secondary,
301 }, 347 },
302 '.cm-completionLabel': { 348 '.cm-completionLabel': {
349 ...theme.typography.editor,
303 color: theme.palette.text.primary, 350 color: theme.palette.text.primary,
304 }, 351 },
305 '.cm-completionDetail': { 352 '.cm-completionDetail': {
353 ...theme.typography.editor,
306 color: theme.palette.text.secondary, 354 color: theme.palette.text.secondary,
307 fontStyle: 'normal', 355 fontStyle: 'normal',
308 }, 356 },
@@ -316,27 +364,22 @@ export default styled('div', {
316 }, 364 },
317 }, 365 },
318 }, 366 },
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': { 367 '.cm-completionIcon': {
329 width: 16, 368 width: 16,
330 padding: 0, 369 padding: 0,
331 marginRight: '0.5em', 370 marginRight: '0.5em',
332 textAlign: 'center', 371 textAlign: 'center',
333 }, 372 },
334 ...codeMirrorLintStyle, 373 };
335 '.cm-problem-read': { 374
336 background: theme.palette.highlight.occurences.read, 375 return {
337 }, 376 ...generalStyle,
338 '.cm-problem-write': { 377 ...highlightingStyle,
339 background: theme.palette.highlight.occurences.write, 378 ...matchingStyle,
340 }, 379 ...lineNumberStyle,
380 ...panelStyle,
381 ...lintStyle,
382 ...foldStyle,
383 ...completionStyle,
341 }; 384 };
342}); 385});
diff --git a/subprojects/frontend/src/editor/GenerateButton.tsx b/subprojects/frontend/src/editor/GenerateButton.tsx
index 0eed129e..14258723 100644
--- a/subprojects/frontend/src/editor/GenerateButton.tsx
+++ b/subprojects/frontend/src/editor/GenerateButton.tsx
@@ -22,7 +22,11 @@ function GenerateButton(): JSX.Element {
22 22
23 if (errorCount > 0) { 23 if (errorCount > 0) {
24 return ( 24 return (
25 <Button color="error" onClick={() => editorStore.nextDiagnostic()}> 25 <Button
26 aria-label={`Select next diagnostic out of ${summary}`}
27 color="error"
28 onClick={() => editorStore.nextDiagnostic()}
29 >
26 {summary} 30 {summary}
27 </Button> 31 </Button>
28 ); 32 );
diff --git a/subprojects/frontend/src/editor/LintPanelStore.ts b/subprojects/frontend/src/editor/LintPanelStore.ts
new file mode 100644
index 00000000..502f9c59
--- /dev/null
+++ b/subprojects/frontend/src/editor/LintPanelStore.ts
@@ -0,0 +1,10 @@
1import { closeLintPanel, openLintPanel } from '@codemirror/lint';
2
3import type EditorStore from './EditorStore';
4import PanelStore from './PanelStore';
5
6export default class LintPanelStore extends PanelStore {
7 constructor(store: EditorStore) {
8 super('cm-panel-lint', openLintPanel, closeLintPanel, store);
9 }
10}
diff --git a/subprojects/frontend/src/editor/PanelStore.ts b/subprojects/frontend/src/editor/PanelStore.ts
index 653d309c..1af4ace1 100644
--- a/subprojects/frontend/src/editor/PanelStore.ts
+++ b/subprojects/frontend/src/editor/PanelStore.ts
@@ -10,14 +10,17 @@ const log = getLogger('editor.PanelStore');
10export default class PanelStore { 10export default class PanelStore {
11 state = false; 11 state = false;
12 12
13 element: Element | undefined;
14
13 constructor( 15 constructor(
14 private readonly panelId: string, 16 readonly panelClass: string,
15 private readonly openCommand: Command, 17 private readonly openCommand: Command,
16 private readonly closeCommand: Command, 18 private readonly closeCommand: Command,
17 private readonly store: EditorStore, 19 protected readonly store: EditorStore,
18 ) { 20 ) {
19 makeObservable(this, { 21 makeObservable(this, {
20 state: observable, 22 state: observable,
23 element: observable,
21 open: action, 24 open: action,
22 close: action, 25 close: action,
23 toggle: action, 26 toggle: action,
@@ -25,6 +28,10 @@ export default class PanelStore {
25 }); 28 });
26 } 29 }
27 30
31 get id(): string {
32 return `${this.store.id}-${this.panelClass}`;
33 }
34
28 open(): boolean { 35 open(): boolean {
29 return this.setState(true); 36 return this.setState(true);
30 } 37 }
@@ -41,7 +48,7 @@ export default class PanelStore {
41 if (this.state === newState) { 48 if (this.state === newState) {
42 return false; 49 return false;
43 } 50 }
44 log.debug('Show', this.panelId, 'panel', newState); 51 log.debug('Show', this.panelClass, 'panel', newState);
45 if (newState) { 52 if (newState) {
46 this.doOpen(); 53 this.doOpen();
47 } else { 54 } else {
@@ -58,7 +65,7 @@ export default class PanelStore {
58 } 65 }
59 } 66 }
60 67
61 private doOpen(): void { 68 protected doOpen(): void {
62 if (!this.store.doCommand(this.openCommand)) { 69 if (!this.store.doCommand(this.openCommand)) {
63 return; 70 return;
64 } 71 }
@@ -66,10 +73,19 @@ export default class PanelStore {
66 if (view === undefined) { 73 if (view === undefined) {
67 return; 74 return;
68 } 75 }
69 const buttonQuery = `.cm-${this.panelId}.cm-panel button[name="close"]`; 76 // We always access the panel DOM element by class name, even for the search panel,
70 const closeButton = view.dom.querySelector(buttonQuery); 77 // where we control the creation of the element, so that we can have a uniform way to
78 // access panel created by both CodeMirror and us.
79 this.element =
80 view.dom.querySelector(`.${this.panelClass}.cm-panel`) ?? undefined;
81 if (this.element === undefined) {
82 log.error('Failed to add panel', this.panelClass, 'to DOM');
83 return;
84 }
85 this.element.id = this.id;
86 const closeButton = this.element.querySelector('button[name="close"]');
71 if (closeButton !== null) { 87 if (closeButton !== null) {
72 log.debug('Addig close button callback to', this.panelId, 'panel'); 88 log.debug('Addig close button callback to', this.panelClass, 'panel');
73 // We must remove the event listener from the button that dispatches a transaction 89 // We must remove the event listener from the button that dispatches a transaction
74 // without going through `EditorStore`. This listened is added by CodeMirror, 90 // 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 91 // and we can only remove it by cloning the DOM node: https://stackoverflow.com/a/9251864
@@ -79,12 +95,17 @@ export default class PanelStore {
79 event.preventDefault(); 95 event.preventDefault();
80 }); 96 });
81 closeButton.replaceWith(closeButtonWithoutListeners); 97 closeButton.replaceWith(closeButtonWithoutListeners);
82 } else {
83 log.error('Opened', this.panelId, 'panel has no close button');
84 } 98 }
85 } 99 }
86 100
87 private doClose(): void { 101 protected doClose(): void {
88 this.store.doCommand(this.closeCommand); 102 this.store.doCommand(this.closeCommand);
103 if (this.element === undefined) {
104 return;
105 }
106 if (this.store.view !== undefined) {
107 log.error('Failed to remove search panel from DOM');
108 }
109 this.element = undefined;
89 } 110 }
90} 111}
diff --git a/subprojects/frontend/src/editor/SearchPanel.ts b/subprojects/frontend/src/editor/SearchPanel.ts
new file mode 100644
index 00000000..c9df41b7
--- /dev/null
+++ b/subprojects/frontend/src/editor/SearchPanel.ts
@@ -0,0 +1,32 @@
1import {
2 type EditorView,
3 type Panel,
4 runScopeHandlers,
5} from '@codemirror/view';
6
7import type SearchPanelStore from './SearchPanelStore';
8
9export default class SearchPanel implements Panel {
10 readonly dom: HTMLDivElement;
11
12 constructor(view: EditorView, store: SearchPanelStore) {
13 this.dom = document.createElement('div');
14 this.dom.id = store.id;
15 this.dom.className = store.panelClass;
16 this.dom.addEventListener(
17 'keydown',
18 (event) => {
19 if (runScopeHandlers(view, event, 'search-panel')) {
20 event.preventDefault();
21 }
22 },
23 {
24 capture: true,
25 },
26 );
27 }
28
29 get top(): boolean {
30 return true;
31 }
32}
diff --git a/subprojects/frontend/src/editor/SearchPanelStore.ts b/subprojects/frontend/src/editor/SearchPanelStore.ts
new file mode 100644
index 00000000..43a571e5
--- /dev/null
+++ b/subprojects/frontend/src/editor/SearchPanelStore.ts
@@ -0,0 +1,108 @@
1import {
2 closeSearchPanel,
3 findNext,
4 findPrevious,
5 getSearchQuery,
6 openSearchPanel,
7 replaceAll,
8 replaceNext,
9 SearchQuery,
10 selectMatches,
11 setSearchQuery,
12} from '@codemirror/search';
13import { action, computed, makeObservable, observable, override } from 'mobx';
14
15import type EditorStore from './EditorStore';
16import PanelStore from './PanelStore';
17
18export default class SearchPanelStore extends PanelStore {
19 searchField: HTMLInputElement | undefined;
20
21 constructor(store: EditorStore) {
22 // Use a custom class name to avoid specificity issues with
23 // CodeMirror `.cm-search.cm-panel` CSS styles.
24 super('refinery-cm-search', openSearchPanel, closeSearchPanel, store);
25 makeObservable(this, {
26 searchField: observable.ref,
27 query: computed,
28 invalidRegexp: computed,
29 open: override,
30 setSearchField: action,
31 updateQuery: action,
32 findNext: action,
33 findPrevious: action,
34 selectMatches: action,
35 });
36 }
37
38 setSearchField(newSearchField: HTMLInputElement | undefined): void {
39 this.searchField = newSearchField;
40 if (this.state) {
41 this.selectSearchField();
42 }
43 }
44
45 get query(): SearchQuery {
46 return getSearchQuery(this.store.state);
47 }
48
49 get invalidRegexp(): boolean {
50 const { search, valid } = this.query;
51 return !valid && search !== '';
52 }
53
54 updateQuery(newQueryOptions: {
55 search?: string;
56 caseSensitive?: boolean;
57 literal?: boolean;
58 regexp?: boolean;
59 replace?: string;
60 }): void {
61 const { search, caseSensitive, literal, regexp, replace } = this.query;
62 const newQuery = new SearchQuery({
63 search,
64 caseSensitive,
65 literal,
66 regexp,
67 replace,
68 ...newQueryOptions,
69 ...(newQueryOptions.regexp === true && { literal: false }),
70 ...(newQueryOptions.literal === true && { regexp: false }),
71 });
72 this.store.dispatch({
73 effects: [setSearchQuery.of(newQuery)],
74 });
75 }
76
77 findNext(): void {
78 this.store.doCommand(findNext);
79 }
80
81 findPrevious(): void {
82 this.store.doCommand(findPrevious);
83 }
84
85 selectMatches(): void {
86 this.store.doCommand(selectMatches);
87 }
88
89 replaceNext(): void {
90 this.store.doCommand(replaceNext);
91 }
92
93 replaceAll(): void {
94 this.store.doCommand(replaceAll);
95 }
96
97 override open(): boolean {
98 return super.open() || this.selectSearchField();
99 }
100
101 private selectSearchField(): boolean {
102 if (this.searchField === undefined) {
103 return false;
104 }
105 this.searchField.select();
106 return true;
107 }
108}
diff --git a/subprojects/frontend/src/editor/SearchToolbar.tsx b/subprojects/frontend/src/editor/SearchToolbar.tsx
new file mode 100644
index 00000000..2840290b
--- /dev/null
+++ b/subprojects/frontend/src/editor/SearchToolbar.tsx
@@ -0,0 +1,198 @@
1import CloseIcon from '@mui/icons-material/Close';
2import FindReplaceIcon from '@mui/icons-material/FindReplace';
3import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
4import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
5import SearchIcon from '@mui/icons-material/Search';
6import Button from '@mui/material/Button';
7import Checkbox from '@mui/material/Checkbox';
8import FormControlLabel from '@mui/material/FormControlLabel';
9import FormHelperText from '@mui/material/FormHelperText';
10import IconButton from '@mui/material/IconButton';
11import Stack from '@mui/material/Stack';
12import TextField from '@mui/material/TextField';
13import Toolbar from '@mui/material/Toolbar';
14import { observer } from 'mobx-react-lite';
15import React, { useCallback } from 'react';
16
17import type SearchPanelStore from './SearchPanelStore';
18
19function SearchToolbar({ store }: { store: SearchPanelStore }): JSX.Element {
20 const {
21 id: panelId,
22 query: { search, valid, caseSensitive, literal, regexp, replace },
23 invalidRegexp,
24 } = store;
25
26 const searchHelperId = `${panelId}-search-helper`;
27
28 const searchFieldRef = useCallback(
29 (element: HTMLInputElement | null) =>
30 store.setSearchField(element ?? undefined),
31 [store],
32 );
33
34 return (
35 <Toolbar variant="dense" sx={{ py: 0.5, alignItems: 'start' }}>
36 <Stack
37 direction="row"
38 flexWrap="wrap"
39 alignItems="center"
40 rowGap={0.5}
41 flexGrow={1}
42 >
43 <Stack direction="row" flexWrap="wrap" alignItems="center" rowGap={0.5}>
44 <TextField
45 type="search"
46 placeholder="Search"
47 aria-label="Search"
48 {...(invalidRegexp && {
49 'aria-describedby': searchHelperId,
50 })}
51 value={search}
52 error={invalidRegexp}
53 onChange={(event) =>
54 store.updateQuery({ search: event.target.value })
55 }
56 onKeyDown={(event) => {
57 if (event.key === 'Enter') {
58 event.preventDefault();
59 if (event.shiftKey) {
60 store.findPrevious();
61 } else {
62 store.findNext();
63 }
64 }
65 }}
66 variant="standard"
67 size="small"
68 sx={{ my: 0.25, mr: 1 }}
69 inputRef={searchFieldRef}
70 />
71 {invalidRegexp && (
72 <FormHelperText
73 id={searchHelperId}
74 sx={(theme) => ({
75 my: 0,
76 mr: 1,
77 fontSize: 'inherit',
78 color: theme.palette.error.main,
79 })}
80 >
81 Invalid regexp
82 </FormHelperText>
83 )}
84 <Stack
85 direction="row"
86 flexWrap="wrap"
87 alignItems="center"
88 mr={1}
89 rowGap={0.5}
90 >
91 <IconButton
92 aria-label="Previous"
93 disabled={!valid}
94 onClick={() => store.findPrevious()}
95 >
96 <KeyboardArrowUpIcon fontSize="small" />
97 </IconButton>
98 <IconButton
99 aria-label="Next"
100 disabled={!valid}
101 onClick={() => store.findNext()}
102 >
103 <KeyboardArrowDownIcon fontSize="small" />
104 </IconButton>
105 <Button
106 disabled={!valid}
107 onClick={() => store.selectMatches()}
108 color="inherit"
109 startIcon={<SearchIcon fontSize="inherit" />}
110 >
111 Find all
112 </Button>
113 </Stack>
114 <Stack direction="row" flexWrap="wrap" rowGap={0.5}>
115 <FormControlLabel
116 control={
117 <Checkbox
118 checked={caseSensitive}
119 onChange={(event) =>
120 store.updateQuery({ caseSensitive: event.target.checked })
121 }
122 size="small"
123 />
124 }
125 label="Match case"
126 />
127 <FormControlLabel
128 control={
129 <Checkbox
130 checked={literal}
131 onChange={(event) =>
132 store.updateQuery({ literal: event.target.checked })
133 }
134 size="small"
135 />
136 }
137 label="Literal"
138 />
139 <FormControlLabel
140 control={
141 <Checkbox
142 checked={regexp}
143 onChange={(event) =>
144 store.updateQuery({ regexp: event.target.checked })
145 }
146 size="small"
147 />
148 }
149 label="Regexp"
150 />
151 </Stack>
152 </Stack>
153 <Stack direction="row" flexWrap="wrap" alignItems="center" rowGap={0.5}>
154 <TextField
155 placeholder="Replace with"
156 aria-label="Replace with"
157 value={replace}
158 onChange={(event) =>
159 store.updateQuery({ replace: event.target.value })
160 }
161 onKeyDown={(event) => {
162 if (event.key === 'Enter') {
163 event.preventDefault();
164 store.replaceNext();
165 }
166 }}
167 variant="standard"
168 size="small"
169 sx={{ mr: 1 }}
170 />
171 <Stack direction="row" flexWrap="wrap" rowGap={0.5}>
172 <Button
173 disabled={!valid}
174 onClick={() => store.replaceNext()}
175 color="inherit"
176 startIcon={<FindReplaceIcon fontSize="inherit" />}
177 >
178 Replace
179 </Button>
180 <Button
181 disabled={!valid}
182 onClick={() => store.replaceAll()}
183 color="inherit"
184 startIcon={<FindReplaceIcon fontSize="inherit" />}
185 >
186 Replace all
187 </Button>
188 </Stack>
189 </Stack>
190 </Stack>
191 <IconButton onClick={() => store.close()} sx={{ ml: 1 }}>
192 <CloseIcon fontSize="small" />
193 </IconButton>
194 </Toolbar>
195 );
196}
197
198export default observer(SearchToolbar);
diff --git a/subprojects/frontend/src/editor/createEditorState.ts b/subprojects/frontend/src/editor/createEditorState.ts
index 33346c05..caaca7f5 100644
--- a/subprojects/frontend/src/editor/createEditorState.ts
+++ b/subprojects/frontend/src/editor/createEditorState.ts
@@ -35,6 +35,7 @@ import { classHighlighter } from '@lezer/highlight';
35import problemLanguageSupport from '../language/problemLanguageSupport'; 35import problemLanguageSupport from '../language/problemLanguageSupport';
36 36
37import type EditorStore from './EditorStore'; 37import type EditorStore from './EditorStore';
38import SearchPanel from './SearchPanel';
38import editorClassNames from './editorClassNames'; 39import editorClassNames from './editorClassNames';
39import findOccurrences from './findOccurrences'; 40import findOccurrences from './findOccurrences';
40import semanticHighlighting from './semanticHighlighting'; 41import semanticHighlighting from './semanticHighlighting';
@@ -61,7 +62,11 @@ export default function createEditorState(
61 history(), 62 history(),
62 indentOnInput(), 63 indentOnInput(),
63 rectangularSelection(), 64 rectangularSelection(),
64 search({ top: true }), 65 search({
66 createPanel(view) {
67 return new SearchPanel(view, store.searchPanel);
68 },
69 }),
65 syntaxHighlighting(classHighlighter), 70 syntaxHighlighting(classHighlighter),
66 semanticHighlighting, 71 semanticHighlighting,
67 // We add the gutters to `extensions` in the order we want them to appear. 72 // We add the gutters to `extensions` in the order we want them to appear.
@@ -72,8 +77,10 @@ export default function createEditorState(
72 const button = document.createElement('button'); 77 const button = document.createElement('button');
73 button.className = editorClassNames.foldPlaceholder; 78 button.className = editorClassNames.foldPlaceholder;
74 button.ariaLabel = 'Unfold lines'; 79 button.ariaLabel = 'Unfold lines';
75 button.innerText = '...'; 80 const span = document.createElement('span');
76 button.onclick = onClick; 81 span.innerText = '...';
82 button.appendChild(span);
83 button.addEventListener('click', onClick);
77 return button; 84 return button;
78 }, 85 },
79 }), 86 }),
diff --git a/subprojects/frontend/src/theme/ThemeProvider.tsx b/subprojects/frontend/src/theme/ThemeProvider.tsx
index dd4f5bb8..2ec9b9d4 100644
--- a/subprojects/frontend/src/theme/ThemeProvider.tsx
+++ b/subprojects/frontend/src/theme/ThemeProvider.tsx
@@ -2,9 +2,12 @@ import {
2 alpha, 2 alpha,
3 createTheme, 3 createTheme,
4 type Components, 4 type Components,
5 type CSSObject,
5 responsiveFontSizes, 6 responsiveFontSizes,
6 type ThemeOptions, 7 type ThemeOptions,
7 ThemeProvider as MaterialUiThemeProvider, 8 ThemeProvider as MaterialUiThemeProvider,
9 type TypographyStyle,
10 type TypographyVariantsOptions,
8} from '@mui/material/styles'; 11} from '@mui/material/styles';
9import { observer } from 'mobx-react-lite'; 12import { observer } from 'mobx-react-lite';
10import React, { type ReactNode } from 'react'; 13import React, { type ReactNode } from 'react';
@@ -22,13 +25,30 @@ interface HighlightPalette {
22 comment: string; 25 comment: string;
23 activeLine: string; 26 activeLine: string;
24 selection: string; 27 selection: string;
28 lineNumber: string;
29 foldPlaceholder: string;
30 activeLintRange: string;
25 occurences: { 31 occurences: {
26 read: string; 32 read: string;
27 write: string; 33 write: string;
28 }; 34 };
35 search: {
36 match: string;
37 selected: string;
38 contrastText: string;
39 };
29} 40}
30 41
31declare module '@mui/material/styles' { 42declare module '@mui/material/styles' {
43 interface TypographyVariants {
44 editor: TypographyStyle;
45 }
46
47 // eslint-disable-next-line @typescript-eslint/no-shadow -- Augment imported interface.
48 interface TypographyVariantsOptions {
49 editor: TypographyStyle;
50 }
51
32 interface Palette { 52 interface Palette {
33 outer: OuterPalette; 53 outer: OuterPalette;
34 highlight: HighlightPalette; 54 highlight: HighlightPalette;
@@ -41,6 +61,18 @@ declare module '@mui/material/styles' {
41} 61}
42 62
43function getMUIThemeOptions(darkMode: boolean): ThemeOptions { 63function getMUIThemeOptions(darkMode: boolean): ThemeOptions {
64 const typography: TypographyVariantsOptions = {
65 editor: {
66 fontFamily: '"JetBrains MonoVariable", "JetBrains Mono", monospace',
67 fontFeatureSettings: '"liga", "calt"',
68 fontSize: '1rem',
69 fontWeight: 400,
70 lineHeight: 1.5,
71 letterSpacing: 0,
72 textRendering: 'optimizeLegibility',
73 },
74 };
75
44 const components: Components = { 76 const components: Components = {
45 MuiButton: { 77 MuiButton: {
46 styleOverrides: { 78 styleOverrides: {
@@ -67,11 +99,37 @@ function getMUIThemeOptions(darkMode: boolean): ThemeOptions {
67 }, 99 },
68 }, 100 },
69 }, 101 },
102 MuiTooltip: {
103 styleOverrides: {
104 tooltip: {
105 background: alpha('#212121', 0.93),
106 color: '#fff',
107 },
108 arrow: {
109 color: alpha('#212121', 0.93),
110 },
111 },
112 },
70 }; 113 };
71 114
72 return darkMode 115 return darkMode
73 ? { 116 ? {
74 components, 117 typography,
118 components: {
119 ...components,
120 MuiTooltip: {
121 ...(components.MuiTooltip || {}),
122 styleOverrides: {
123 ...(components.MuiTooltip?.styleOverrides || {}),
124 tooltip: {
125 ...((components.MuiTooltip?.styleOverrides?.tooltip as
126 | CSSObject
127 | undefined) || {}),
128 color: '#ebebff',
129 },
130 },
131 },
132 },
75 palette: { 133 palette: {
76 mode: 'dark', 134 mode: 'dark',
77 primary: { main: '#56b6c2' }, 135 primary: { main: '#56b6c2' },
@@ -99,31 +157,57 @@ function getMUIThemeOptions(darkMode: boolean): ThemeOptions {
99 comment: '#6b717d', 157 comment: '#6b717d',
100 activeLine: '#21252b', 158 activeLine: '#21252b',
101 selection: '#3e4453', 159 selection: '#3e4453',
160 lineNumber: '#4b5263',
161 foldPlaceholder: alpha('#ebebff', 0.12),
162 activeLintRange: alpha('#fbc346', 0.28),
102 occurences: { 163 occurences: {
103 read: 'rgba(255, 255, 255, 0.15)', 164 read: alpha('#ebebff', 0.24),
104 write: 'rgba(255, 255, 128, 0.4)', 165 write: alpha('#ebebff', 0.24),
166 },
167 search: {
168 match: '#33eaff',
169 selected: '#dd33fa',
170 contrastText: '#21252b',
105 }, 171 },
106 }, 172 },
107 }, 173 },
108 } 174 }
109 : { 175 : {
110 components, 176 typography,
177 components: {
178 ...components,
179 MuiToolbar: {
180 styleOverrides: {
181 root: {
182 color: 'rgba(0, 0, 0, 0.54)',
183 },
184 },
185 },
186 },
111 palette: { 187 palette: {
112 mode: 'light', 188 mode: 'light',
113 primary: { main: '#0097a7' }, 189 primary: { main: '#0398a8' },
114 outer: { 190 outer: {
115 background: '#f5f5f5', 191 background: '#f5f5f5',
116 border: '#d7d7d7', 192 border: '#cacaca',
117 }, 193 },
118 highlight: { 194 highlight: {
119 number: '#1976d2', 195 number: '#3d79a2',
120 parameter: '#6a3e3e', 196 parameter: '#6a3e3e',
121 comment: alpha('#000', 0.38), 197 comment: 'rgba(0, 0, 0, 0.38)',
122 activeLine: '#f5f5f5', 198 activeLine: '#f5f5f5',
123 selection: '#c8e4fb', 199 selection: '#c8e4fb',
200 lineNumber: 'rgba(0, 0, 0, 0.38)',
201 foldPlaceholder: 'rgba(0, 0, 0, 0.12)',
202 activeLintRange: alpha('#ed6c02', 0.24),
124 occurences: { 203 occurences: {
125 read: '#ceccf7', 204 read: 'rgba(0, 0, 0, 0.12)',
126 write: '#f0d8a8', 205 write: 'rgba(0, 0, 0, 0.12)',
206 },
207 search: {
208 match: '#00bcd4',
209 selected: '#d500f9',
210 contrastText: '#ffffff',
127 }, 211 },
128 }, 212 },
129 }, 213 },