From a96c52b21e7e590bbdd70b80896780a446fa2e8b Mon Sep 17 00:00:00 2001 From: Kristóf Marussy Date: Mon, 13 Dec 2021 02:07:04 +0100 Subject: build: separate module for frontend This allows us to simplify the webpack configuration and the gradle build scripts. --- subprojects/frontend/src/editor/EditorArea.tsx | 152 +++++++++++ subprojects/frontend/src/editor/EditorButtons.tsx | 98 +++++++ subprojects/frontend/src/editor/EditorParent.ts | 205 +++++++++++++++ subprojects/frontend/src/editor/EditorStore.ts | 289 +++++++++++++++++++++ subprojects/frontend/src/editor/GenerateButton.tsx | 44 ++++ .../frontend/src/editor/decorationSetExtension.ts | 39 +++ subprojects/frontend/src/editor/findOccurrences.ts | 35 +++ .../frontend/src/editor/semanticHighlighting.ts | 24 ++ 8 files changed, 886 insertions(+) create mode 100644 subprojects/frontend/src/editor/EditorArea.tsx create mode 100644 subprojects/frontend/src/editor/EditorButtons.tsx create mode 100644 subprojects/frontend/src/editor/EditorParent.ts create mode 100644 subprojects/frontend/src/editor/EditorStore.ts create mode 100644 subprojects/frontend/src/editor/GenerateButton.tsx create mode 100644 subprojects/frontend/src/editor/decorationSetExtension.ts create mode 100644 subprojects/frontend/src/editor/findOccurrences.ts create mode 100644 subprojects/frontend/src/editor/semanticHighlighting.ts (limited to 'subprojects/frontend/src/editor') diff --git a/subprojects/frontend/src/editor/EditorArea.tsx b/subprojects/frontend/src/editor/EditorArea.tsx new file mode 100644 index 00000000..dba20f6e --- /dev/null +++ b/subprojects/frontend/src/editor/EditorArea.tsx @@ -0,0 +1,152 @@ +import { Command, EditorView } from '@codemirror/view'; +import { closeSearchPanel, openSearchPanel } from '@codemirror/search'; +import { closeLintPanel, openLintPanel } from '@codemirror/lint'; +import { observer } from 'mobx-react-lite'; +import React, { + useCallback, + useEffect, + useRef, + useState, +} from 'react'; + +import { EditorParent } from './EditorParent'; +import { useRootStore } from '../RootStore'; +import { getLogger } from '../utils/logger'; + +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'); +} + +export const EditorArea = observer(() => { + const { editorStore } = useRootStore(); + const editorParentRef = useRef(null); + const [editorViewState, setEditorViewState] = useState(null); + + const setSearchPanelOpen = usePanel( + 'search', + editorStore.showSearchPanel, + editorViewState, + openSearchPanel, + closeSearchPanel, + useCallback(() => editorStore.setSearchPanelOpen(false), [editorStore]), + ); + + const setLintPanelOpen = usePanel( + 'panel-lint', + editorStore.showLintPanel, + editorViewState, + openLintPanel, + closeLintPanel, + useCallback(() => editorStore.setLintPanelOpen(false), [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'); + }; + }, [ + editorParentRef, + editorStore, + setSearchPanelOpen, + setLintPanelOpen, + ]); + + return ( + + ); +}); diff --git a/subprojects/frontend/src/editor/EditorButtons.tsx b/subprojects/frontend/src/editor/EditorButtons.tsx new file mode 100644 index 00000000..150aa00d --- /dev/null +++ b/subprojects/frontend/src/editor/EditorButtons.tsx @@ -0,0 +1,98 @@ +import type { Diagnostic } from '@codemirror/lint'; +import { observer } from 'mobx-react-lite'; +import IconButton from '@mui/material/IconButton'; +import Stack from '@mui/material/Stack'; +import ToggleButton from '@mui/material/ToggleButton'; +import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; +import CheckIcon from '@mui/icons-material/Check'; +import ErrorIcon from '@mui/icons-material/Error'; +import FormatListNumberedIcon from '@mui/icons-material/FormatListNumbered'; +import FormatPaint from '@mui/icons-material/FormatPaint'; +import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; +import RedoIcon from '@mui/icons-material/Redo'; +import SearchIcon from '@mui/icons-material/Search'; +import UndoIcon from '@mui/icons-material/Undo'; +import WarningIcon from '@mui/icons-material/Warning'; +import React from 'react'; + +import { useRootStore } from '../RootStore'; + +// Exhastive switch as proven by TypeScript. +// eslint-disable-next-line consistent-return +function getLintIcon(severity: Diagnostic['severity'] | null) { + switch (severity) { + case 'error': + return ; + case 'warning': + return ; + case 'info': + return ; + case null: + return ; + } +} + +export const EditorButtons = observer(() => { + const { editorStore } = useRootStore(); + + return ( + + + editorStore.undo()} + aria-label="Undo" + > + + + editorStore.redo()} + aria-label="Redo" + > + + + + + editorStore.toggleLineNumbers()} + aria-label="Show line numbers" + value="show-line-numbers" + > + + + editorStore.toggleSearchPanel()} + aria-label="Show find/replace" + value="show-search-panel" + > + + + editorStore.toggleLintPanel()} + aria-label="Show diagnostics panel" + value="show-lint-panel" + > + {getLintIcon(editorStore.highestDiagnosticLevel)} + + + editorStore.formatText()} + aria-label="Automatic format" + > + + + + ); +}); diff --git a/subprojects/frontend/src/editor/EditorParent.ts b/subprojects/frontend/src/editor/EditorParent.ts new file mode 100644 index 00000000..94ca24ea --- /dev/null +++ b/subprojects/frontend/src/editor/EditorParent.ts @@ -0,0 +1,205 @@ +import { styled } from '@mui/material/styles'; + +/** + * Returns a squiggly underline background image encoded as a CSS `url()` data URI with Base64. + * + * Based on + * https://github.com/codemirror/lint/blob/f524b4a53b0183bb343ac1e32b228d28030d17af/src/lint.ts#L501 + * + * @param color the color of the underline + * @returns the CSS `url()` + */ +function underline(color: string) { + const svg = ` + + `; + const svgBase64 = window.btoa(svg); + return `url('data:image/svg+xml;base64,${svgBase64}')`; +} + +export const EditorParent = styled('div')(({ theme }) => { + 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: underline(color), + }; + }); + + return { + background: theme.palette.background.default, + '&, .cm-editor': { + height: '100%', + }, + '.cm-content': { + padding: 0, + }, + '.cm-scroller, .cm-tooltip-autocomplete, .cm-completionLabel, .cm-completionDetail': { + fontSize: 16, + fontFamily: '"JetBrains MonoVariable", "JetBrains Mono", monospace', + fontFeatureSettings: '"liga", "calt"', + fontWeight: 400, + letterSpacing: 0, + textRendering: 'optimizeLegibility', + }, + '.cm-scroller': { + color: theme.palette.text.secondary, + }, + '.cm-gutters': { + background: 'rgba(255, 255, 255, 0.1)', + color: theme.palette.text.disabled, + border: 'none', + }, + '.cm-specialChar': { + color: theme.palette.secondary.main, + }, + '.cm-activeLine': { + background: 'rgba(0, 0, 0, 0.3)', + }, + '.cm-activeLineGutter': { + background: 'transparent', + }, + '.cm-lineNumbers .cm-activeLineGutter': { + color: theme.palette.text.primary, + }, + '.cm-cursor, .cm-cursor-primary': { + borderColor: theme.palette.primary.main, + background: theme.palette.common.black, + }, + '.cm-selectionBackground': { + background: '#3e4453', + }, + '.cm-focused': { + outline: 'none', + '.cm-selectionBackground': { + background: '#3e4453', + }, + }, + '.cm-panels-top': { + color: theme.palette.text.secondary, + }, + '.cm-panel': { + '&, & button, & input': { + fontFamily: '"Roboto","Helvetica","Arial",sans-serif', + }, + background: theme.palette.background.paper, + borderTop: `1px solid ${theme.palette.divider}`, + 'button[name="close"]': { + background: 'transparent', + color: theme.palette.text.secondary, + cursor: 'pointer', + }, + }, + '.cm-panel.cm-panel-lint': { + '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: { + borderBottom: `1px solid ${theme.palette.divider}`, + cursor: 'pointer', + }, + '[aria-selected]': { + background: '#3e4453', + color: theme.palette.text.primary, + }, + '&:focus [aria-selected]': { + background: theme.palette.primary.main, + color: theme.palette.primary.contrastText, + }, + }, + }, + '.cm-foldPlaceholder': { + background: theme.palette.background.paper, + borderColor: theme.palette.text.disabled, + color: theme.palette.text.secondary, + }, + '.cmt-comment': { + fontStyle: 'italic', + color: theme.palette.text.disabled, + }, + '.cmt-number': { + color: '#6188a6', + }, + '.cmt-string': { + color: theme.palette.secondary.dark, + }, + '.cmt-keyword': { + color: theme.palette.primary.main, + }, + '.cmt-typeName, .cmt-macroName, .cmt-atom': { + color: theme.palette.text.primary, + }, + '.cmt-variableName': { + color: '#c8ae9d', + }, + '.cmt-problem-node': { + '&, & .cmt-variableName': { + color: theme.palette.text.secondary, + }, + }, + '.cmt-problem-individual': { + '&, & .cmt-variableName': { + color: theme.palette.text.primary, + }, + }, + '.cmt-problem-abstract, .cmt-problem-new': { + fontStyle: 'italic', + }, + '.cmt-problem-containment': { + fontWeight: 700, + }, + '.cmt-problem-error': { + '&, & .cmt-typeName': { + color: theme.palette.error.main, + }, + }, + '.cmt-problem-builtin': { + '&, & .cmt-typeName, & .cmt-atom, & .cmt-variableName': { + color: theme.palette.primary.main, + fontWeight: 400, + fontStyle: 'normal', + }, + }, + '.cm-tooltip-autocomplete': { + background: theme.palette.background.paper, + boxShadow: `0px 2px 4px -1px rgb(0 0 0 / 20%), + 0px 4px 5px 0px rgb(0 0 0 / 14%), + 0px 1px 10px 0px rgb(0 0 0 / 12%)`, + '.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-write': { + background: 'rgba(255, 255, 128, 0.3)', + }, + '.cm-problem-read': { + background: 'rgba(255, 255, 255, 0.15)', + }, + }; +}); diff --git a/subprojects/frontend/src/editor/EditorStore.ts b/subprojects/frontend/src/editor/EditorStore.ts new file mode 100644 index 00000000..5760de28 --- /dev/null +++ b/subprojects/frontend/src/editor/EditorStore.ts @@ -0,0 +1,289 @@ +import { autocompletion, completionKeymap } from '@codemirror/autocomplete'; +import { closeBrackets, closeBracketsKeymap } from '@codemirror/closebrackets'; +import { defaultKeymap, indentWithTab } from '@codemirror/commands'; +import { commentKeymap } from '@codemirror/comment'; +import { foldGutter, foldKeymap } from '@codemirror/fold'; +import { highlightActiveLineGutter, lineNumbers } from '@codemirror/gutter'; +import { classHighlightStyle } from '@codemirror/highlight'; +import { + history, + historyKeymap, + redo, + redoDepth, + undo, + undoDepth, +} from '@codemirror/history'; +import { indentOnInput } from '@codemirror/language'; +import { + Diagnostic, + lintKeymap, + setDiagnostics, +} from '@codemirror/lint'; +import { bracketMatching } from '@codemirror/matchbrackets'; +import { rectangularSelection } from '@codemirror/rectangular-selection'; +import { searchConfig, searchKeymap } from '@codemirror/search'; +import { + EditorState, + StateCommand, + StateEffect, + Transaction, + TransactionSpec, +} from '@codemirror/state'; +import { + drawSelection, + EditorView, + highlightActiveLine, + highlightSpecialChars, + keymap, +} from '@codemirror/view'; +import { + makeAutoObservable, + observable, + reaction, +} from 'mobx'; + +import { findOccurrences, IOccurrence, setOccurrences } from './findOccurrences'; +import { problemLanguageSupport } from '../language/problemLanguageSupport'; +import { + IHighlightRange, + semanticHighlighting, + setSemanticHighlighting, +} from './semanticHighlighting'; +import type { ThemeStore } from '../theme/ThemeStore'; +import { getLogger } from '../utils/logger'; +import { XtextClient } from '../xtext/XtextClient'; + +const log = getLogger('editor.EditorStore'); + +export class EditorStore { + private readonly themeStore; + + state: EditorState; + + private readonly client: XtextClient; + + showLineNumbers = false; + + showSearchPanel = false; + + showLintPanel = false; + + errorCount = 0; + + warningCount = 0; + + 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), + ], + }), + classHighlightStyle.extension, + closeBrackets(), + bracketMatching(), + drawSelection(), + EditorState.allowMultipleSelections.of(true), + EditorView.theme({}, { + dark: this.themeStore.darkMode, + }), + findOccurrences, + highlightActiveLine(), + highlightActiveLineGutter(), + highlightSpecialChars(), + history(), + indentOnInput(), + rectangularSelection(), + searchConfig({ + top: true, + matchCase: true, + }), + 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, + ...commentKeymap, + ...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(), + ], + }); + 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, + })), + ], + }); + }, + ); + makeAutoObservable(this, { + state: observable.ref, + }); + } + + updateDispatcher(newDispatcher: ((tr: Transaction) => void) | null): void { + this.dispatcher = newDispatcher || this.defaultDispatcher; + } + + onTransaction(tr: Transaction): void { + log.trace('Editor transaction', tr); + this.state = tr.state; + this.client.onTransaction(tr); + } + + dispatch(...specs: readonly TransactionSpec[]): void { + this.dispatcher(this.state.update(...specs)); + } + + doStateCommand(command: StateCommand): boolean { + return command({ + state: this.state, + dispatch: this.dispatcher, + }); + } + + updateDiagnostics(diagnostics: Diagnostic[]): void { + this.dispatch(setDiagnostics(this.state, diagnostics)); + this.errorCount = 0; + this.warningCount = 0; + this.infoCount = 0; + diagnostics.forEach(({ severity }) => { + switch (severity) { + case 'error': + this.errorCount += 1; + break; + case 'warning': + this.warningCount += 1; + break; + case 'info': + this.infoCount += 1; + break; + } + }); + } + + get highestDiagnosticLevel(): Diagnostic['severity'] | null { + if (this.errorCount > 0) { + return 'error'; + } + if (this.warningCount > 0) { + return 'warning'; + } + if (this.infoCount > 0) { + return 'info'; + } + return null; + } + + updateSemanticHighlighting(ranges: IHighlightRange[]): void { + this.dispatch(setSemanticHighlighting(ranges)); + } + + updateOccurrences(write: IOccurrence[], read: IOccurrence[]): void { + this.dispatch(setOccurrences(write, read)); + } + + /** + * @returns `true` if there is history to undo + */ + get canUndo(): boolean { + return undoDepth(this.state) > 0; + } + + // eslint-disable-next-line class-methods-use-this + undo(): void { + log.debug('Undo', this.doStateCommand(undo)); + } + + /** + * @returns `true` if there is history to redo + */ + get canRedo(): boolean { + return redoDepth(this.state) > 0; + } + + // eslint-disable-next-line class-methods-use-this + redo(): void { + log.debug('Redo', this.doStateCommand(redo)); + } + + toggleLineNumbers(): void { + this.showLineNumbers = !this.showLineNumbers; + 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 newShosSearchPanel 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/GenerateButton.tsx b/subprojects/frontend/src/editor/GenerateButton.tsx new file mode 100644 index 00000000..3834cec4 --- /dev/null +++ b/subprojects/frontend/src/editor/GenerateButton.tsx @@ -0,0 +1,44 @@ +import { observer } from 'mobx-react-lite'; +import Button from '@mui/material/Button'; +import PlayArrowIcon from '@mui/icons-material/PlayArrow'; +import React from 'react'; + +import { useRootStore } from '../RootStore'; + +const GENERATE_LABEL = 'Generate'; + +export const GenerateButton = observer(() => { + const { editorStore } = useRootStore(); + const { errorCount, warningCount } = editorStore; + + const diagnostics: string[] = []; + if (errorCount > 0) { + diagnostics.push(`${errorCount} error${errorCount === 1 ? '' : 's'}`); + } + if (warningCount > 0) { + diagnostics.push(`${warningCount} warning${warningCount === 1 ? '' : 's'}`); + } + const summary = diagnostics.join(' and '); + + if (errorCount > 0) { + return ( + + ); + } + + return ( + + ); +}); diff --git a/subprojects/frontend/src/editor/decorationSetExtension.ts b/subprojects/frontend/src/editor/decorationSetExtension.ts new file mode 100644 index 00000000..2d630c20 --- /dev/null +++ b/subprojects/frontend/src/editor/decorationSetExtension.ts @@ -0,0 +1,39 @@ +import { StateEffect, StateField, TransactionSpec } from '@codemirror/state'; +import { EditorView, Decoration, DecorationSet } from '@codemirror/view'; + +export type TransactionSpecFactory = (decorations: DecorationSet) => TransactionSpec; + +export function decorationSetExtension(): [TransactionSpecFactory, StateField] { + const setEffect = StateEffect.define(); + const field = StateField.define({ + create() { + return Decoration.none; + }, + update(currentDecorations, transaction) { + let newDecorations: DecorationSet | null = null; + transaction.effects.forEach((effect) => { + if (effect.is(setEffect)) { + newDecorations = effect.value; + } + }); + if (newDecorations === null) { + if (transaction.docChanged) { + return currentDecorations.map(transaction.changes); + } + return currentDecorations; + } + return newDecorations; + }, + provide: (f) => EditorView.decorations.from(f), + }); + + function transactionSpecFactory(decorations: DecorationSet) { + return { + effects: [ + setEffect.of(decorations), + ], + }; + } + + return [transactionSpecFactory, field]; +} diff --git a/subprojects/frontend/src/editor/findOccurrences.ts b/subprojects/frontend/src/editor/findOccurrences.ts new file mode 100644 index 00000000..92102746 --- /dev/null +++ b/subprojects/frontend/src/editor/findOccurrences.ts @@ -0,0 +1,35 @@ +import { Range, RangeSet } from '@codemirror/rangeset'; +import type { TransactionSpec } from '@codemirror/state'; +import { Decoration } from '@codemirror/view'; + +import { decorationSetExtension } from './decorationSetExtension'; + +export interface IOccurrence { + from: number; + + to: number; +} + +const [setOccurrencesInteral, findOccurrences] = decorationSetExtension(); + +const writeDecoration = Decoration.mark({ + class: 'cm-problem-write', +}); + +const readDecoration = Decoration.mark({ + class: 'cm-problem-read', +}); + +export function setOccurrences(write: IOccurrence[], read: IOccurrence[]): TransactionSpec { + const decorations: Range[] = []; + write.forEach(({ from, to }) => { + decorations.push(writeDecoration.range(from, to)); + }); + read.forEach(({ from, to }) => { + decorations.push(readDecoration.range(from, to)); + }); + const rangeSet = RangeSet.of(decorations, true); + return setOccurrencesInteral(rangeSet); +} + +export { findOccurrences }; diff --git a/subprojects/frontend/src/editor/semanticHighlighting.ts b/subprojects/frontend/src/editor/semanticHighlighting.ts new file mode 100644 index 00000000..2aed421b --- /dev/null +++ b/subprojects/frontend/src/editor/semanticHighlighting.ts @@ -0,0 +1,24 @@ +import { RangeSet } from '@codemirror/rangeset'; +import type { TransactionSpec } from '@codemirror/state'; +import { Decoration } from '@codemirror/view'; + +import { decorationSetExtension } from './decorationSetExtension'; + +export interface IHighlightRange { + from: number; + + to: number; + + classes: string[]; +} + +const [setSemanticHighlightingInternal, semanticHighlighting] = decorationSetExtension(); + +export function setSemanticHighlighting(ranges: IHighlightRange[]): TransactionSpec { + const rangeSet = RangeSet.of(ranges.map(({ from, to, classes }) => Decoration.mark({ + class: classes.map((c) => `cmt-problem-${c}`).join(' '), + }).range(from, to)), true); + return setSemanticHighlightingInternal(rangeSet); +} + +export { semanticHighlighting }; -- cgit v1.2.3-54-g00ecf