From 19cd11118cde7160cd447c81bc965007c0437479 Mon Sep 17 00:00:00 2001 From: Kristóf Marussy Date: Tue, 16 Aug 2022 21:14:50 +0200 Subject: refactor(frondend): improve editor store and theme Also bumps frontend dependencies. --- subprojects/frontend/package.json | 15 +- subprojects/frontend/src/RootStore.tsx | 6 +- subprojects/frontend/src/TopBar.tsx | 4 +- subprojects/frontend/src/editor/EditorArea.tsx | 138 +-------- subprojects/frontend/src/editor/EditorButtons.tsx | 10 +- subprojects/frontend/src/editor/EditorParent.ts | 228 -------------- subprojects/frontend/src/editor/EditorStore.ts | 320 ++++++++----------- subprojects/frontend/src/editor/EditorTheme.ts | 342 +++++++++++++++++++++ subprojects/frontend/src/editor/GenerateButton.tsx | 2 +- subprojects/frontend/src/editor/PanelStore.ts | 90 ++++++ .../frontend/src/editor/createEditorState.ts | 121 ++++++++ .../frontend/src/editor/editorClassNames.ts | 10 + .../src/language/problemLanguageSupport.ts | 8 +- subprojects/frontend/src/theme/EditorTheme.ts | 7 - subprojects/frontend/src/theme/ThemeProvider.tsx | 85 +++-- subprojects/frontend/src/theme/ThemeStore.ts | 26 +- subprojects/frontend/tsconfig.base.json | 1 + 17 files changed, 784 insertions(+), 629 deletions(-) delete mode 100644 subprojects/frontend/src/editor/EditorParent.ts create mode 100644 subprojects/frontend/src/editor/EditorTheme.ts create mode 100644 subprojects/frontend/src/editor/PanelStore.ts create mode 100644 subprojects/frontend/src/editor/createEditorState.ts create mode 100644 subprojects/frontend/src/editor/editorClassNames.ts delete mode 100644 subprojects/frontend/src/theme/EditorTheme.ts (limited to 'subprojects') diff --git a/subprojects/frontend/package.json b/subprojects/frontend/package.json index d438c104..69ff74c6 100644 --- a/subprojects/frontend/package.json +++ b/subprojects/frontend/package.json @@ -26,7 +26,7 @@ "@codemirror/commands": "^6.0.1", "@codemirror/language": "^6.2.1", "@codemirror/lint": "^6.0.0", - "@codemirror/search": "^6.0.1", + "@codemirror/search": "^6.1.0", "@codemirror/state": "^6.1.1", "@codemirror/view": "^6.2.0", "@emotion/react": "^11.10.0", @@ -36,9 +36,10 @@ "@fontsource/roboto": "^4.5.8", "@lezer/common": "^1.0.0", "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.2.2", + "@lezer/lr": "^1.2.3", + "@material-icons/svg": "^1.0.32", "@mui/icons-material": "5.8.4", - "@mui/material": "5.10.0", + "@mui/material": "5.10.1", "ansi-styles": "^6.1.0", "escape-string-regexp": "^5.0.0", "loglevel": "^1.8.0", @@ -53,12 +54,12 @@ "devDependencies": { "@lezer/generator": "^1.1.1", "@types/eslint": "^8.4.5", - "@types/node": "^18.7.4", + "@types/node": "^18.7.6", "@types/prettier": "^2.7.0", "@types/react": "^18.0.17", "@types/react-dom": "^18.0.6", - "@typescript-eslint/eslint-plugin": "^5.33.0", - "@typescript-eslint/parser": "^5.33.0", + "@typescript-eslint/eslint-plugin": "^5.33.1", + "@typescript-eslint/parser": "^5.33.1", "@vitejs/plugin-react": "^2.0.1", "cross-env": "^7.0.3", "eslint": "^8.22.0", @@ -73,7 +74,7 @@ "eslint-plugin-react-hooks": "^4.6.0", "prettier": "^2.7.1", "typescript": "~4.7.4", - "vite": "^3.0.7", + "vite": "^3.0.8", "vite-plugin-inject-preload": "^1.0.1" } } diff --git a/subprojects/frontend/src/RootStore.tsx b/subprojects/frontend/src/RootStore.tsx index a7406d7b..4a267b0e 100644 --- a/subprojects/frontend/src/RootStore.tsx +++ b/subprojects/frontend/src/RootStore.tsx @@ -4,13 +4,13 @@ import EditorStore from './editor/EditorStore'; import ThemeStore from './theme/ThemeStore'; export default class RootStore { - editorStore; + readonly editorStore: EditorStore; - themeStore; + readonly themeStore: ThemeStore; constructor(initialValue: string) { + this.editorStore = new EditorStore(initialValue); this.themeStore = new ThemeStore(); - this.editorStore = new EditorStore(initialValue, this.themeStore); } } diff --git a/subprojects/frontend/src/TopBar.tsx b/subprojects/frontend/src/TopBar.tsx index af571a1e..5ad80d40 100644 --- a/subprojects/frontend/src/TopBar.tsx +++ b/subprojects/frontend/src/TopBar.tsx @@ -12,8 +12,8 @@ export default function TopBar(): JSX.Element { elevation={0} color="transparent" sx={(theme) => ({ - background: theme.palette.highlight.activeLine, - borderBottom: `1px solid ${theme.palette.divider2}`, + background: theme.palette.outer.background, + borderBottom: `1px solid ${theme.palette.outer.border}`, })} > diff --git a/subprojects/frontend/src/editor/EditorArea.tsx b/subprojects/frontend/src/editor/EditorArea.tsx index d4305610..e5712461 100644 --- a/subprojects/frontend/src/editor/EditorArea.tsx +++ b/subprojects/frontend/src/editor/EditorArea.tsx @@ -1,139 +1,31 @@ -import { closeLintPanel, openLintPanel } from '@codemirror/lint'; -import { closeSearchPanel, openSearchPanel } from '@codemirror/search'; -import { type Command, EditorView } from '@codemirror/view'; +import { useTheme } from '@mui/material/styles'; import { observer } from 'mobx-react-lite'; -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect } from 'react'; import { useRootStore } from '../RootStore'; -import getLogger from '../utils/getLogger'; -import EditorParent from './EditorParent'; - -const log = getLogger('editor.EditorArea'); - -function usePanel( - panelId: string, - stateToSet: boolean, - editorView: EditorView | null, - openCommand: Command, - closeCommand: Command, - closeCallback: () => void, -) { - const [cachedViewState, setCachedViewState] = useState(false); - useEffect(() => { - if (editorView === null || cachedViewState === stateToSet) { - return; - } - if (stateToSet) { - openCommand(editorView); - const buttonQuery = `.cm-${panelId}.cm-panel button[name="close"]`; - const closeButton = editorView.dom.querySelector(buttonQuery); - if (closeButton) { - log.debug('Addig close button callback to', panelId, 'panel'); - // We must remove the event listener added by CodeMirror from the button - // that dispatches a transaction without going through `EditorStorre`. - // Cloning a DOM node removes event listeners, - // see https://stackoverflow.com/a/9251864 - const closeButtonWithoutListeners = closeButton.cloneNode(true); - closeButtonWithoutListeners.addEventListener('click', (event) => { - closeCallback(); - event.preventDefault(); - }); - closeButton.replaceWith(closeButtonWithoutListeners); - } else { - log.error('Opened', panelId, 'panel has no close button'); - } - } else { - closeCommand(editorView); - } - setCachedViewState(stateToSet); - }, [ - stateToSet, - editorView, - cachedViewState, - panelId, - openCommand, - closeCommand, - closeCallback, - ]); - return setCachedViewState; -} - -function fixCodeMirrorAccessibility(editorView: EditorView) { - // Reported by Lighthouse 8.3.0. - const { contentDOM } = editorView; - contentDOM.removeAttribute('aria-expanded'); - contentDOM.setAttribute('aria-label', 'Code editor'); -} +import EditorTheme from './EditorTheme'; function EditorArea(): JSX.Element { const { editorStore } = useRootStore(); - const editorParentRef = useRef(null); - const [editorViewState, setEditorViewState] = useState( - null, - ); + const { + palette: { mode: paletteMode }, + } = useTheme(); - const setSearchPanelOpen = usePanel( - 'search', - editorStore.showSearchPanel, - editorViewState, - openSearchPanel, - closeSearchPanel, - useCallback(() => editorStore.setSearchPanelOpen(false), [editorStore]), + useEffect( + () => editorStore.setDarkMode(paletteMode === 'dark'), + [editorStore, paletteMode], ); - const setLintPanelOpen = usePanel( - 'panel-lint', - editorStore.showLintPanel, - editorViewState, - openLintPanel, - closeLintPanel, - useCallback(() => editorStore.setLintPanelOpen(false), [editorStore]), + const editorParentRef = useCallback( + (editorParent: HTMLDivElement | null) => { + editorStore.setEditorParent(editorParent); + }, + [editorStore], ); - useEffect(() => { - if (editorParentRef.current === null) { - return () => { - // Nothing to clean up. - }; - } - - const editorView = new EditorView({ - state: editorStore.state, - parent: editorParentRef.current, - dispatch: (transaction) => { - editorStore.onTransaction(transaction); - editorView.update([transaction]); - if (editorView.state !== editorStore.state) { - log.error( - 'Failed to synchronize editor state - store state:', - editorStore.state, - 'view state:', - editorView.state, - ); - } - }, - }); - fixCodeMirrorAccessibility(editorView); - setEditorViewState(editorView); - setSearchPanelOpen(false); - setLintPanelOpen(false); - // `dispatch` is bound to the view instance, - // so it does not have to be called as a method. - // eslint-disable-next-line @typescript-eslint/unbound-method - editorStore.updateDispatcher(editorView.dispatch); - log.info('Editor created'); - - return () => { - editorStore.updateDispatcher(null); - editorView.destroy(); - log.info('Editor destroyed'); - }; - }, [editorStore, setSearchPanelOpen, setLintPanelOpen]); - return ( - diff --git a/subprojects/frontend/src/editor/EditorButtons.tsx b/subprojects/frontend/src/editor/EditorButtons.tsx index 1412a314..34b64751 100644 --- a/subprojects/frontend/src/editor/EditorButtons.tsx +++ b/subprojects/frontend/src/editor/EditorButtons.tsx @@ -19,7 +19,7 @@ import { useRootStore } from '../RootStore'; // Exhastive switch as proven by TypeScript. // eslint-disable-next-line consistent-return -function getLintIcon(severity: Diagnostic['severity'] | null) { +function getLintIcon(severity: Diagnostic['severity'] | undefined) { switch (severity) { case 'error': return ; @@ -61,16 +61,16 @@ function EditorButtons(): JSX.Element { editorStore.toggleSearchPanel()} + selected={editorStore.searchPanel.state} + onClick={() => editorStore.searchPanel.toggle()} aria-label="Show find/replace" value="show-search-panel" > editorStore.toggleLintPanel()} + selected={editorStore.lintPanel.state} + onClick={() => editorStore.lintPanel.toggle()} aria-label="Show diagnostics panel" value="show-lint-panel" > diff --git a/subprojects/frontend/src/editor/EditorParent.ts b/subprojects/frontend/src/editor/EditorParent.ts deleted file mode 100644 index 3742b89c..00000000 --- a/subprojects/frontend/src/editor/EditorParent.ts +++ /dev/null @@ -1,228 +0,0 @@ -import { alpha, styled } from '@mui/material/styles'; - -export default styled('div', { - name: 'EditorParent', - shouldForwardProp: (propName) => propName !== 'showLineNumbers', -})<{ showLineNumbers: boolean }>(({ theme, showLineNumbers }) => { - const codeMirrorLintStyle: Record = {}; - (['error', 'warning', 'info'] as const).forEach((severity) => { - const color = theme.palette[severity].main; - codeMirrorLintStyle[`.cm-diagnostic-${severity}`] = { - borderLeftColor: color, - }; - codeMirrorLintStyle[`.cm-lintRange-${severity}`] = { - backgroundImage: 'none', - textDecoration: `underline wavy ${color}`, - textDecorationSkipInk: 'none', - }; - }); - - return { - background: theme.palette.background.default, - '&, .cm-editor': { - height: '100%', - }, - '.cm-content': { - padding: 0, - }, - '.cm-scroller, .cm-tooltip-autocomplete, .cm-completionLabel, .cm-completionDetail': - { - ...theme.typography.body1, - fontFamily: '"JetBrains MonoVariable", "JetBrains Mono", monospace', - fontFeatureSettings: '"liga", "calt"', - letterSpacing: 0, - textRendering: 'optimizeLegibility', - }, - '.cm-scroller': { - color: theme.palette.text.secondary, - }, - '.cm-gutters': { - background: 'transparent', - color: theme.palette.text.disabled, - border: 'none', - }, - '.cm-specialChar': { - color: theme.palette.secondary.main, - }, - '.cm-activeLine': { - background: theme.palette.highlight.activeLine, - }, - '.cm-foldGutter': { - color: alpha(theme.palette.text.primary, 0), - transition: theme.transitions.create('color', { - duration: theme.transitions.duration.short, - }), - '@media (hover: none)': { - color: theme.palette.text.primary, - }, - }, - '.cm-gutters:hover .cm-foldGutter': { - color: theme.palette.text.primary, - }, - '.cm-activeLineGutter': { - background: 'transparent', - }, - '.cm-lineNumbers': { - ...(!showLineNumbers && { - display: 'none !important', - }), - '.cm-activeLineGutter': { - color: theme.palette.text.primary, - }, - }, - '.cm-cursor, .cm-cursor-primary': { - borderLeft: `2px solid ${theme.palette.primary.main}`, - }, - '.cm-selectionBackground': { - background: theme.palette.selection.main, - }, - '.cm-focused': { - outline: 'none', - '.cm-selectionBackground': { - background: theme.palette.selection.main, - }, - }, - '.cm-panels-top': { - color: theme.palette.text.secondary, - }, - '.cm-panel': { - '&, & button, & input': { - fontFamily: theme.typography.fontFamily, - }, - background: theme.palette.background.default, - borderTop: `1px solid ${theme.palette.divider2}`, - 'button[name="close"]': { - background: 'transparent', - color: theme.palette.text.secondary, - cursor: 'pointer', - }, - }, - '.cm-panel.cm-panel-lint': { - boderBottom: 'none', - 'button[name="close"]': { - // Close button interferes with scrollbar, so we better hide it. - // The panel can still be closed from the toolbar. - display: 'none', - }, - ul: { - li: { - cursor: 'pointer', - color: theme.palette.text.primary, - }, - '[aria-selected], &:focus [aria-selected]': { - background: theme.palette.selection.main, - color: theme.palette.selection.contrastText, - }, - }, - }, - '.cm-foldPlaceholder': { - color: theme.palette.text.secondary, - backgroundColor: alpha(theme.palette.text.secondary, 0), - border: `1px solid ${alpha(theme.palette.text.secondary, 0.5)}`, - borderRadius: theme.shape.borderRadius, - transition: theme.transitions.create( - ['background-color', 'border-color', 'color'], - { - duration: theme.transitions.duration.short, - }, - ), - '&:hover': { - backgroundColor: alpha( - theme.palette.text.secondary, - theme.palette.action.hoverOpacity, - ), - borderColor: theme.palette.text.secondary, - '@media (hover: none)': { - backgroundColor: 'transparent', - }, - }, - }, - '.tok-comment': { - fontStyle: 'italic', - color: theme.palette.highlight.comment, - }, - '.tok-number': { - color: theme.palette.highlight.number, - }, - '.tok-string': { - color: theme.palette.secondary.dark, - }, - '.tok-keyword': { - color: theme.palette.primary.main, - }, - '.tok-typeName, .tok-macroName, .tok-atom': { - color: theme.palette.text.primary, - }, - '.tok-variableName': { - color: theme.palette.highlight.parameter, - }, - '.tok-problem-node': { - '&, & .tok-variableName': { - color: theme.palette.text.secondary, - }, - }, - '.tok-problem-individual': { - '&, & .tok-variableName': { - color: theme.palette.text.primary, - }, - }, - '.tok-problem-abstract, .tok-problem-new': { - fontStyle: 'italic', - }, - '.tok-problem-containment': { - fontWeight: 700, - }, - '.tok-problem-error': { - '&, & .tok-typeName': { - color: theme.palette.error.main, - }, - }, - '.tok-problem-builtin': { - '&, & .tok-typeName, & .tok-atom, & .tok-variableName': { - color: theme.palette.primary.main, - fontWeight: 400, - fontStyle: 'normal', - }, - }, - '.cm-tooltip-autocomplete': { - background: theme.palette.background.paper, - ...(theme.palette.mode === 'dark' && { - overflow: 'hidden', - borderRadius: theme.shape.borderRadius, - // https://github.com/mui/material-ui/blob/10c72729c7d03bab8cdce6eb422642684c56dca2/packages/mui-material/src/Paper/Paper.js#L18 - backgroundImage: - 'linear-gradient(rgba(255, 255, 255, 0.09), rgba(255, 255, 255, 0.09))', - }), - boxShadow: theme.shadows[4], - '.cm-completionIcon': { - color: theme.palette.text.secondary, - }, - '.cm-completionLabel': { - color: theme.palette.text.primary, - }, - '.cm-completionDetail': { - color: theme.palette.text.secondary, - fontStyle: 'normal', - }, - '[aria-selected]': { - background: `${theme.palette.primary.main} !important`, - '.cm-completionIcon, .cm-completionLabel, .cm-completionDetail': { - color: theme.palette.primary.contrastText, - }, - }, - }, - '.cm-completionIcon': { - width: 16, - padding: 0, - marginRight: '0.5em', - textAlign: 'center', - }, - ...codeMirrorLintStyle, - '.cm-problem-read': { - background: theme.palette.highlight.occurences.read, - }, - '.cm-problem-write': { - background: theme.palette.highlight.occurences.write, - }, - }; -}); diff --git a/subprojects/frontend/src/editor/EditorStore.ts b/subprojects/frontend/src/editor/EditorStore.ts index f75147a4..4bad68b3 100644 --- a/subprojects/frontend/src/editor/EditorStore.ts +++ b/subprojects/frontend/src/editor/EditorStore.ts @@ -1,58 +1,30 @@ +import { CompletionContext, CompletionResult } from '@codemirror/autocomplete'; +import { redo, redoDepth, undo, undoDepth } from '@codemirror/commands'; import { - closeBrackets, - closeBracketsKeymap, - autocompletion, - completionKeymap, -} from '@codemirror/autocomplete'; + type Diagnostic, + setDiagnostics, + closeLintPanel, + openLintPanel, + nextDiagnostic, +} from '@codemirror/lint'; +import { closeSearchPanel, openSearchPanel } from '@codemirror/search'; import { - defaultKeymap, - history, - historyKeymap, - indentWithTab, - redo, - redoDepth, - undo, - undoDepth, -} from '@codemirror/commands'; -import { - bracketMatching, - foldGutter, - foldKeymap, - indentOnInput, - syntaxHighlighting, -} from '@codemirror/language'; -import { type Diagnostic, lintKeymap, setDiagnostics } from '@codemirror/lint'; -import { search, searchKeymap } from '@codemirror/search'; -import { - EditorState, type StateCommand, StateEffect, type Transaction, type TransactionSpec, + type EditorState, } from '@codemirror/state'; -import { - drawSelection, - EditorView, - highlightActiveLine, - highlightActiveLineGutter, - highlightSpecialChars, - keymap, - lineNumbers, - rectangularSelection, -} from '@codemirror/view'; -import { classHighlighter } from '@lezer/highlight'; -import { makeAutoObservable, observable, reaction } from 'mobx'; - -import problemLanguageSupport from '../language/problemLanguageSupport'; -import type ThemeStore from '../theme/ThemeStore'; +import { type Command, EditorView } from '@codemirror/view'; +import { action, computed, makeObservable, observable } from 'mobx'; + import getLogger from '../utils/getLogger'; import XtextClient from '../xtext/XtextClient'; -import findOccurrences, { - type IOccurrence, - setOccurrences, -} from './findOccurrences'; -import semanticHighlighting, { +import PanelStore from './PanelStore'; +import createEditorState from './createEditorState'; +import { type IOccurrence, setOccurrences } from './findOccurrences'; +import { type IHighlightRange, setSemanticHighlighting, } from './semanticHighlighting'; @@ -60,17 +32,17 @@ import semanticHighlighting, { const log = getLogger('editor.EditorStore'); export default class EditorStore { - private readonly themeStore; - state: EditorState; private readonly client: XtextClient; - showLineNumbers = false; + view: EditorView | undefined; - showSearchPanel = false; + readonly searchPanel: PanelStore; - showLintPanel = false; + readonly lintPanel: PanelStore; + + showLineNumbers = false; errorCount = 0; @@ -78,116 +50,124 @@ export default class EditorStore { infoCount = 0; - private readonly defaultDispatcher = (tr: Transaction): void => { - this.onTransaction(tr); - }; - - private dispatcher = this.defaultDispatcher; - - constructor(initialValue: string, themeStore: ThemeStore) { - this.themeStore = themeStore; - this.state = EditorState.create({ - doc: initialValue, - extensions: [ - autocompletion({ - activateOnTyping: true, - override: [(context) => this.client.contentAssist(context)], - }), - closeBrackets(), - bracketMatching(), - drawSelection(), - EditorState.allowMultipleSelections.of(true), - EditorView.theme( - {}, - { - dark: this.themeStore.darkMode, - }, - ), - findOccurrences, - highlightActiveLine(), - highlightActiveLineGutter(), - highlightSpecialChars(), - history(), - indentOnInput(), - rectangularSelection(), - search({ - top: true, - caseSensitive: true, - }), - syntaxHighlighting(classHighlighter), - semanticHighlighting, - // We add the gutters to `extensions` in the order we want them to appear. - lineNumbers(), - foldGutter(), - keymap.of([ - { key: 'Mod-Shift-f', run: () => this.formatText() }, - ...closeBracketsKeymap, - ...completionKeymap, - ...foldKeymap, - ...historyKeymap, - indentWithTab, - // Override keys in `lintKeymap` to go through the `EditorStore`. - { key: 'Mod-Shift-m', run: () => this.setLintPanelOpen(true) }, - ...lintKeymap, - // Override keys in `searchKeymap` to go through the `EditorStore`. - { - key: 'Mod-f', - run: () => this.setSearchPanelOpen(true), - scope: 'editor search-panel', - }, - { - key: 'Escape', - run: () => this.setSearchPanelOpen(false), - scope: 'editor search-panel', - }, - ...searchKeymap, - ...defaultKeymap, - ]), - problemLanguageSupport(), - ], - }); + constructor(initialValue: string) { + this.state = createEditorState(initialValue, this); this.client = new XtextClient(this); - reaction( - () => this.themeStore.darkMode, - (darkMode) => { - log.debug('Update editor dark mode', darkMode); - this.dispatch({ - effects: [ - StateEffect.appendConfig.of( - EditorView.theme( - {}, - { - dark: darkMode, - }, - ), - ), - ], - }); - }, + this.searchPanel = new PanelStore( + 'search', + openSearchPanel, + closeSearchPanel, + this, + ); + this.lintPanel = new PanelStore( + 'panel-lint', + openLintPanel, + closeLintPanel, + this, ); - makeAutoObservable(this, { + makeObservable(this, { state: observable.ref, + view: observable.ref, + showLineNumbers: observable, + errorCount: observable, + warningCount: observable, + infoCount: observable, + highestDiagnosticLevel: computed, + canUndo: computed, + canRedo: computed, + setDarkMode: action, + setEditorParent: action, + dispatch: action, + dispatchTransaction: action, + doCommand: action, + doStateCommand: action, + updateDiagnostics: action, + nextDiagnostic: action, + updateOccurrences: action, + updateSemanticHighlighting: action, + undo: action, + redo: action, + toggleLineNumbers: action, }); } - updateDispatcher(newDispatcher: ((tr: Transaction) => void) | null): void { - this.dispatcher = newDispatcher || this.defaultDispatcher; + setDarkMode(darkMode: boolean): void { + log.debug('Update editor dark mode', darkMode); + this.dispatch({ + effects: [ + StateEffect.appendConfig.of([EditorView.darkTheme.of(darkMode)]), + ], + }); } - onTransaction(tr: Transaction): void { - log.trace('Editor transaction', tr); - this.state = tr.state; - this.client.onTransaction(tr); + setEditorParent(editorParent: Element | null): void { + if (this.view !== undefined) { + this.view.destroy(); + } + if (editorParent === null) { + this.view = undefined; + return; + } + const view = new EditorView({ + state: this.state, + parent: editorParent, + dispatch: (transaction) => { + this.dispatchTransactionWithoutView(transaction); + view.update([transaction]); + if (view.state !== this.state) { + log.error( + 'Failed to synchronize editor state - store state:', + this.state, + 'view state:', + view.state, + ); + } + }, + }); + this.view = view; + this.searchPanel.synchronizeStateToView(); + this.lintPanel.synchronizeStateToView(); + + // Reported by Lighthouse 8.3.0. + const { contentDOM } = view; + contentDOM.removeAttribute('aria-expanded'); + contentDOM.setAttribute('aria-label', 'Code editor'); + + log.info('Editor created'); } dispatch(...specs: readonly TransactionSpec[]): void { - this.dispatcher(this.state.update(...specs)); + const transaction = this.state.update(...specs); + this.dispatchTransaction(transaction); + } + + dispatchTransaction(transaction: Transaction): void { + if (this.view === undefined) { + this.dispatchTransactionWithoutView(transaction); + } else { + this.view.dispatch(transaction); + } + } + + private readonly dispatchTransactionWithoutView = action( + (tr: Transaction) => { + log.trace('Editor transaction', tr); + this.state = tr.state; + this.client.onTransaction(tr); + }, + ); + + doCommand(command: Command): boolean { + if (this.view === undefined) { + return false; + } + return command(this.view); } doStateCommand(command: StateCommand): boolean { return command({ state: this.state, - dispatch: this.dispatcher, + dispatch: (transaction) => this.dispatchTransaction(transaction), }); } @@ -213,7 +193,11 @@ export default class EditorStore { }); } - get highestDiagnosticLevel(): Diagnostic['severity'] | null { + nextDiagnostic(): void { + this.doCommand(nextDiagnostic); + } + + get highestDiagnosticLevel(): Diagnostic['severity'] | undefined { if (this.errorCount > 0) { return 'error'; } @@ -223,7 +207,7 @@ export default class EditorStore { if (this.infoCount > 0) { return 'info'; } - return null; + return undefined; } updateSemanticHighlighting(ranges: IHighlightRange[]): void { @@ -234,6 +218,10 @@ export default class EditorStore { this.dispatch(setOccurrences(write, read)); } + contentAssist(context: CompletionContext): Promise { + return this.client.contentAssist(context); + } + /** * @returns `true` if there is history to undo */ @@ -241,7 +229,6 @@ export default class EditorStore { return undoDepth(this.state) > 0; } - // eslint-disable-next-line class-methods-use-this undo(): void { log.debug('Undo', this.doStateCommand(undo)); } @@ -253,7 +240,6 @@ export default class EditorStore { return redoDepth(this.state) > 0; } - // eslint-disable-next-line class-methods-use-this redo(): void { log.debug('Redo', this.doStateCommand(redo)); } @@ -263,44 +249,6 @@ export default class EditorStore { log.debug('Show line numbers', this.showLineNumbers); } - /** - * Sets whether the CodeMirror search panel should be open. - * - * This method can be used as a CodeMirror command, - * because it returns `false` if it didn't execute, - * allowing other commands for the same keybind to run instead. - * This matches the behavior of the `openSearchPanel` and `closeSearchPanel` - * commands from `'@codemirror/search'`. - * - * @param newShowSearchPanel whether we should show the search panel - * @returns `true` if the state was changed, `false` otherwise - */ - setSearchPanelOpen(newShowSearchPanel: boolean): boolean { - if (this.showSearchPanel === newShowSearchPanel) { - return false; - } - this.showSearchPanel = newShowSearchPanel; - log.debug('Show search panel', this.showSearchPanel); - return true; - } - - toggleSearchPanel(): void { - this.setSearchPanelOpen(!this.showSearchPanel); - } - - setLintPanelOpen(newShowLintPanel: boolean): boolean { - if (this.showLintPanel === newShowLintPanel) { - return false; - } - this.showLintPanel = newShowLintPanel; - log.debug('Show lint panel', this.showLintPanel); - return true; - } - - toggleLintPanel(): void { - this.setLintPanelOpen(!this.showLintPanel); - } - formatText(): boolean { this.client.formatText(); return true; diff --git a/subprojects/frontend/src/editor/EditorTheme.ts b/subprojects/frontend/src/editor/EditorTheme.ts new file mode 100644 index 00000000..8d98e832 --- /dev/null +++ b/subprojects/frontend/src/editor/EditorTheme.ts @@ -0,0 +1,342 @@ +import errorSVG from '@material-icons/svg/svg/error/baseline.svg?raw'; +import expandMoreSVG from '@material-icons/svg/svg/expand_more/baseline.svg?raw'; +import infoSVG from '@material-icons/svg/svg/info/baseline.svg?raw'; +import warningSVG from '@material-icons/svg/svg/warning/baseline.svg?raw'; +import { alpha, styled } from '@mui/material/styles'; + +import editorClassNames from './editorClassNames'; + +function svgURL(svg: string): string { + return `url('data:image/svg+xml;utf8,${svg}')`; +} + +export default styled('div', { + name: 'EditorTheme', + shouldForwardProp: (propName) => propName !== 'showLineNumbers', +})<{ showLineNumbers: boolean }>(({ theme, showLineNumbers }) => { + let codeMirrorLintStyle: Record = {}; + ( + [ + { + severity: 'error', + icon: errorSVG, + }, + { + severity: 'warning', + icon: warningSVG, + }, + { + severity: 'info', + icon: infoSVG, + }, + ] as const + ).forEach(({ severity, icon }) => { + const palette = theme.palette[severity]; + const color = palette.main; + const iconStyle = { + background: color, + maskImage: svgURL(icon), + maskSize: '16px 16px', + height: 16, + width: 16, + }; + const tooltipColor = + theme.palette.mode === 'dark' ? palette.main : palette.light; + codeMirrorLintStyle = { + ...codeMirrorLintStyle, + [`.cm-lintRange-${severity}`]: { + backgroundImage: 'none', + textDecoration: `underline wavy ${color}`, + textDecorationSkipInk: 'none', + }, + [`.cm-diagnostic-${severity}`]: { + marginLeft: 0, + padding: '4px 8px 4px 32px', + borderLeft: 'none', + position: 'relative', + '::before': { + ...iconStyle, + content: '" "', + position: 'absolute', + top: 6, + left: 8, + }, + }, + [`.cm-tooltip .cm-diagnostic-${severity}::before`]: { + background: tooltipColor, + }, + [`.cm-lint-marker-${severity}`]: { + ...iconStyle, + display: 'block', + margin: '4px 0', + // Remove original CodeMirror icon. + content: '""', + '::before': { + // Remove original CodeMirror icon. + content: '""', + display: 'none', + }, + }, + }; + }); + + return { + background: theme.palette.background.default, + '&, .cm-editor': { + height: '100%', + }, + '.cm-content': { + padding: 0, + }, + '.cm-scroller': { + color: theme.palette.text.secondary, + }, + '.cm-scroller, .cm-tooltip-autocomplete, .cm-completionLabel, .cm-completionDetail': + { + ...theme.typography.body1, + fontFamily: '"JetBrains MonoVariable", "JetBrains Mono", monospace', + fontFeatureSettings: '"liga", "calt"', + letterSpacing: 0, + textRendering: 'optimizeLegibility', + }, + '.cm-gutters': { + background: 'transparent', + color: theme.palette.text.disabled, + border: 'none', + }, + '.cm-specialChar': { + color: theme.palette.secondary.main, + }, + '.cm-activeLine': { + background: theme.palette.highlight.activeLine, + }, + '.cm-gutter-lint': { + width: 16, + '.cm-gutterElement': { + padding: 0, + }, + }, + '.cm-foldGutter': { + opacity: 0, + width: 16, + transition: theme.transitions.create('opacity', { + duration: theme.transitions.duration.short, + }), + '@media (hover: none)': { + opacity: 1, + }, + }, + '.cm-gutters:hover .cm-foldGutter': { + opacity: 1, + }, + [`.${editorClassNames.foldMarker}`]: { + display: 'block', + margin: '4px 0', + padding: 0, + maskImage: svgURL(expandMoreSVG), + maskSize: '16px 16px', + height: 16, + width: 16, + background: theme.palette.text.primary, + border: 'none', + cursor: 'pointer', + }, + [`.${editorClassNames.foldMarkerClosed}`]: { + transform: 'rotate(-90deg)', + }, + '.cm-activeLineGutter': { + background: 'transparent', + }, + '.cm-lineNumbers': { + ...(!showLineNumbers && { + display: 'none !important', + }), + '.cm-activeLineGutter': { + color: theme.palette.text.primary, + }, + }, + '.cm-cursor, .cm-cursor-primary': { + borderLeft: `2px solid ${theme.palette.primary.main}`, + }, + '.cm-selectionBackground': { + background: theme.palette.highlight.selection, + }, + '.cm-focused': { + outline: 'none', + '.cm-selectionBackground': { + background: theme.palette.highlight.selection, + }, + }, + '.cm-panels-top': { + color: theme.palette.text.secondary, + borderBottom: `1px solid ${theme.palette.outer.border}`, + marginBottom: theme.spacing(1), + }, + '.cm-panel': { + position: 'relative', + overflow: 'hidden', + background: theme.palette.outer.background, + borderTop: `1px solid ${theme.palette.outer.border}`, + '&, & button, & input': { + fontFamily: theme.typography.fontFamily, + }, + 'button[name="close"]': { + background: 'transparent', + color: theme.palette.text.secondary, + cursor: 'pointer', + }, + }, + '.cm-panel.cm-panel-lint': { + borderTop: `1px solid ${theme.palette.outer.border}`, + borderBottom: 'none', + 'button[name="close"]': { + // Close button interferes with scrollbar, so we better hide it. + // The panel can still be closed from the toolbar. + display: 'none', + }, + ul: { + maxHeight: 'max(112px, 20vh)', + li: { + cursor: 'pointer', + color: theme.palette.text.primary, + }, + '.cm-diagnostic': { + ...theme.typography.body2, + '&[aria-selected="true"]': { + color: theme.palette.text.primary, + background: 'transparent', + fontWeight: 700, + }, + ':hover': { + background: alpha( + theme.palette.text.primary, + theme.palette.action.hoverOpacity, + ), + }, + }, + }, + }, + [`.${editorClassNames.foldPlaceholder}`]: { + ...theme.typography.body1, + padding: 0, + fontFamily: 'inherit', + fontFeatureSettings: '"liga", "calt"', + color: theme.palette.text.secondary, + backgroundColor: alpha( + theme.palette.text.secondary, + theme.palette.action.focusOpacity, + ), + border: 'none', + cursor: 'pointer', + transition: theme.transitions.create(['background-color', 'color'], { + duration: theme.transitions.duration.short, + }), + '&:hover': { + color: theme.palette.text.primary, + backgroundColor: alpha( + theme.palette.text.secondary, + theme.palette.action.focusOpacity + theme.palette.action.hoverOpacity, + ), + }, + }, + '.tok-comment': { + fontStyle: 'italic', + color: theme.palette.highlight.comment, + }, + '.tok-number': { + color: theme.palette.highlight.number, + }, + '.tok-string': { + color: theme.palette.secondary, + }, + '.tok-keyword': { + color: theme.palette.primary.main, + }, + '.tok-typeName, .tok-atom': { + color: theme.palette.text.primary, + }, + '.tok-variableName': { + color: theme.palette.highlight.parameter, + }, + '.tok-problem-node': { + '&, & .tok-variableName': { + color: theme.palette.text.secondary, + }, + }, + '.tok-problem-individual': { + '&, & .tok-variableName': { + color: theme.palette.text.primary, + }, + }, + '.tok-problem-abstract, .tok-problem-new': { + fontStyle: 'italic', + }, + '.tok-problem-containment': { + fontWeight: 700, + }, + '.tok-problem-error': { + '&, & .tok-typeName': { + color: theme.palette.error.main, + }, + }, + '.tok-problem-builtin': { + '&, & .tok-typeName, & .tok-atom, & .tok-variableName': { + color: theme.palette.primary.main, + fontWeight: 400, + fontStyle: 'normal', + }, + }, + '.cm-tooltip.cm-tooltip-autocomplete': { + background: theme.palette.background.paper, + borderRadius: theme.shape.borderRadius, + overflow: 'hidden', + ...(theme.palette.mode === 'dark' && { + // https://github.com/mui/material-ui/blob/10c72729c7d03bab8cdce6eb422642684c56dca2/packages/mui-material/src/Paper/Paper.js#L18 + backgroundImage: + 'linear-gradient(rgba(255, 255, 255, 0.07), rgba(255, 255, 255, 0.07))', + }), + boxShadow: theme.shadows[2], + '.cm-completionIcon': { + color: theme.palette.text.secondary, + }, + '.cm-completionLabel': { + color: theme.palette.text.primary, + }, + '.cm-completionDetail': { + color: theme.palette.text.secondary, + fontStyle: 'normal', + }, + 'li[aria-selected="true"]': { + background: alpha( + theme.palette.text.primary, + theme.palette.action.focusOpacity, + ), + '.cm-completionIcon, .cm-completionLabel, .cm-completionDetail': { + color: theme.palette.text.primary, + }, + }, + }, + '.cm-tooltip.cm-tooltip-hover, .cm-tooltip.cm-tooltip-lint': { + ...theme.typography.body2, + // https://github.com/mui/material-ui/blob/dee9529f7a298c54ae760761112c3ae9ba082137/packages/mui-material/src/Tooltip/Tooltip.js#L121-L125 + background: alpha(theme.palette.grey[700], 0.92), + borderRadius: theme.shape.borderRadius, + color: theme.palette.common.white, + overflow: 'hidden', + maxWidth: 400, + }, + '.cm-completionIcon': { + width: 16, + padding: 0, + marginRight: '0.5em', + textAlign: 'center', + }, + ...codeMirrorLintStyle, + '.cm-problem-read': { + background: theme.palette.highlight.occurences.read, + }, + '.cm-problem-write': { + background: theme.palette.highlight.occurences.write, + }, + }; +}); diff --git a/subprojects/frontend/src/editor/GenerateButton.tsx b/subprojects/frontend/src/editor/GenerateButton.tsx index 4d5c4e44..0eed129e 100644 --- a/subprojects/frontend/src/editor/GenerateButton.tsx +++ b/subprojects/frontend/src/editor/GenerateButton.tsx @@ -22,7 +22,7 @@ function GenerateButton(): JSX.Element { if (errorCount > 0) { return ( - ); diff --git a/subprojects/frontend/src/editor/PanelStore.ts b/subprojects/frontend/src/editor/PanelStore.ts new file mode 100644 index 00000000..653d309c --- /dev/null +++ b/subprojects/frontend/src/editor/PanelStore.ts @@ -0,0 +1,90 @@ +import type { Command } from '@codemirror/view'; +import { action, makeObservable, observable } from 'mobx'; + +import getLogger from '../utils/getLogger'; + +import type EditorStore from './EditorStore'; + +const log = getLogger('editor.PanelStore'); + +export default class PanelStore { + state = false; + + constructor( + private readonly panelId: string, + private readonly openCommand: Command, + private readonly closeCommand: Command, + private readonly store: EditorStore, + ) { + makeObservable(this, { + state: observable, + open: action, + close: action, + toggle: action, + synchronizeStateToView: action, + }); + } + + open(): boolean { + return this.setState(true); + } + + close(): boolean { + return this.setState(false); + } + + toggle(): void { + this.setState(!this.state); + } + + private setState(newState: boolean): boolean { + if (this.state === newState) { + return false; + } + log.debug('Show', this.panelId, 'panel', newState); + if (newState) { + this.doOpen(); + } else { + this.doClose(); + } + this.state = newState; + return true; + } + + synchronizeStateToView(): void { + this.doClose(); + if (this.state) { + this.doOpen(); + } + } + + private doOpen(): void { + if (!this.store.doCommand(this.openCommand)) { + return; + } + const { view } = this.store; + if (view === undefined) { + return; + } + const buttonQuery = `.cm-${this.panelId}.cm-panel button[name="close"]`; + const closeButton = view.dom.querySelector(buttonQuery); + if (closeButton !== null) { + log.debug('Addig close button callback to', this.panelId, 'panel'); + // We must remove the event listener from the button that dispatches a transaction + // without going through `EditorStore`. This listened is added by CodeMirror, + // and we can only remove it by cloning the DOM node: https://stackoverflow.com/a/9251864 + const closeButtonWithoutListeners = closeButton.cloneNode(true); + closeButtonWithoutListeners.addEventListener('click', (event) => { + this.close(); + event.preventDefault(); + }); + closeButton.replaceWith(closeButtonWithoutListeners); + } else { + log.error('Opened', this.panelId, 'panel has no close button'); + } + } + + private doClose(): void { + this.store.doCommand(this.closeCommand); + } +} diff --git a/subprojects/frontend/src/editor/createEditorState.ts b/subprojects/frontend/src/editor/createEditorState.ts new file mode 100644 index 00000000..33346c05 --- /dev/null +++ b/subprojects/frontend/src/editor/createEditorState.ts @@ -0,0 +1,121 @@ +import { + closeBrackets, + closeBracketsKeymap, + autocompletion, + completionKeymap, +} from '@codemirror/autocomplete'; +import { + defaultKeymap, + history, + historyKeymap, + indentWithTab, +} from '@codemirror/commands'; +import { + bracketMatching, + codeFolding, + foldGutter, + foldKeymap, + indentOnInput, + syntaxHighlighting, +} from '@codemirror/language'; +import { lintKeymap, lintGutter } from '@codemirror/lint'; +import { search, searchKeymap } from '@codemirror/search'; +import { EditorState } from '@codemirror/state'; +import { + drawSelection, + highlightActiveLine, + highlightActiveLineGutter, + highlightSpecialChars, + keymap, + lineNumbers, + rectangularSelection, +} from '@codemirror/view'; +import { classHighlighter } from '@lezer/highlight'; + +import problemLanguageSupport from '../language/problemLanguageSupport'; + +import type EditorStore from './EditorStore'; +import editorClassNames from './editorClassNames'; +import findOccurrences from './findOccurrences'; +import semanticHighlighting from './semanticHighlighting'; + +export default function createEditorState( + initialValue: string, + store: EditorStore, +): EditorState { + return EditorState.create({ + doc: initialValue, + extensions: [ + autocompletion({ + activateOnTyping: true, + override: [(context) => store.contentAssist(context)], + }), + closeBrackets(), + bracketMatching(), + drawSelection(), + EditorState.allowMultipleSelections.of(true), + findOccurrences, + highlightActiveLine(), + highlightActiveLineGutter(), + highlightSpecialChars(), + history(), + indentOnInput(), + rectangularSelection(), + search({ top: true }), + syntaxHighlighting(classHighlighter), + semanticHighlighting, + // We add the gutters to `extensions` in the order we want them to appear. + lintGutter(), + lineNumbers(), + codeFolding({ + placeholderDOM(_view, onClick) { + const button = document.createElement('button'); + button.className = editorClassNames.foldPlaceholder; + button.ariaLabel = 'Unfold lines'; + button.innerText = '...'; + button.onclick = onClick; + return button; + }, + }), + foldGutter({ + markerDOM(open) { + const button = document.createElement('button'); + button.className = [ + editorClassNames.foldMarker, + open + ? editorClassNames.foldMarkerOpen + : editorClassNames.foldMarkerClosed, + ].join(' '); + button.ariaPressed = open ? 'true' : 'false'; + button.ariaLabel = 'Fold lines'; + return button; + }, + }), + keymap.of([ + { key: 'Mod-Shift-f', run: () => store.formatText() }, + ...closeBracketsKeymap, + ...completionKeymap, + ...foldKeymap, + ...historyKeymap, + indentWithTab, + // Override keys in `lintKeymap` to go through the `EditorStore`. + { key: 'Mod-Shift-m', run: () => store.lintPanel.open() }, + ...lintKeymap, + // Override keys in `searchKeymap` to go through the `EditorStore`. + { + key: 'Mod-f', + run: () => store.searchPanel.open(), + scope: 'editor search-panel', + }, + { + key: 'Escape', + run: () => store.searchPanel.close(), + scope: 'editor search-panel', + }, + ...searchKeymap, + ...defaultKeymap, + ]), + problemLanguageSupport(), + ], + }); +} diff --git a/subprojects/frontend/src/editor/editorClassNames.ts b/subprojects/frontend/src/editor/editorClassNames.ts new file mode 100644 index 00000000..5584e8c2 --- /dev/null +++ b/subprojects/frontend/src/editor/editorClassNames.ts @@ -0,0 +1,10 @@ +const PREFIX = 'problem-editor-'; + +const editorClassNames = { + foldPlaceholder: `${PREFIX}fold-placeholder`, + foldMarker: `${PREFIX}fold-marker`, + foldMarkerClosed: `${PREFIX}fold-marker-closed`, + foldMarkerOpen: `${PREFIX}fold-marker-open`, +}; + +export default editorClassNames; diff --git a/subprojects/frontend/src/language/problemLanguageSupport.ts b/subprojects/frontend/src/language/problemLanguageSupport.ts index 246135d8..07a884e7 100644 --- a/subprojects/frontend/src/language/problemLanguageSupport.ts +++ b/subprojects/frontend/src/language/problemLanguageSupport.ts @@ -28,18 +28,18 @@ const parserWithMetadata = parser.configure({ BlockComment: t.blockComment, 'problem class enum pred rule indiv scope': t.definitionKeyword, 'abstract extends refers contains opposite error default': t.modifier, - 'true false unknown error': t.keyword, + 'true false unknown error': t.operatorKeyword, 'may must current count': t.operatorKeyword, - 'new delete': t.operatorKeyword, + 'new delete': t.keyword, NotOp: t.operator, UnknownOp: t.operator, - OrOp: t.punctuation, + OrOp: t.separator, StarArgument: t.keyword, 'IntMult StarMult Real': t.number, StarMult: t.number, String: t.string, 'RelationName/QualifiedName': t.typeName, - 'RuleName/QualifiedName': t.macroName, + 'RuleName/QualifiedName': t.typeName, 'IndividualNodeName/QualifiedName': t.atom, 'VariableName/QualifiedName': t.variableName, '{ }': t.brace, diff --git a/subprojects/frontend/src/theme/EditorTheme.ts b/subprojects/frontend/src/theme/EditorTheme.ts deleted file mode 100644 index a16b4c3b..00000000 --- a/subprojects/frontend/src/theme/EditorTheme.ts +++ /dev/null @@ -1,7 +0,0 @@ -enum EditorTheme { - Light, - Dark, - Default = EditorTheme.Dark, -} - -export default EditorTheme; diff --git a/subprojects/frontend/src/theme/ThemeProvider.tsx b/subprojects/frontend/src/theme/ThemeProvider.tsx index 9a8fdd44..dd4f5bb8 100644 --- a/subprojects/frontend/src/theme/ThemeProvider.tsx +++ b/subprojects/frontend/src/theme/ThemeProvider.tsx @@ -11,13 +11,17 @@ import React, { type ReactNode } from 'react'; import { useRootStore } from '../RootStore'; -import EditorTheme from './EditorTheme'; +interface OuterPalette { + background: string; + border: string; +} -interface HighlightStyles { +interface HighlightPalette { number: string; parameter: string; comment: string; activeLine: string; + selection: string; occurences: { read: string; write: string; @@ -26,19 +30,17 @@ interface HighlightStyles { declare module '@mui/material/styles' { interface Palette { - divider2: string; - selection: Palette['primary']; - highlight: HighlightStyles; + outer: OuterPalette; + highlight: HighlightPalette; } interface PaletteOptions { - divider2: string; - selection: PaletteOptions['primary']; - highlight: HighlightStyles; + outer: OuterPalette; + highlight: HighlightPalette; } } -function getMUIThemeOptions(currentTheme: EditorTheme): ThemeOptions { +function getMUIThemeOptions(darkMode: boolean): ThemeOptions { const components: Components = { MuiButton: { styleOverrides: { @@ -67,32 +69,8 @@ function getMUIThemeOptions(currentTheme: EditorTheme): ThemeOptions { }, }; - switch (currentTheme) { - case EditorTheme.Light: - return { - components, - palette: { - mode: 'light', - primary: { main: '#0097a7' }, - selection: { - main: '#c8e4fb', - contrastText: '#000', - }, - divider2: '#d7d7d7', - highlight: { - number: '#1976d2', - parameter: '#6a3e3e', - comment: alpha('#000', 0.38), - activeLine: '#f5f5f5', - occurences: { - read: '#ceccf7', - write: '#f0d8a8', - }, - }, - }, - }; - case EditorTheme.Dark: - return { + return darkMode + ? { components, palette: { mode: 'dark', @@ -111,34 +89,53 @@ function getMUIThemeOptions(currentTheme: EditorTheme): ThemeOptions { disabled: '#4b5263', }, divider: alpha('#abb2bf', 0.16), - divider2: '#181a1f', - selection: { - main: '#3e4453', - contrastText: '#fff', + outer: { + background: '#21252b', + border: '#181a1f', }, highlight: { number: '#6188a6', parameter: '#c8ae9d', comment: '#6b717d', activeLine: '#21252b', + selection: '#3e4453', occurences: { read: 'rgba(255, 255, 255, 0.15)', write: 'rgba(255, 255, 128, 0.4)', }, }, }, + } + : { + components, + palette: { + mode: 'light', + primary: { main: '#0097a7' }, + outer: { + background: '#f5f5f5', + border: '#d7d7d7', + }, + highlight: { + number: '#1976d2', + parameter: '#6a3e3e', + comment: alpha('#000', 0.38), + activeLine: '#f5f5f5', + selection: '#c8e4fb', + occurences: { + read: '#ceccf7', + write: '#f0d8a8', + }, + }, + }, }; - default: - throw new Error(`Unknown theme: ${currentTheme}`); - } } function ThemeProvider({ children }: { children?: ReactNode }) { const { - themeStore: { currentTheme }, + themeStore: { darkMode }, } = useRootStore(); - const themeOptions = getMUIThemeOptions(currentTheme); + const themeOptions = getMUIThemeOptions(darkMode); const theme = responsiveFontSizes(createTheme(themeOptions)); return ( diff --git a/subprojects/frontend/src/theme/ThemeStore.ts b/subprojects/frontend/src/theme/ThemeStore.ts index ded1f29a..11391b06 100644 --- a/subprojects/frontend/src/theme/ThemeStore.ts +++ b/subprojects/frontend/src/theme/ThemeStore.ts @@ -1,28 +1,16 @@ -import { makeAutoObservable } from 'mobx'; - -import EditorTheme from './EditorTheme'; +import { action, makeObservable, observable } from 'mobx'; export default class ThemeStore { - currentTheme: EditorTheme = EditorTheme.Default; + darkMode = true; constructor() { - makeAutoObservable(this); + makeObservable(this, { + darkMode: observable, + toggleDarkMode: action, + }); } toggleDarkMode(): void { - switch (this.currentTheme) { - case EditorTheme.Light: - this.currentTheme = EditorTheme.Dark; - break; - case EditorTheme.Dark: - this.currentTheme = EditorTheme.Light; - break; - default: - throw new Error(`Unknown theme: ${this.currentTheme}`); - } - } - - get darkMode(): boolean { - return this.currentTheme === EditorTheme.Dark; + this.darkMode = !this.darkMode; } } diff --git a/subprojects/frontend/tsconfig.base.json b/subprojects/frontend/tsconfig.base.json index e33e330e..9cc8ace4 100644 --- a/subprojects/frontend/tsconfig.base.json +++ b/subprojects/frontend/tsconfig.base.json @@ -5,6 +5,7 @@ "moduleResolution": "Node", "esModuleInterop": true, "allowSyntheticDefaultImports": true, + "useDefineForClassFields": true, "strict": true, "noImplicitOverride": true, "noImplicitReturns": true, -- cgit v1.2.3-54-g00ecf