diff options
author | Kristóf Marussy <kristof@marussy.com> | 2022-11-01 12:05:21 -0400 |
---|---|---|
committer | Kristóf Marussy <kristof@marussy.com> | 2022-11-05 19:41:33 +0100 |
commit | ffe5b08149681f51625bdac43c7ab41ccf5f9cc3 (patch) | |
tree | f19137f20c73613ed8d4e63a725e4a1d20a00ec2 /subprojects/frontend | |
parent | feat(frontend): overlay scrollbars for editor (diff) | |
download | refinery-ffe5b08149681f51625bdac43c7ab41ccf5f9cc3.tar.gz refinery-ffe5b08149681f51625bdac43c7ab41ccf5f9cc3.tar.zst refinery-ffe5b08149681f51625bdac43c7ab41ccf5f9cc3.zip |
feat(frontend): scrollbar annotations
Diffstat (limited to 'subprojects/frontend')
-rw-r--r-- | subprojects/frontend/src/editor/DiagnosticValue.ts | 20 | ||||
-rw-r--r-- | subprojects/frontend/src/editor/EditorStore.ts | 53 | ||||
-rw-r--r-- | subprojects/frontend/src/editor/EditorTheme.ts | 26 | ||||
-rw-r--r-- | subprojects/frontend/src/editor/scrollbarViewPlugin.ts | 106 | ||||
-rw-r--r-- | subprojects/frontend/src/theme/ThemeProvider.tsx | 3 |
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 @@ | |||
1 | import type { Diagnostic } from '@codemirror/lint'; | ||
2 | import { RangeValue } from '@codemirror/state'; | ||
3 | |||
4 | export 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'; |
15 | import { type Command, EditorView } from '@codemirror/view'; | 16 | import { type Command, EditorView } from '@codemirror/view'; |
16 | import { makeAutoObservable, observable } from 'mobx'; | 17 | import { makeAutoObservable, observable } from 'mobx'; |
@@ -20,6 +21,7 @@ import type PWAStore from '../PWAStore'; | |||
20 | import getLogger from '../utils/getLogger'; | 21 | import getLogger from '../utils/getLogger'; |
21 | import XtextClient from '../xtext/XtextClient'; | 22 | import XtextClient from '../xtext/XtextClient'; |
22 | 23 | ||
24 | import DiagnosticValue from './DiagnosticValue'; | ||
23 | import LintPanelStore from './LintPanelStore'; | 25 | import LintPanelStore from './LintPanelStore'; |
24 | import SearchPanelStore from './SearchPanelStore'; | 26 | import SearchPanelStore from './SearchPanelStore'; |
25 | import createEditorState from './createEditorState'; | 27 | import 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'; | |||
2 | import { reaction } from 'mobx'; | 2 | import { reaction } from 'mobx'; |
3 | 3 | ||
4 | import type EditorStore from './EditorStore'; | 4 | import type EditorStore from './EditorStore'; |
5 | import findOccurrences from './findOccurrences'; | ||
5 | 6 | ||
6 | export const HOLDER_CLASS = 'cm-scroller-holder'; | 7 | export const HOLDER_CLASS = 'cm-scroller-holder'; |
7 | export const THUMB_CLASS = 'cm-scroller-thumb'; | 8 | export const THUMB_CLASS = 'cm-scroller-thumb'; |
@@ -10,8 +11,13 @@ export const THUMB_X_CLASS = 'cm-scroller-thumb-x'; | |||
10 | export const THUMB_ACTIVE_CLASS = 'active'; | 11 | export const THUMB_ACTIVE_CLASS = 'active'; |
11 | export const GUTTER_DECORATION_CLASS = 'cm-scroller-gutter-decoration'; | 12 | export const GUTTER_DECORATION_CLASS = 'cm-scroller-gutter-decoration'; |
12 | export const TOP_DECORATION_CLASS = 'cm-scroller-top-decoration'; | 13 | export const TOP_DECORATION_CLASS = 'cm-scroller-top-decoration'; |
14 | export const ANNOTATION_SELECTION_CLASS = 'cm-scroller-selection'; | ||
15 | export const ANNOTATION_DIAGNOSTIC_CLASS = 'cm-scroller-diagnostic'; | ||
16 | export const ANNOTATION_OCCURRENCE_CLASS = 'cm-scroller-occurrence'; | ||
13 | export const SHADOW_WIDTH = 10; | 17 | export const SHADOW_WIDTH = 10; |
14 | export const SCROLLBAR_WIDTH = 12; | 18 | export const SCROLLBAR_WIDTH = 12; |
19 | export const ANNOTATION_WIDTH = SCROLLBAR_WIDTH / 2; | ||
20 | export const MIN_ANNOTATION_HEIGHT = 1; | ||
15 | 21 | ||
16 | export default function scrollbarViewPlugin( | 22 | export 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 | ||
24 | interface HighlightPalette { | 24 | interface 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', |