From bb900e1bd40a6b7efd7a538114d985ea7f7e3e88 Mon Sep 17 00:00:00 2001 From: Kristóf Marussy Date: Wed, 17 Aug 2022 21:43:29 +0200 Subject: feat(frontend): custom search panel Also improves editor styling (to enable panel styling). --- subprojects/frontend/.eslintrc.cjs | 2 + subprojects/frontend/src/TopBar.tsx | 7 +- subprojects/frontend/src/editor/EditorArea.tsx | 14 +- subprojects/frontend/src/editor/EditorButtons.tsx | 7 + subprojects/frontend/src/editor/EditorStore.ts | 44 ++- subprojects/frontend/src/editor/EditorTheme.ts | 433 +++++++++++---------- subprojects/frontend/src/editor/GenerateButton.tsx | 6 +- subprojects/frontend/src/editor/LintPanelStore.ts | 10 + subprojects/frontend/src/editor/PanelStore.ts | 41 +- subprojects/frontend/src/editor/SearchPanel.ts | 32 ++ .../frontend/src/editor/SearchPanelStore.ts | 108 +++++ subprojects/frontend/src/editor/SearchToolbar.tsx | 198 ++++++++++ .../frontend/src/editor/createEditorState.ts | 13 +- subprojects/frontend/src/theme/ThemeProvider.tsx | 104 ++++- 14 files changed, 776 insertions(+), 243 deletions(-) create mode 100644 subprojects/frontend/src/editor/LintPanelStore.ts create mode 100644 subprojects/frontend/src/editor/SearchPanel.ts create mode 100644 subprojects/frontend/src/editor/SearchPanelStore.ts create mode 100644 subprojects/frontend/src/editor/SearchToolbar.tsx (limited to 'subprojects') diff --git a/subprojects/frontend/.eslintrc.cjs b/subprojects/frontend/.eslintrc.cjs index e6be4d65..68636f4e 100644 --- a/subprojects/frontend/.eslintrc.cjs +++ b/subprojects/frontend/.eslintrc.cjs @@ -53,6 +53,8 @@ module.exports = { 'newlines-between': 'always', }, ], + // Use prop spreading to conditionally add props with `exactOptionalPropertyTypes`. + 'react/jsx-props-no-spreading': 'off', }, overrides: [ { 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 { })} > - + theme.palette.text.primary} + > Refinery 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 @@ +import Portal from '@mui/material/Portal'; import { useTheme } from '@mui/material/styles'; import { observer } from 'mobx-react-lite'; import React, { useCallback, useEffect } from 'react'; @@ -5,9 +6,12 @@ import React, { useCallback, useEffect } from 'react'; import { useRootStore } from '../RootStore'; import EditorTheme from './EditorTheme'; +import SearchToolbar from './SearchToolbar'; function EditorArea(): JSX.Element { const { editorStore } = useRootStore(); + const { searchPanel: searchPanelStore } = editorStore; + const { element: searchPanelContainer } = searchPanelStore; const { palette: { mode: paletteMode }, } = useTheme(); @@ -19,7 +23,7 @@ function EditorArea(): JSX.Element { const editorParentRef = useCallback( (editorParent: HTMLDivElement | null) => { - editorStore.setEditorParent(editorParent); + editorStore.setEditorParent(editorParent ?? undefined); }, [editorStore], ); @@ -28,7 +32,13 @@ function EditorArea(): JSX.Element { + > + {searchPanelContainer !== undefined && ( + + + + )} + ); } 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 { selected={editorStore.showLineNumbers} onClick={() => editorStore.toggleLineNumbers()} aria-label="Show line numbers" + aria-controls={editorStore.lineNumbersId} value="show-line-numbers" > @@ -64,6 +65,9 @@ function EditorButtons(): JSX.Element { selected={editorStore.searchPanel.state} onClick={() => editorStore.searchPanel.toggle()} aria-label="Show find/replace" + {...(editorStore.searchPanel.state && { + 'aria-controls': editorStore.searchPanel.id, + })} value="show-search-panel" > @@ -72,6 +76,9 @@ function EditorButtons(): JSX.Element { selected={editorStore.lintPanel.state} onClick={() => editorStore.lintPanel.toggle()} aria-label="Show diagnostics panel" + {...(editorStore.lintPanel.state && { + 'aria-controls': editorStore.lintPanel.id, + })} value="show-lint-panel" > {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'; import { type Diagnostic, setDiagnostics, - closeLintPanel, - openLintPanel, nextDiagnostic, } from '@codemirror/lint'; -import { closeSearchPanel, openSearchPanel } from '@codemirror/search'; import { type StateCommand, StateEffect, @@ -17,11 +14,13 @@ import { } from '@codemirror/state'; import { type Command, EditorView } from '@codemirror/view'; import { action, computed, makeObservable, observable } from 'mobx'; +import { nanoid } from 'nanoid'; import getLogger from '../utils/getLogger'; import XtextClient from '../xtext/XtextClient'; -import PanelStore from './PanelStore'; +import LintPanelStore from './LintPanelStore'; +import SearchPanelStore from './SearchPanelStore'; import createEditorState from './createEditorState'; import { type IOccurrence, setOccurrences } from './findOccurrences'; import { @@ -32,15 +31,17 @@ import { const log = getLogger('editor.EditorStore'); export default class EditorStore { + readonly id: string; + state: EditorState; private readonly client: XtextClient; view: EditorView | undefined; - readonly searchPanel: PanelStore; + readonly searchPanel: SearchPanelStore; - readonly lintPanel: PanelStore; + readonly lintPanel: LintPanelStore; showLineNumbers = false; @@ -51,20 +52,11 @@ export default class EditorStore { infoCount = 0; constructor(initialValue: string) { + this.id = nanoid(); this.state = createEditorState(initialValue, this); this.client = new XtextClient(this); - this.searchPanel = new PanelStore( - 'search', - openSearchPanel, - closeSearchPanel, - this, - ); - this.lintPanel = new PanelStore( - 'panel-lint', - openLintPanel, - closeLintPanel, - this, - ); + this.searchPanel = new SearchPanelStore(this); + this.lintPanel = new LintPanelStore(this); makeObservable(this, { state: observable.ref, view: observable.ref, @@ -100,11 +92,11 @@ export default class EditorStore { }); } - setEditorParent(editorParent: Element | null): void { + setEditorParent(editorParent: Element | undefined): void { if (this.view !== undefined) { this.view.destroy(); } - if (editorParent === null) { + if (editorParent === undefined) { this.view = undefined; return; } @@ -129,9 +121,15 @@ export default class EditorStore { this.lintPanel.synchronizeStateToView(); // Reported by Lighthouse 8.3.0. - const { contentDOM } = view; + const { contentDOM, dom: containerDOM } = view; contentDOM.removeAttribute('aria-expanded'); contentDOM.setAttribute('aria-label', 'Code editor'); + const lineNumbersGutter = containerDOM.querySelector('.cm-lineNumbers'); + if (lineNumbersGutter === null) { + log.error('No line numbers in editor'); + } else { + lineNumbersGutter.id = this.lineNumbersId; + } log.info('Editor created'); } @@ -244,6 +242,10 @@ export default class EditorStore { log.debug('Redo', this.doStateCommand(redo)); } + get lineNumbersId(): string { + return `${this.id}-lineNumbers`; + } + toggleLineNumbers(): void { this.showLineNumbers = !this.showLineNumbers; 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'; 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 { alpha, styled, type CSSObject } from '@mui/material/styles'; import editorClassNames from './editorClassNames'; @@ -14,36 +14,178 @@ export default styled('div', { name: 'EditorTheme', shouldForwardProp: (propName) => propName !== 'showLineNumbers', })<{ showLineNumbers: boolean }>(({ theme, showLineNumbers }) => { - let codeMirrorLintStyle: Record = {}; - ( - [ - { - severity: 'error', - icon: errorSVG, + const generalStyle: CSSObject = { + background: theme.palette.background.default, + '&, .cm-editor': { + height: '100%', + }, + '.cm-scroller': { + ...theme.typography.editor, + color: theme.palette.text.secondary, + }, + '.cm-gutters': { + background: 'transparent', + border: 'none', + }, + '.cm-content': { + padding: 0, + }, + '.cm-activeLine': { + background: theme.palette.highlight.activeLine, + }, + '.cm-activeLineGutter': { + background: 'transparent', + }, + '.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, + }, + }, + }; + + const highlightingStyle: CSSObject = { + '.cm-specialChar': { + color: theme.palette.secondary.main, + }, + '.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, }, - { - severity: 'warning', - icon: warningSVG, + }, + '.tok-problem-abstract, .tok-problem-new': { + fontStyle: 'italic', + }, + '.tok-problem-containment': { + fontWeight: 700, + }, + '.tok-problem-error': { + '&, & .tok-typeName': { + color: theme.palette.error.main, }, - { - severity: 'info', - icon: infoSVG, + }, + '.tok-problem-builtin': { + '&, & .tok-typeName, & .tok-atom, & .tok-variableName': { + color: theme.palette.primary.main, + fontWeight: 400, + fontStyle: 'normal', }, - ] as const - ).forEach(({ severity, icon }) => { + }, + }; + + const matchingStyle: CSSObject = { + '.cm-problem-read': { + background: theme.palette.highlight.occurences.read, + }, + '.cm-problem-write': { + background: theme.palette.highlight.occurences.write, + }, + '.cm-matchingBracket, .cm-nonmatchingBracket': { + background: 'transparent', + }, + '.cm-focused .cm-matchingBracket': { + background: 'transparent', + outline: `1px solid ${alpha(theme.palette.text.primary, 0.5)}`, + outlineOffset: -1, + }, + '.cm-focused .cm-nonmatchingBracket': { + background: theme.palette.error.main, + '&, span': { + color: theme.palette.error.contrastText, + }, + }, + '.cm-searchMatch': { + opacity: 1, + background: theme.palette.highlight.search.match, + '&, span': { + color: theme.palette.highlight.search.contrastText, + }, + }, + '.cm-searchMatch-selected': { + background: theme.palette.highlight.search.selected, + }, + }; + + const lineNumberStyle: CSSObject = { + '.cm-lineNumbers': { + color: theme.palette.highlight.lineNumber, + ...(!showLineNumbers && { + display: 'none !important', + }), + '.cm-gutterElement': { + padding: '0 2px 0 6px', + }, + '.cm-activeLineGutter': { + color: theme.palette.text.primary, + }, + }, + }; + + const panelStyle: CSSObject = { + '.cm-panels-top': { + color: theme.palette.text.primary, + borderBottom: `1px solid ${theme.palette.outer.border}`, + marginBottom: theme.spacing(1), + }, + '.cm-panel, .cm-panel.cm-search': { + color: theme.palette.text.primary, + background: theme.palette.outer.background, + borderTop: `1px solid ${theme.palette.outer.border}`, + margin: 0, + padding: 0, + 'button[name="close"]': { + background: 'transparent', + color: theme.palette.text.secondary, + cursor: 'pointer', + }, + }, + }; + + function lintSeverityStyle( + severity: 'error' | 'warning' | 'info', + icon: string, + ): CSSObject { const palette = theme.palette[severity]; const color = palette.main; - const iconStyle = { + const tooltipColor = theme.palette.mode === 'dark' ? color : palette.light; + const iconStyle: CSSObject = { background: color, maskImage: svgURL(icon), maskSize: '16px 16px', height: 16, width: 16, }; - const tooltipColor = - theme.palette.mode === 'dark' ? palette.main : palette.light; - codeMirrorLintStyle = { - ...codeMirrorLintStyle, + return { [`.cm-lintRange-${severity}`]: { backgroundImage: 'none', textDecoration: `underline wavy ${color}`, @@ -78,113 +220,23 @@ export default styled('div', { }, }, }; - }); + } - 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, - }, + const lintStyle: CSSObject = { '.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', + '.cm-tooltip.cm-tooltip-hover, .cm-tooltip.cm-tooltip-lint': { + ...((theme.components?.MuiTooltip?.styleOverrides?.tooltip as + | CSSObject + | undefined) || {}), + ...theme.typography.body2, + borderRadius: theme.shape.borderRadius, 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', - }, + maxWidth: 400, }, '.cm-panel.cm-panel-lint': { borderTop: `1px solid ${theme.palette.outer.border}`, @@ -195,7 +247,7 @@ export default styled('div', { display: 'none', }, ul: { - maxHeight: 'max(112px, 20vh)', + maxHeight: `max(${28 * 4}px, 20vh)`, li: { cursor: 'pointer', color: theme.palette.text.primary, @@ -216,77 +268,71 @@ export default styled('div', { }, }, }, - [`.${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'], { + '.cm-lintRange-active': { + background: theme.palette.highlight.activeLintRange, + }, + ...lintSeverityStyle('error', errorSVG), + ...lintSeverityStyle('warning', warningSVG), + ...lintSeverityStyle('info', infoSVG), + }; + + const foldStyle = { + '.cm-foldGutter': { + opacity: 0, + width: 16, + transition: theme.transitions.create('opacity', { 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, - ), + '@media (hover: none)': { + opacity: 1, }, }, - '.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, + '.cm-gutters:hover .cm-foldGutter': { + opacity: 1, }, - '.tok-typeName, .tok-atom': { - color: theme.palette.text.primary, + [`.${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', }, - '.tok-variableName': { - color: theme.palette.highlight.parameter, + [`.${editorClassNames.foldMarkerClosed}`]: { + transform: 'rotate(-90deg)', }, - '.tok-problem-node': { - '&, & .tok-variableName': { + [`.${editorClassNames.foldPlaceholder}`]: { + ...theme.typography.editor, + padding: 0, + fontFamily: 'inherit', + background: 'transparent', + border: 'none', + cursor: 'pointer', + // Use an inner `span` element to match the height of other text highlights. + span: { color: theme.palette.text.secondary, + backgroundColor: 'transparent', + backgroundImage: `linear-gradient(${theme.palette.highlight.foldPlaceholder}, ${theme.palette.highlight.foldPlaceholder})`, + transition: theme.transitions.create('background-color', { + duration: theme.transitions.duration.short, + }), }, - }, - '.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', + '&:hover span': { + backgroundColor: alpha( + theme.palette.highlight.foldPlaceholder, + theme.palette.action.hoverOpacity, + ), }, }, + }; + + const completionStyle: CSSObject = { '.cm-tooltip.cm-tooltip-autocomplete': { + ...theme.typography.editor, background: theme.palette.background.paper, borderRadius: theme.shape.borderRadius, overflow: 'hidden', @@ -300,9 +346,11 @@ export default styled('div', { color: theme.palette.text.secondary, }, '.cm-completionLabel': { + ...theme.typography.editor, color: theme.palette.text.primary, }, '.cm-completionDetail': { + ...theme.typography.editor, color: theme.palette.text.secondary, fontStyle: 'normal', }, @@ -316,27 +364,22 @@ export default styled('div', { }, }, }, - '.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, - }, + }; + + return { + ...generalStyle, + ...highlightingStyle, + ...matchingStyle, + ...lineNumberStyle, + ...panelStyle, + ...lintStyle, + ...foldStyle, + ...completionStyle, }; }); 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 { if (errorCount > 0) { return ( - ); 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 @@ +import { closeLintPanel, openLintPanel } from '@codemirror/lint'; + +import type EditorStore from './EditorStore'; +import PanelStore from './PanelStore'; + +export default class LintPanelStore extends PanelStore { + constructor(store: EditorStore) { + super('cm-panel-lint', openLintPanel, closeLintPanel, store); + } +} 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'); export default class PanelStore { state = false; + element: Element | undefined; + constructor( - private readonly panelId: string, + readonly panelClass: string, private readonly openCommand: Command, private readonly closeCommand: Command, - private readonly store: EditorStore, + protected readonly store: EditorStore, ) { makeObservable(this, { state: observable, + element: observable, open: action, close: action, toggle: action, @@ -25,6 +28,10 @@ export default class PanelStore { }); } + get id(): string { + return `${this.store.id}-${this.panelClass}`; + } + open(): boolean { return this.setState(true); } @@ -41,7 +48,7 @@ export default class PanelStore { if (this.state === newState) { return false; } - log.debug('Show', this.panelId, 'panel', newState); + log.debug('Show', this.panelClass, 'panel', newState); if (newState) { this.doOpen(); } else { @@ -58,7 +65,7 @@ export default class PanelStore { } } - private doOpen(): void { + protected doOpen(): void { if (!this.store.doCommand(this.openCommand)) { return; } @@ -66,10 +73,19 @@ export default class PanelStore { if (view === undefined) { return; } - const buttonQuery = `.cm-${this.panelId}.cm-panel button[name="close"]`; - const closeButton = view.dom.querySelector(buttonQuery); + // We always access the panel DOM element by class name, even for the search panel, + // where we control the creation of the element, so that we can have a uniform way to + // access panel created by both CodeMirror and us. + this.element = + view.dom.querySelector(`.${this.panelClass}.cm-panel`) ?? undefined; + if (this.element === undefined) { + log.error('Failed to add panel', this.panelClass, 'to DOM'); + return; + } + this.element.id = this.id; + const closeButton = this.element.querySelector('button[name="close"]'); if (closeButton !== null) { - log.debug('Addig close button callback to', this.panelId, 'panel'); + log.debug('Addig close button callback to', this.panelClass, '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 @@ -79,12 +95,17 @@ export default class PanelStore { event.preventDefault(); }); closeButton.replaceWith(closeButtonWithoutListeners); - } else { - log.error('Opened', this.panelId, 'panel has no close button'); } } - private doClose(): void { + protected doClose(): void { this.store.doCommand(this.closeCommand); + if (this.element === undefined) { + return; + } + if (this.store.view !== undefined) { + log.error('Failed to remove search panel from DOM'); + } + this.element = undefined; } } 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 @@ +import { + type EditorView, + type Panel, + runScopeHandlers, +} from '@codemirror/view'; + +import type SearchPanelStore from './SearchPanelStore'; + +export default class SearchPanel implements Panel { + readonly dom: HTMLDivElement; + + constructor(view: EditorView, store: SearchPanelStore) { + this.dom = document.createElement('div'); + this.dom.id = store.id; + this.dom.className = store.panelClass; + this.dom.addEventListener( + 'keydown', + (event) => { + if (runScopeHandlers(view, event, 'search-panel')) { + event.preventDefault(); + } + }, + { + capture: true, + }, + ); + } + + get top(): boolean { + return true; + } +} 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 @@ +import { + closeSearchPanel, + findNext, + findPrevious, + getSearchQuery, + openSearchPanel, + replaceAll, + replaceNext, + SearchQuery, + selectMatches, + setSearchQuery, +} from '@codemirror/search'; +import { action, computed, makeObservable, observable, override } from 'mobx'; + +import type EditorStore from './EditorStore'; +import PanelStore from './PanelStore'; + +export default class SearchPanelStore extends PanelStore { + searchField: HTMLInputElement | undefined; + + constructor(store: EditorStore) { + // Use a custom class name to avoid specificity issues with + // CodeMirror `.cm-search.cm-panel` CSS styles. + super('refinery-cm-search', openSearchPanel, closeSearchPanel, store); + makeObservable(this, { + searchField: observable.ref, + query: computed, + invalidRegexp: computed, + open: override, + setSearchField: action, + updateQuery: action, + findNext: action, + findPrevious: action, + selectMatches: action, + }); + } + + setSearchField(newSearchField: HTMLInputElement | undefined): void { + this.searchField = newSearchField; + if (this.state) { + this.selectSearchField(); + } + } + + get query(): SearchQuery { + return getSearchQuery(this.store.state); + } + + get invalidRegexp(): boolean { + const { search, valid } = this.query; + return !valid && search !== ''; + } + + updateQuery(newQueryOptions: { + search?: string; + caseSensitive?: boolean; + literal?: boolean; + regexp?: boolean; + replace?: string; + }): void { + const { search, caseSensitive, literal, regexp, replace } = this.query; + const newQuery = new SearchQuery({ + search, + caseSensitive, + literal, + regexp, + replace, + ...newQueryOptions, + ...(newQueryOptions.regexp === true && { literal: false }), + ...(newQueryOptions.literal === true && { regexp: false }), + }); + this.store.dispatch({ + effects: [setSearchQuery.of(newQuery)], + }); + } + + findNext(): void { + this.store.doCommand(findNext); + } + + findPrevious(): void { + this.store.doCommand(findPrevious); + } + + selectMatches(): void { + this.store.doCommand(selectMatches); + } + + replaceNext(): void { + this.store.doCommand(replaceNext); + } + + replaceAll(): void { + this.store.doCommand(replaceAll); + } + + override open(): boolean { + return super.open() || this.selectSearchField(); + } + + private selectSearchField(): boolean { + if (this.searchField === undefined) { + return false; + } + this.searchField.select(); + return true; + } +} 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 @@ +import CloseIcon from '@mui/icons-material/Close'; +import FindReplaceIcon from '@mui/icons-material/FindReplace'; +import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; +import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; +import SearchIcon from '@mui/icons-material/Search'; +import Button from '@mui/material/Button'; +import Checkbox from '@mui/material/Checkbox'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import FormHelperText from '@mui/material/FormHelperText'; +import IconButton from '@mui/material/IconButton'; +import Stack from '@mui/material/Stack'; +import TextField from '@mui/material/TextField'; +import Toolbar from '@mui/material/Toolbar'; +import { observer } from 'mobx-react-lite'; +import React, { useCallback } from 'react'; + +import type SearchPanelStore from './SearchPanelStore'; + +function SearchToolbar({ store }: { store: SearchPanelStore }): JSX.Element { + const { + id: panelId, + query: { search, valid, caseSensitive, literal, regexp, replace }, + invalidRegexp, + } = store; + + const searchHelperId = `${panelId}-search-helper`; + + const searchFieldRef = useCallback( + (element: HTMLInputElement | null) => + store.setSearchField(element ?? undefined), + [store], + ); + + return ( + + + + + store.updateQuery({ search: event.target.value }) + } + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + if (event.shiftKey) { + store.findPrevious(); + } else { + store.findNext(); + } + } + }} + variant="standard" + size="small" + sx={{ my: 0.25, mr: 1 }} + inputRef={searchFieldRef} + /> + {invalidRegexp && ( + ({ + my: 0, + mr: 1, + fontSize: 'inherit', + color: theme.palette.error.main, + })} + > + Invalid regexp + + )} + + store.findPrevious()} + > + + + store.findNext()} + > + + + + + + + store.updateQuery({ caseSensitive: event.target.checked }) + } + size="small" + /> + } + label="Match case" + /> + + store.updateQuery({ literal: event.target.checked }) + } + size="small" + /> + } + label="Literal" + /> + + store.updateQuery({ regexp: event.target.checked }) + } + size="small" + /> + } + label="Regexp" + /> + + + + + store.updateQuery({ replace: event.target.value }) + } + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + store.replaceNext(); + } + }} + variant="standard" + size="small" + sx={{ mr: 1 }} + /> + + + + + + + store.close()} sx={{ ml: 1 }}> + + + + ); +} + +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'; import problemLanguageSupport from '../language/problemLanguageSupport'; import type EditorStore from './EditorStore'; +import SearchPanel from './SearchPanel'; import editorClassNames from './editorClassNames'; import findOccurrences from './findOccurrences'; import semanticHighlighting from './semanticHighlighting'; @@ -61,7 +62,11 @@ export default function createEditorState( history(), indentOnInput(), rectangularSelection(), - search({ top: true }), + search({ + createPanel(view) { + return new SearchPanel(view, store.searchPanel); + }, + }), syntaxHighlighting(classHighlighter), semanticHighlighting, // We add the gutters to `extensions` in the order we want them to appear. @@ -72,8 +77,10 @@ export default function createEditorState( const button = document.createElement('button'); button.className = editorClassNames.foldPlaceholder; button.ariaLabel = 'Unfold lines'; - button.innerText = '...'; - button.onclick = onClick; + const span = document.createElement('span'); + span.innerText = '...'; + button.appendChild(span); + button.addEventListener('click', onClick); return button; }, }), 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 { alpha, createTheme, type Components, + type CSSObject, responsiveFontSizes, type ThemeOptions, ThemeProvider as MaterialUiThemeProvider, + type TypographyStyle, + type TypographyVariantsOptions, } from '@mui/material/styles'; import { observer } from 'mobx-react-lite'; import React, { type ReactNode } from 'react'; @@ -22,13 +25,30 @@ interface HighlightPalette { comment: string; activeLine: string; selection: string; + lineNumber: string; + foldPlaceholder: string; + activeLintRange: string; occurences: { read: string; write: string; }; + search: { + match: string; + selected: string; + contrastText: string; + }; } declare module '@mui/material/styles' { + interface TypographyVariants { + editor: TypographyStyle; + } + + // eslint-disable-next-line @typescript-eslint/no-shadow -- Augment imported interface. + interface TypographyVariantsOptions { + editor: TypographyStyle; + } + interface Palette { outer: OuterPalette; highlight: HighlightPalette; @@ -41,6 +61,18 @@ declare module '@mui/material/styles' { } function getMUIThemeOptions(darkMode: boolean): ThemeOptions { + const typography: TypographyVariantsOptions = { + editor: { + fontFamily: '"JetBrains MonoVariable", "JetBrains Mono", monospace', + fontFeatureSettings: '"liga", "calt"', + fontSize: '1rem', + fontWeight: 400, + lineHeight: 1.5, + letterSpacing: 0, + textRendering: 'optimizeLegibility', + }, + }; + const components: Components = { MuiButton: { styleOverrides: { @@ -67,11 +99,37 @@ function getMUIThemeOptions(darkMode: boolean): ThemeOptions { }, }, }, + MuiTooltip: { + styleOverrides: { + tooltip: { + background: alpha('#212121', 0.93), + color: '#fff', + }, + arrow: { + color: alpha('#212121', 0.93), + }, + }, + }, }; return darkMode ? { - components, + typography, + components: { + ...components, + MuiTooltip: { + ...(components.MuiTooltip || {}), + styleOverrides: { + ...(components.MuiTooltip?.styleOverrides || {}), + tooltip: { + ...((components.MuiTooltip?.styleOverrides?.tooltip as + | CSSObject + | undefined) || {}), + color: '#ebebff', + }, + }, + }, + }, palette: { mode: 'dark', primary: { main: '#56b6c2' }, @@ -99,31 +157,57 @@ function getMUIThemeOptions(darkMode: boolean): ThemeOptions { comment: '#6b717d', activeLine: '#21252b', selection: '#3e4453', + lineNumber: '#4b5263', + foldPlaceholder: alpha('#ebebff', 0.12), + activeLintRange: alpha('#fbc346', 0.28), occurences: { - read: 'rgba(255, 255, 255, 0.15)', - write: 'rgba(255, 255, 128, 0.4)', + read: alpha('#ebebff', 0.24), + write: alpha('#ebebff', 0.24), + }, + search: { + match: '#33eaff', + selected: '#dd33fa', + contrastText: '#21252b', }, }, }, } : { - components, + typography, + components: { + ...components, + MuiToolbar: { + styleOverrides: { + root: { + color: 'rgba(0, 0, 0, 0.54)', + }, + }, + }, + }, palette: { mode: 'light', - primary: { main: '#0097a7' }, + primary: { main: '#0398a8' }, outer: { background: '#f5f5f5', - border: '#d7d7d7', + border: '#cacaca', }, highlight: { - number: '#1976d2', + number: '#3d79a2', parameter: '#6a3e3e', - comment: alpha('#000', 0.38), + comment: 'rgba(0, 0, 0, 0.38)', activeLine: '#f5f5f5', selection: '#c8e4fb', + lineNumber: 'rgba(0, 0, 0, 0.38)', + foldPlaceholder: 'rgba(0, 0, 0, 0.12)', + activeLintRange: alpha('#ed6c02', 0.24), occurences: { - read: '#ceccf7', - write: '#f0d8a8', + read: 'rgba(0, 0, 0, 0.12)', + write: 'rgba(0, 0, 0, 0.12)', + }, + search: { + match: '#00bcd4', + selected: '#d500f9', + contrastText: '#ffffff', }, }, }, -- cgit v1.2.3-54-g00ecf