diff options
author | Kristóf Marussy <kristof@marussy.com> | 2022-08-17 21:43:29 +0200 |
---|---|---|
committer | Kristóf Marussy <kristof@marussy.com> | 2022-08-17 21:43:29 +0200 |
commit | bb900e1bd40a6b7efd7a538114d985ea7f7e3e88 (patch) | |
tree | bb15a937ade92313dc654a640bc1de925442eff2 /subprojects/frontend/src | |
parent | refactor(frondend): improve editor store and theme (diff) | |
download | refinery-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/frontend/src')
-rw-r--r-- | subprojects/frontend/src/TopBar.tsx | 7 | ||||
-rw-r--r-- | subprojects/frontend/src/editor/EditorArea.tsx | 14 | ||||
-rw-r--r-- | subprojects/frontend/src/editor/EditorButtons.tsx | 7 | ||||
-rw-r--r-- | subprojects/frontend/src/editor/EditorStore.ts | 44 | ||||
-rw-r--r-- | subprojects/frontend/src/editor/EditorTheme.ts | 433 | ||||
-rw-r--r-- | subprojects/frontend/src/editor/GenerateButton.tsx | 6 | ||||
-rw-r--r-- | subprojects/frontend/src/editor/LintPanelStore.ts | 10 | ||||
-rw-r--r-- | subprojects/frontend/src/editor/PanelStore.ts | 41 | ||||
-rw-r--r-- | subprojects/frontend/src/editor/SearchPanel.ts | 32 | ||||
-rw-r--r-- | subprojects/frontend/src/editor/SearchPanelStore.ts | 108 | ||||
-rw-r--r-- | subprojects/frontend/src/editor/SearchToolbar.tsx | 198 | ||||
-rw-r--r-- | subprojects/frontend/src/editor/createEditorState.ts | 13 | ||||
-rw-r--r-- | subprojects/frontend/src/theme/ThemeProvider.tsx | 104 |
13 files changed, 774 insertions, 243 deletions
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 @@ | |||
1 | import Portal from '@mui/material/Portal'; | ||
1 | import { useTheme } from '@mui/material/styles'; | 2 | import { useTheme } from '@mui/material/styles'; |
2 | import { observer } from 'mobx-react-lite'; | 3 | import { observer } from 'mobx-react-lite'; |
3 | import React, { useCallback, useEffect } from 'react'; | 4 | import React, { useCallback, useEffect } from 'react'; |
@@ -5,9 +6,12 @@ import React, { useCallback, useEffect } from 'react'; | |||
5 | import { useRootStore } from '../RootStore'; | 6 | import { useRootStore } from '../RootStore'; |
6 | 7 | ||
7 | import EditorTheme from './EditorTheme'; | 8 | import EditorTheme from './EditorTheme'; |
9 | import SearchToolbar from './SearchToolbar'; | ||
8 | 10 | ||
9 | function EditorArea(): JSX.Element { | 11 | function 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'; | |||
3 | import { | 3 | import { |
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'; |
10 | import { closeSearchPanel, openSearchPanel } from '@codemirror/search'; | ||
11 | import { | 8 | import { |
12 | type StateCommand, | 9 | type StateCommand, |
13 | StateEffect, | 10 | StateEffect, |
@@ -17,11 +14,13 @@ import { | |||
17 | } from '@codemirror/state'; | 14 | } from '@codemirror/state'; |
18 | import { type Command, EditorView } from '@codemirror/view'; | 15 | import { type Command, EditorView } from '@codemirror/view'; |
19 | import { action, computed, makeObservable, observable } from 'mobx'; | 16 | import { action, computed, makeObservable, observable } from 'mobx'; |
17 | import { nanoid } from 'nanoid'; | ||
20 | 18 | ||
21 | import getLogger from '../utils/getLogger'; | 19 | import getLogger from '../utils/getLogger'; |
22 | import XtextClient from '../xtext/XtextClient'; | 20 | import XtextClient from '../xtext/XtextClient'; |
23 | 21 | ||
24 | import PanelStore from './PanelStore'; | 22 | import LintPanelStore from './LintPanelStore'; |
23 | import SearchPanelStore from './SearchPanelStore'; | ||
25 | import createEditorState from './createEditorState'; | 24 | import createEditorState from './createEditorState'; |
26 | import { type IOccurrence, setOccurrences } from './findOccurrences'; | 25 | import { type IOccurrence, setOccurrences } from './findOccurrences'; |
27 | import { | 26 | import { |
@@ -32,15 +31,17 @@ import { | |||
32 | const log = getLogger('editor.EditorStore'); | 31 | const log = getLogger('editor.EditorStore'); |
33 | 32 | ||
34 | export default class EditorStore { | 33 | export 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'; | |||
2 | import expandMoreSVG from '@material-icons/svg/svg/expand_more/baseline.svg?raw'; | 2 | import expandMoreSVG from '@material-icons/svg/svg/expand_more/baseline.svg?raw'; |
3 | import infoSVG from '@material-icons/svg/svg/info/baseline.svg?raw'; | 3 | import infoSVG from '@material-icons/svg/svg/info/baseline.svg?raw'; |
4 | import warningSVG from '@material-icons/svg/svg/warning/baseline.svg?raw'; | 4 | import warningSVG from '@material-icons/svg/svg/warning/baseline.svg?raw'; |
5 | import { alpha, styled } from '@mui/material/styles'; | 5 | import { alpha, styled, type CSSObject } from '@mui/material/styles'; |
6 | 6 | ||
7 | import editorClassNames from './editorClassNames'; | 7 | import 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 @@ | |||
1 | import { closeLintPanel, openLintPanel } from '@codemirror/lint'; | ||
2 | |||
3 | import type EditorStore from './EditorStore'; | ||
4 | import PanelStore from './PanelStore'; | ||
5 | |||
6 | export 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'); | |||
10 | export default class PanelStore { | 10 | export 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 @@ | |||
1 | import { | ||
2 | type EditorView, | ||
3 | type Panel, | ||
4 | runScopeHandlers, | ||
5 | } from '@codemirror/view'; | ||
6 | |||
7 | import type SearchPanelStore from './SearchPanelStore'; | ||
8 | |||
9 | export 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 @@ | |||
1 | import { | ||
2 | closeSearchPanel, | ||
3 | findNext, | ||
4 | findPrevious, | ||
5 | getSearchQuery, | ||
6 | openSearchPanel, | ||
7 | replaceAll, | ||
8 | replaceNext, | ||
9 | SearchQuery, | ||
10 | selectMatches, | ||
11 | setSearchQuery, | ||
12 | } from '@codemirror/search'; | ||
13 | import { action, computed, makeObservable, observable, override } from 'mobx'; | ||
14 | |||
15 | import type EditorStore from './EditorStore'; | ||
16 | import PanelStore from './PanelStore'; | ||
17 | |||
18 | export 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 @@ | |||
1 | import CloseIcon from '@mui/icons-material/Close'; | ||
2 | import FindReplaceIcon from '@mui/icons-material/FindReplace'; | ||
3 | import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; | ||
4 | import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; | ||
5 | import SearchIcon from '@mui/icons-material/Search'; | ||
6 | import Button from '@mui/material/Button'; | ||
7 | import Checkbox from '@mui/material/Checkbox'; | ||
8 | import FormControlLabel from '@mui/material/FormControlLabel'; | ||
9 | import FormHelperText from '@mui/material/FormHelperText'; | ||
10 | import IconButton from '@mui/material/IconButton'; | ||
11 | import Stack from '@mui/material/Stack'; | ||
12 | import TextField from '@mui/material/TextField'; | ||
13 | import Toolbar from '@mui/material/Toolbar'; | ||
14 | import { observer } from 'mobx-react-lite'; | ||
15 | import React, { useCallback } from 'react'; | ||
16 | |||
17 | import type SearchPanelStore from './SearchPanelStore'; | ||
18 | |||
19 | function 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 | |||
198 | export 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'; | |||
35 | import problemLanguageSupport from '../language/problemLanguageSupport'; | 35 | import problemLanguageSupport from '../language/problemLanguageSupport'; |
36 | 36 | ||
37 | import type EditorStore from './EditorStore'; | 37 | import type EditorStore from './EditorStore'; |
38 | import SearchPanel from './SearchPanel'; | ||
38 | import editorClassNames from './editorClassNames'; | 39 | import editorClassNames from './editorClassNames'; |
39 | import findOccurrences from './findOccurrences'; | 40 | import findOccurrences from './findOccurrences'; |
40 | import semanticHighlighting from './semanticHighlighting'; | 41 | import 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'; |
9 | import { observer } from 'mobx-react-lite'; | 12 | import { observer } from 'mobx-react-lite'; |
10 | import React, { type ReactNode } from 'react'; | 13 | import 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 | ||
31 | declare module '@mui/material/styles' { | 42 | declare 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 | ||
43 | function getMUIThemeOptions(darkMode: boolean): ThemeOptions { | 63 | function 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 | }, |