aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorLibravatar Kristóf Marussy <kristof@marussy.com>2022-11-01 12:05:21 -0400
committerLibravatar Kristóf Marussy <kristof@marussy.com>2022-11-05 19:41:33 +0100
commitffe5b08149681f51625bdac43c7ab41ccf5f9cc3 (patch)
treef19137f20c73613ed8d4e63a725e4a1d20a00ec2
parentfeat(frontend): overlay scrollbars for editor (diff)
downloadrefinery-ffe5b08149681f51625bdac43c7ab41ccf5f9cc3.tar.gz
refinery-ffe5b08149681f51625bdac43c7ab41ccf5f9cc3.tar.zst
refinery-ffe5b08149681f51625bdac43c7ab41ccf5f9cc3.zip
feat(frontend): scrollbar annotations
-rw-r--r--subprojects/frontend/src/editor/DiagnosticValue.ts20
-rw-r--r--subprojects/frontend/src/editor/EditorStore.ts53
-rw-r--r--subprojects/frontend/src/editor/EditorTheme.ts26
-rw-r--r--subprojects/frontend/src/editor/scrollbarViewPlugin.ts106
-rw-r--r--subprojects/frontend/src/theme/ThemeProvider.tsx3
5 files changed, 178 insertions, 30 deletions
diff --git a/subprojects/frontend/src/editor/DiagnosticValue.ts b/subprojects/frontend/src/editor/DiagnosticValue.ts
new file mode 100644
index 00000000..ad23c467
--- /dev/null
+++ b/subprojects/frontend/src/editor/DiagnosticValue.ts
@@ -0,0 +1,20 @@
1import type { Diagnostic } from '@codemirror/lint';
2import { RangeValue } from '@codemirror/state';
3
4export default class DiagnosticValue extends RangeValue {
5 static VALUES: Record<Diagnostic['severity'], DiagnosticValue> = {
6 error: new DiagnosticValue('error'),
7 warning: new DiagnosticValue('warning'),
8 info: new DiagnosticValue('info'),
9 };
10
11 private constructor(public readonly severity: Diagnostic['severity']) {
12 super();
13 }
14
15 override point = true;
16
17 override eq(other: RangeValue): boolean {
18 return other instanceof DiagnosticValue && other.severity === this.severity;
19 }
20}
diff --git a/subprojects/frontend/src/editor/EditorStore.ts b/subprojects/frontend/src/editor/EditorStore.ts
index 4ee24779..acad3d09 100644
--- a/subprojects/frontend/src/editor/EditorStore.ts
+++ b/subprojects/frontend/src/editor/EditorStore.ts
@@ -11,6 +11,7 @@ import {
11 type Transaction, 11 type Transaction,
12 type TransactionSpec, 12 type TransactionSpec,
13 type EditorState, 13 type EditorState,
14 RangeSet,
14} from '@codemirror/state'; 15} from '@codemirror/state';
15import { type Command, EditorView } from '@codemirror/view'; 16import { type Command, EditorView } from '@codemirror/view';
16import { makeAutoObservable, observable } from 'mobx'; 17import { makeAutoObservable, observable } from 'mobx';
@@ -20,6 +21,7 @@ import type PWAStore from '../PWAStore';
20import getLogger from '../utils/getLogger'; 21import getLogger from '../utils/getLogger';
21import XtextClient from '../xtext/XtextClient'; 22import XtextClient from '../xtext/XtextClient';
22 23
24import DiagnosticValue from './DiagnosticValue';
23import LintPanelStore from './LintPanelStore'; 25import LintPanelStore from './LintPanelStore';
24import SearchPanelStore from './SearchPanelStore'; 26import SearchPanelStore from './SearchPanelStore';
25import createEditorState from './createEditorState'; 27import createEditorState from './createEditorState';
@@ -46,11 +48,7 @@ export default class EditorStore {
46 48
47 showLineNumbers = false; 49 showLineNumbers = false;
48 50
49 errorCount = 0; 51 diagnostics: RangeSet<DiagnosticValue> = RangeSet.of([]);
50
51 warningCount = 0;
52
53 infoCount = 0;
54 52
55 constructor(initialValue: string, pwaStore: PWAStore) { 53 constructor(initialValue: string, pwaStore: PWAStore) {
56 this.id = nanoid(); 54 this.id = nanoid();
@@ -161,6 +159,9 @@ export default class EditorStore {
161 log.trace('Editor transaction', tr); 159 log.trace('Editor transaction', tr);
162 this.state = tr.state; 160 this.state = tr.state;
163 this.client.onTransaction(tr); 161 this.client.onTransaction(tr);
162 if (tr.docChanged) {
163 this.diagnostics = this.diagnostics.map(tr.changes);
164 }
164 } 165 }
165 166
166 doCommand(command: Command): boolean { 167 doCommand(command: Command): boolean {
@@ -178,25 +179,31 @@ export default class EditorStore {
178 } 179 }
179 180
180 updateDiagnostics(diagnostics: Diagnostic[]): void { 181 updateDiagnostics(diagnostics: Diagnostic[]): void {
182 diagnostics.sort((a, b) => a.from - b.from);
181 this.dispatch(setDiagnostics(this.state, diagnostics)); 183 this.dispatch(setDiagnostics(this.state, diagnostics));
182 this.errorCount = 0; 184 this.diagnostics = RangeSet.of(
183 this.warningCount = 0; 185 diagnostics.map(({ severity, from, to }) =>
184 this.infoCount = 0; 186 DiagnosticValue.VALUES[severity].range(from, to),
185 diagnostics.forEach(({ severity }) => { 187 ),
186 switch (severity) { 188 );
187 case 'error': 189 }
188 this.errorCount += 1; 190
189 break; 191 countDiagnostics(severity: Diagnostic['severity']): number {
190 case 'warning': 192 return this.diagnostics.update({
191 this.warningCount += 1; 193 filter: (_from, _to, value) => value.eq(DiagnosticValue.VALUES[severity]),
192 break; 194 }).size;
193 case 'info': 195 }
194 this.infoCount += 1; 196
195 break; 197 get errorCount(): number {
196 default: 198 return this.countDiagnostics('error');
197 throw new Error('Unknown severity'); 199 }
198 } 200
199 }); 201 get warningCount(): number {
202 return this.countDiagnostics('warning');
203 }
204
205 get infoCount(): number {
206 return this.countDiagnostics('info');
200 } 207 }
201 208
202 nextDiagnostic(): void { 209 nextDiagnostic(): void {
diff --git a/subprojects/frontend/src/editor/EditorTheme.ts b/subprojects/frontend/src/editor/EditorTheme.ts
index 829b709f..f5267682 100644
--- a/subprojects/frontend/src/editor/EditorTheme.ts
+++ b/subprojects/frontend/src/editor/EditorTheme.ts
@@ -137,7 +137,7 @@ export default styled('div', {
137 background: 'transparent', 137 background: 'transparent',
138 }, 138 },
139 '.cm-cursor, .cm-cursor-primary': { 139 '.cm-cursor, .cm-cursor-primary': {
140 borderLeft: `2px solid ${theme.palette.primary.main}`, 140 borderLeft: `2px solid ${theme.palette.highlight.cursor}`,
141 }, 141 },
142 '.cm-selectionBackground': { 142 '.cm-selectionBackground': {
143 background: theme.palette.highlight.selection, 143 background: theme.palette.highlight.selection,
@@ -245,6 +245,17 @@ export default styled('div', {
245 boxShadow: `1px 0 0 ${theme.palette.text.primary} inset`, 245 boxShadow: `1px 0 0 ${theme.palette.text.primary} inset`,
246 }, 246 },
247 }, 247 },
248 '.cm-scroller-selection': {
249 position: 'absolute',
250 right: 0,
251 boxShadow: `0 2px 0 ${theme.palette.highlight.cursor} inset`,
252 zIndex: 200,
253 },
254 '.cm-scroller-occurrence': {
255 position: 'absolute',
256 background: theme.palette.text.secondary,
257 zIndex: 150,
258 },
248 }; 259 };
249 260
250 const lineNumberStyle: CSSObject = { 261 const lineNumberStyle: CSSObject = {
@@ -288,6 +299,7 @@ export default styled('div', {
288 function lintSeverityStyle( 299 function lintSeverityStyle(
289 severity: 'error' | 'warning' | 'info', 300 severity: 'error' | 'warning' | 'info',
290 icon: string, 301 icon: string,
302 zIndex: number,
291 ): CSSObject { 303 ): CSSObject {
292 const palette = theme.palette[severity]; 304 const palette = theme.palette[severity];
293 const color = palette.main; 305 const color = palette.main;
@@ -342,6 +354,12 @@ export default styled('div', {
342 display: 'none', 354 display: 'none',
343 }, 355 },
344 }, 356 },
357 [`.cm-scroller-diagnostic-${severity}`]: {
358 position: 'absolute',
359 right: 0,
360 background: color,
361 zIndex,
362 },
345 }; 363 };
346 } 364 }
347 365
@@ -400,9 +418,9 @@ export default styled('div', {
400 '.cm-lintRange-active': { 418 '.cm-lintRange-active': {
401 background: theme.palette.highlight.activeLintRange, 419 background: theme.palette.highlight.activeLintRange,
402 }, 420 },
403 ...lintSeverityStyle('error', errorSVG), 421 ...lintSeverityStyle('error', errorSVG, 120),
404 ...lintSeverityStyle('warning', warningSVG), 422 ...lintSeverityStyle('warning', warningSVG, 110),
405 ...lintSeverityStyle('info', infoSVG), 423 ...lintSeverityStyle('info', infoSVG, 100),
406 }; 424 };
407 425
408 const foldStyle = { 426 const foldStyle = {
diff --git a/subprojects/frontend/src/editor/scrollbarViewPlugin.ts b/subprojects/frontend/src/editor/scrollbarViewPlugin.ts
index 2882f02e..c95e581d 100644
--- a/subprojects/frontend/src/editor/scrollbarViewPlugin.ts
+++ b/subprojects/frontend/src/editor/scrollbarViewPlugin.ts
@@ -2,6 +2,7 @@ import { type PluginValue, ViewPlugin } from '@codemirror/view';
2import { reaction } from 'mobx'; 2import { reaction } from 'mobx';
3 3
4import type EditorStore from './EditorStore'; 4import type EditorStore from './EditorStore';
5import findOccurrences from './findOccurrences';
5 6
6export const HOLDER_CLASS = 'cm-scroller-holder'; 7export const HOLDER_CLASS = 'cm-scroller-holder';
7export const THUMB_CLASS = 'cm-scroller-thumb'; 8export const THUMB_CLASS = 'cm-scroller-thumb';
@@ -10,8 +11,13 @@ export const THUMB_X_CLASS = 'cm-scroller-thumb-x';
10export const THUMB_ACTIVE_CLASS = 'active'; 11export const THUMB_ACTIVE_CLASS = 'active';
11export const GUTTER_DECORATION_CLASS = 'cm-scroller-gutter-decoration'; 12export const GUTTER_DECORATION_CLASS = 'cm-scroller-gutter-decoration';
12export const TOP_DECORATION_CLASS = 'cm-scroller-top-decoration'; 13export const TOP_DECORATION_CLASS = 'cm-scroller-top-decoration';
14export const ANNOTATION_SELECTION_CLASS = 'cm-scroller-selection';
15export const ANNOTATION_DIAGNOSTIC_CLASS = 'cm-scroller-diagnostic';
16export const ANNOTATION_OCCURRENCE_CLASS = 'cm-scroller-occurrence';
13export const SHADOW_WIDTH = 10; 17export const SHADOW_WIDTH = 10;
14export const SCROLLBAR_WIDTH = 12; 18export const SCROLLBAR_WIDTH = 12;
19export const ANNOTATION_WIDTH = SCROLLBAR_WIDTH / 2;
20export const MIN_ANNOTATION_HEIGHT = 1;
15 21
16export default function scrollbarViewPlugin( 22export default function scrollbarViewPlugin(
17 editorStore: EditorStore, 23 editorStore: EditorStore,
@@ -94,6 +100,84 @@ export default function scrollbarViewPlugin(
94 let gutters: Element | undefined; 100 let gutters: Element | undefined;
95 101
96 let requested = false; 102 let requested = false;
103 let rebuildRequested = false;
104
105 const annotations: HTMLDivElement[] = [];
106
107 function rebuildAnnotations(trackYHeight: number) {
108 const annotationOverlayHeight = Math.min(
109 view.contentHeight,
110 trackYHeight,
111 );
112 const lineHeight = annotationOverlayHeight / editorStore.state.doc.lines;
113
114 let i = 0;
115
116 function getOrCreateAnnotation(
117 from: number,
118 to?: number,
119 ): HTMLDivElement {
120 const startLine = editorStore.state.doc.lineAt(from).number;
121 const endLine =
122 to === undefined
123 ? startLine
124 : editorStore.state.doc.lineAt(to).number;
125 const top = (startLine - 1) * lineHeight;
126 const height = Math.max(
127 MIN_ANNOTATION_HEIGHT,
128 Math.max(1, endLine - startLine) * lineHeight,
129 );
130
131 let annotation: HTMLDivElement;
132 if (i < annotations.length) {
133 annotation = annotations[i];
134 } else {
135 annotation = ownerDocument.createElement('div');
136 annotations.push(annotation);
137 holder.appendChild(annotation);
138 }
139 i += 1;
140
141 annotation.style.top = `${top}px`;
142 annotation.style.height = `${height}px`;
143
144 return annotation;
145 }
146
147 editorStore.state.selection.ranges.forEach(({ head }) => {
148 const selectionAnnotation = getOrCreateAnnotation(head);
149 selectionAnnotation.className = ANNOTATION_SELECTION_CLASS;
150 selectionAnnotation.style.width = `${SCROLLBAR_WIDTH}px`;
151 });
152
153 const diagnosticsIter = editorStore.diagnostics.iter();
154 while (diagnosticsIter.value !== null) {
155 const diagnosticAnnotation = getOrCreateAnnotation(
156 diagnosticsIter.from,
157 diagnosticsIter.to,
158 );
159 diagnosticAnnotation.className = `${ANNOTATION_DIAGNOSTIC_CLASS} ${ANNOTATION_DIAGNOSTIC_CLASS}-${diagnosticsIter.value.severity}`;
160 diagnosticAnnotation.style.width = `${ANNOTATION_WIDTH}px`;
161 diagnosticsIter.next();
162 }
163
164 const occurrences = editorStore.state.field(findOccurrences);
165 const occurrencesIter = occurrences.iter();
166 while (occurrencesIter.value !== null) {
167 const occurrenceAnnotation = getOrCreateAnnotation(
168 occurrencesIter.from,
169 occurrencesIter.to,
170 );
171 occurrenceAnnotation.className = ANNOTATION_OCCURRENCE_CLASS;
172 occurrenceAnnotation.style.width = `${ANNOTATION_WIDTH}px`;
173 occurrenceAnnotation.style.right = `${ANNOTATION_WIDTH}px`;
174 occurrencesIter.next();
175 }
176
177 annotations
178 .splice(i)
179 .forEach((staleAnnotation) => holder.removeChild(staleAnnotation));
180 }
97 181
98 function update() { 182 function update() {
99 requested = false; 183 requested = false;
@@ -148,6 +232,11 @@ export default function scrollbarViewPlugin(
148 0, 232 0,
149 Math.min(scrollTop, SHADOW_WIDTH), 233 Math.min(scrollTop, SHADOW_WIDTH),
150 )}px`; 234 )}px`;
235
236 if (rebuildRequested) {
237 rebuildAnnotations(trackYHeight);
238 rebuildRequested = false;
239 }
151 } 240 }
152 241
153 function requestUpdate() { 242 function requestUpdate() {
@@ -157,16 +246,27 @@ export default function scrollbarViewPlugin(
157 } 246 }
158 } 247 }
159 248
160 observer = new ResizeObserver(requestUpdate); 249 function requestRebuild() {
250 requestUpdate();
251 rebuildRequested = true;
252 }
253
254 observer = new ResizeObserver(requestRebuild);
161 observer.observe(scrollDOM); 255 observer.observe(scrollDOM);
162 256
163 scrollDOM.addEventListener('scroll', requestUpdate); 257 scrollDOM.addEventListener('scroll', requestUpdate);
164 258
165 requestUpdate(); 259 requestRebuild();
260
261 const disposeRebuildReaction = reaction(
262 () => editorStore.diagnostics,
263 requestRebuild,
264 );
166 265
167 return { 266 return {
168 update: requestUpdate, 267 update: requestRebuild,
169 destroy() { 268 destroy() {
269 disposeRebuildReaction();
170 disposePanelReaction(); 270 disposePanelReaction();
171 observer?.disconnect(); 271 observer?.disconnect();
172 scrollDOM.removeEventListener('scroll', requestUpdate); 272 scrollDOM.removeEventListener('scroll', requestUpdate);
diff --git a/subprojects/frontend/src/theme/ThemeProvider.tsx b/subprojects/frontend/src/theme/ThemeProvider.tsx
index 47062314..a00d70fe 100644
--- a/subprojects/frontend/src/theme/ThemeProvider.tsx
+++ b/subprojects/frontend/src/theme/ThemeProvider.tsx
@@ -22,6 +22,7 @@ interface OuterPalette {
22} 22}
23 23
24interface HighlightPalette { 24interface HighlightPalette {
25 cursor: string;
25 number: string; 26 number: string;
26 parameter: string; 27 parameter: string;
27 comment: string; 28 comment: string;
@@ -175,6 +176,7 @@ const lightTheme = createResponsiveTheme({
175 border: '#c8c8c8', 176 border: '#c8c8c8',
176 }, 177 },
177 highlight: { 178 highlight: {
179 cursor: '#4078f2',
178 number: '#0084bc', 180 number: '#0084bc',
179 parameter: '#6a3e3e', 181 parameter: '#6a3e3e',
180 comment: '#a0a1a7', 182 comment: '#a0a1a7',
@@ -249,6 +251,7 @@ const darkTheme = createResponsiveTheme({
249 border: '#181a1f', 251 border: '#181a1f',
250 }, 252 },
251 highlight: { 253 highlight: {
254 cursor: '#61afef',
252 number: '#6188a6', 255 number: '#6188a6',
253 parameter: '#c8ae9d', 256 parameter: '#c8ae9d',
254 comment: '#7f848e', 257 comment: '#7f848e',