aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorLibravatar Kristóf Marussy <kristof@marussy.com>2022-10-31 19:15:21 -0400
committerLibravatar Kristóf Marussy <kristof@marussy.com>2022-11-05 19:41:17 +0100
commit216dea1a36d1c05108ac5cfcec84a7808ecf1d6e (patch)
tree338e11bc8f90f270899f40f8217a70a040375d7c
parentrefactor(frontend): editor theme improvements (diff)
downloadrefinery-216dea1a36d1c05108ac5cfcec84a7808ecf1d6e.tar.gz
refinery-216dea1a36d1c05108ac5cfcec84a7808ecf1d6e.tar.zst
refinery-216dea1a36d1c05108ac5cfcec84a7808ecf1d6e.zip
feat(frontend): overlay scrollbars for editor
-rw-r--r--subprojects/frontend/src/editor/EditorArea.tsx22
-rw-r--r--subprojects/frontend/src/editor/EditorAreaDecorations.tsx148
-rw-r--r--subprojects/frontend/src/editor/EditorTheme.ts99
-rw-r--r--subprojects/frontend/src/editor/createEditorState.ts2
-rw-r--r--subprojects/frontend/src/editor/scrollbarViewPlugin.ts177
5 files changed, 272 insertions, 176 deletions
diff --git a/subprojects/frontend/src/editor/EditorArea.tsx b/subprojects/frontend/src/editor/EditorArea.tsx
index e6227672..95f0f92e 100644
--- a/subprojects/frontend/src/editor/EditorArea.tsx
+++ b/subprojects/frontend/src/editor/EditorArea.tsx
@@ -1,9 +1,8 @@
1import Box from '@mui/material/Box'; 1import Box from '@mui/material/Box';
2import { useTheme } from '@mui/material/styles'; 2import { useTheme } from '@mui/material/styles';
3import { observer } from 'mobx-react-lite'; 3import { observer } from 'mobx-react-lite';
4import React, { useCallback, useEffect, useState } from 'react'; 4import React, { useCallback, useEffect } from 'react';
5 5
6import EditorAreaDecorations from './EditorAreaDecorations';
7import type EditorStore from './EditorStore'; 6import type EditorStore from './EditorStore';
8import EditorTheme from './EditorTheme'; 7import EditorTheme from './EditorTheme';
9 8
@@ -15,41 +14,26 @@ export default observer(function EditorArea({
15 const { 14 const {
16 palette: { mode: paletteMode }, 15 palette: { mode: paletteMode },
17 } = useTheme(); 16 } = useTheme();
18 const [parent, setParent] = useState<HTMLElement | undefined>();
19 const [scroller, setScroller] = useState<HTMLElement | undefined>();
20 17
21 useEffect( 18 useEffect(
22 () => editorStore.setDarkMode(paletteMode === 'dark'), 19 () => editorStore.setDarkMode(paletteMode === 'dark'),
23 [editorStore, paletteMode], 20 [editorStore, paletteMode],
24 ); 21 );
25 22
26 const parentRef = useCallback(
27 (value: HTMLElement | null) => setParent(value ?? undefined),
28 [],
29 );
30
31 const editorParentRef = useCallback( 23 const editorParentRef = useCallback(
32 (editorParent: HTMLDivElement | null) => { 24 (editorParent: HTMLDivElement | null) => {
33 editorStore.setEditorParent(editorParent ?? undefined); 25 editorStore.setEditorParent(editorParent ?? undefined);
34 setScroller(editorStore.view?.scrollDOM);
35 }, 26 },
36 [editorStore, setScroller], 27 [editorStore],
37 ); 28 );
38 29
39 return ( 30 return (
40 <Box 31 <Box flexGrow={1} flexShrink={1} overflow="auto">
41 ref={parentRef}
42 position="relative"
43 flexGrow={1}
44 flexShrink={1}
45 overflow="auto"
46 >
47 <EditorTheme 32 <EditorTheme
48 showLineNumbers={editorStore.showLineNumbers} 33 showLineNumbers={editorStore.showLineNumbers}
49 showActiveLine={!editorStore.hasSelection} 34 showActiveLine={!editorStore.hasSelection}
50 ref={editorParentRef} 35 ref={editorParentRef}
51 /> 36 />
52 <EditorAreaDecorations parent={parent} scroller={scroller} />
53 </Box> 37 </Box>
54 ); 38 );
55}); 39});
diff --git a/subprojects/frontend/src/editor/EditorAreaDecorations.tsx b/subprojects/frontend/src/editor/EditorAreaDecorations.tsx
deleted file mode 100644
index e00cc290..00000000
--- a/subprojects/frontend/src/editor/EditorAreaDecorations.tsx
+++ /dev/null
@@ -1,148 +0,0 @@
1import { type CSSObject, type Theme, styled } from '@mui/material/styles';
2import React, { memo, useEffect, useState } from 'react';
3
4const SHADOW_SIZE = 10;
5
6const EditorAreaDecoration = styled('div')({
7 position: 'absolute',
8 pointerEvents: 'none',
9});
10
11function shadowTheme(
12 origin: string,
13 scaleX: boolean,
14 scaleY: boolean,
15): CSSObject {
16 function radialGradient(opacity: number, scale: string): string {
17 return `radial-gradient(
18 farthest-side at ${origin},
19 rgba(0, 0, 0, ${opacity}),
20 rgba(0, 0, 0, 0)
21 )
22 ${origin} /
23 ${scaleX ? scale : '100%'}
24 ${scaleY ? scale : '100%'}
25 no-repeat`;
26 }
27
28 return {
29 background: `
30 ${radialGradient(0.2, '40%')},
31 ${radialGradient(0.14, '50%')},
32 ${radialGradient(0.12, '100%')}
33 `,
34 };
35}
36
37function animateSize(
38 theme: Theme,
39 direction: 'height' | 'width',
40 opacity: number,
41): CSSObject {
42 return {
43 [direction]: opacity * SHADOW_SIZE,
44 transition: theme.transitions.create(direction, {
45 duration: theme.transitions.duration.shortest,
46 }),
47 };
48}
49
50const TopDecoration = memo(
51 styled(EditorAreaDecoration, {
52 shouldForwardProp: (prop) => prop !== 'visible' && prop !== 'opacity',
53 })<{
54 visible: boolean;
55 opacity: number;
56 }>(({ theme, visible, opacity }) => ({
57 display: visible ? 'block' : 'none',
58 top: 0,
59 left: 0,
60 right: 0,
61 ...shadowTheme('50% 0', false, true),
62 ...animateSize(theme, 'height', opacity),
63 })),
64);
65
66const GutterDecoration = memo(
67 styled(EditorAreaDecoration, {
68 shouldForwardProp: (prop) =>
69 prop !== 'top' &&
70 prop !== 'bottom' &&
71 prop !== 'guttersWidth' &&
72 prop !== 'opacity',
73 })<{
74 top: number;
75 bottom: number;
76 guttersWidth: number;
77 opacity: number;
78 }>(({ theme, top, bottom, guttersWidth, opacity }) => ({
79 top,
80 left: guttersWidth,
81 bottom,
82 ...shadowTheme('0 50%', true, false),
83 ...animateSize(theme, 'width', opacity),
84 })),
85);
86
87function convertToOpacity(scroll: number): number {
88 return Math.max(0, Math.min(1, scroll / SHADOW_SIZE));
89}
90
91export default function EditorAreaDecorations({
92 parent,
93 scroller,
94}: {
95 parent: HTMLElement | undefined;
96 scroller: HTMLElement | undefined;
97}): JSX.Element {
98 const [top, setTop] = useState(0);
99 const [bottom, setBottom] = useState(0);
100 const [guttersWidth, setGuttersWidth] = useState(0);
101 const [topOpacity, setTopOpacity] = useState(0);
102 const [gutterOpacity, setGutterOpacity] = useState(0);
103
104 useEffect(() => {
105 if (parent === undefined || scroller === undefined) {
106 return () => {};
107 }
108 const gutters = scroller.querySelector('.cm-gutters');
109
110 const updateBounds = () => {
111 const parentRect = parent.getBoundingClientRect();
112 const rect = scroller.getBoundingClientRect();
113 setTop(rect.top - parentRect.top);
114 setBottom(parentRect.bottom - rect.bottom);
115 setGuttersWidth(gutters?.clientWidth ?? 0);
116 };
117 updateBounds();
118 const resizeObserver = new ResizeObserver(updateBounds);
119 resizeObserver.observe(scroller);
120 if (gutters !== null) {
121 resizeObserver.observe(gutters);
122 }
123
124 const updateScroll = () => {
125 setTopOpacity(convertToOpacity(scroller.scrollTop));
126 setGutterOpacity(convertToOpacity(scroller.scrollLeft));
127 };
128 updateScroll();
129 scroller.addEventListener('scroll', updateScroll);
130
131 return () => {
132 resizeObserver.disconnect();
133 scroller.removeEventListener('scroll', updateScroll);
134 };
135 }, [parent, scroller, setTop]);
136
137 return (
138 <>
139 <GutterDecoration
140 top={top}
141 bottom={bottom}
142 guttersWidth={guttersWidth}
143 opacity={gutterOpacity}
144 />
145 <TopDecoration visible={top === 0} opacity={topOpacity} />
146 </>
147 );
148}
diff --git a/subprojects/frontend/src/editor/EditorTheme.ts b/subprojects/frontend/src/editor/EditorTheme.ts
index 325f8d18..829b709f 100644
--- a/subprojects/frontend/src/editor/EditorTheme.ts
+++ b/subprojects/frontend/src/editor/EditorTheme.ts
@@ -4,15 +4,36 @@ import infoSVG from '@material-icons/svg/svg/info/baseline.svg?raw';
4import warningSVG from '@material-icons/svg/svg/warning/baseline.svg?raw'; 4import warningSVG from '@material-icons/svg/svg/warning/baseline.svg?raw';
5import { alpha, styled, type CSSObject } from '@mui/material/styles'; 5import { alpha, styled, type CSSObject } from '@mui/material/styles';
6 6
7import {
8 INDENTATION_MARKER_ACTIVE_CLASS,
9 INDENTATION_MARKER_CLASS,
10} from './indentationMarkerViewPlugin';
11
12function svgURL(svg: string): string { 7function svgURL(svg: string): string {
13 return `url('data:image/svg+xml;utf8,${svg}')`; 8 return `url('data:image/svg+xml;utf8,${svg}')`;
14} 9}
15 10
11function radialShadowTheme(
12 origin: string,
13 scaleX: boolean,
14 scaleY: boolean,
15): CSSObject {
16 function radialGradient(opacity: number, scale: string): string {
17 return `radial-gradient(
18 farthest-side at ${origin},
19 rgba(0, 0, 0, ${opacity}),
20 rgba(0, 0, 0, 0)
21 )
22 ${origin} /
23 ${scaleX ? scale : '100%'}
24 ${scaleY ? scale : '100%'}
25 no-repeat`;
26 }
27
28 return {
29 background: `
30 ${radialGradient(0.2, '40%')},
31 ${radialGradient(0.14, '50%')},
32 ${radialGradient(0.12, '100%')}
33 `,
34 };
35}
36
16export default styled('div', { 37export default styled('div', {
17 name: 'EditorTheme', 38 name: 'EditorTheme',
18 shouldForwardProp: (propName) => 39 shouldForwardProp: (propName) =>
@@ -36,8 +57,68 @@ export default styled('div', {
36 '&, .cm-editor': { 57 '&, .cm-editor': {
37 height: '100%', 58 height: '100%',
38 }, 59 },
60 '.cm-scroller-holder': {
61 display: 'flex',
62 position: 'relative',
63 flexDirection: 'column',
64 overflow: 'hidden',
65 flex: '1 1',
66 },
39 '.cm-scroller': { 67 '.cm-scroller': {
40 color: theme.palette.text.secondary, 68 color: theme.palette.text.secondary,
69 scrollbarWidth: 'none',
70 MsOverflowStyle: 'none',
71 '&::-webkit-scrollbar': {
72 width: 0,
73 height: 0,
74 background: 'transparent',
75 },
76 },
77 '.cm-scroller-thumb': {
78 position: 'absolute',
79 background: theme.palette.text.secondary,
80 opacity: theme.palette.mode === 'dark' ? 0.16 : 0.28,
81 transition: theme.transitions.create('opacity', {
82 duration: theme.transitions.duration.shortest,
83 }),
84 '&:hover': {
85 opacity: 0.75,
86 },
87 '&.active': {
88 opacity: 1,
89 pointerEvents: 'none',
90 userSelect: 'none',
91 },
92 },
93 '.cm-scroller-thumb-y': {
94 top: 0,
95 right: 0,
96 },
97 '.cm-scroller-thumb-x': {
98 left: 0,
99 bottom: 0,
100 },
101 '.cm-scroller-gutter-decoration': {
102 position: 'absolute',
103 top: 0,
104 bottom: 0,
105 left: 0,
106 width: 0,
107 transition: theme.transitions.create('width', {
108 duration: theme.transitions.duration.shortest,
109 }),
110 ...radialShadowTheme('0 50%', true, false),
111 },
112 '.cm-scroller-top-decoration': {
113 position: 'absolute',
114 top: 0,
115 left: 0,
116 right: 0,
117 height: 0,
118 transition: theme.transitions.create('height', {
119 duration: theme.transitions.duration.shortest,
120 }),
121 ...radialShadowTheme('50% 0', false, true),
41 }, 122 },
42 '.cm-gutters': { 123 '.cm-gutters': {
43 background: theme.palette.background.default, 124 background: theme.palette.background.default,
@@ -157,12 +238,12 @@ export default styled('div', {
157 '.cm-searchMatch-selected': { 238 '.cm-searchMatch-selected': {
158 background: theme.palette.highlight.search.selected, 239 background: theme.palette.highlight.search.selected,
159 }, 240 },
160 [`.${INDENTATION_MARKER_CLASS}`]: { 241 '.cm-indentation-marker': {
161 display: 'inline-block', 242 display: 'inline-block',
162 boxShadow: `1px 0 0 ${theme.palette.highlight.lineNumber} inset`, 243 boxShadow: `1px 0 0 ${theme.palette.highlight.lineNumber} inset`,
163 }, 244 '&.active': {
164 [`.${INDENTATION_MARKER_CLASS}.${INDENTATION_MARKER_ACTIVE_CLASS}`]: { 245 boxShadow: `1px 0 0 ${theme.palette.text.primary} inset`,
165 boxShadow: `1px 0 0 ${theme.palette.text.primary} inset`, 246 },
166 }, 247 },
167 }; 248 };
168 249
diff --git a/subprojects/frontend/src/editor/createEditorState.ts b/subprojects/frontend/src/editor/createEditorState.ts
index 079e8a47..05028fcc 100644
--- a/subprojects/frontend/src/editor/createEditorState.ts
+++ b/subprojects/frontend/src/editor/createEditorState.ts
@@ -38,6 +38,7 @@ import type EditorStore from './EditorStore';
38import SearchPanel from './SearchPanel'; 38import SearchPanel from './SearchPanel';
39import findOccurrences from './findOccurrences'; 39import findOccurrences from './findOccurrences';
40import indentationMarkerViewPlugin from './indentationMarkerViewPlugin'; 40import indentationMarkerViewPlugin from './indentationMarkerViewPlugin';
41import scrollbarViewPlugin from './scrollbarViewPlugin';
41import semanticHighlighting from './semanticHighlighting'; 42import semanticHighlighting from './semanticHighlighting';
42 43
43export default function createEditorState( 44export default function createEditorState(
@@ -120,6 +121,7 @@ export default function createEditorState(
120 ...defaultKeymap, 121 ...defaultKeymap,
121 ]), 122 ]),
122 problemLanguageSupport(), 123 problemLanguageSupport(),
124 scrollbarViewPlugin(store),
123 ], 125 ],
124 }); 126 });
125} 127}
diff --git a/subprojects/frontend/src/editor/scrollbarViewPlugin.ts b/subprojects/frontend/src/editor/scrollbarViewPlugin.ts
new file mode 100644
index 00000000..2882f02e
--- /dev/null
+++ b/subprojects/frontend/src/editor/scrollbarViewPlugin.ts
@@ -0,0 +1,177 @@
1import { type PluginValue, ViewPlugin } from '@codemirror/view';
2import { reaction } from 'mobx';
3
4import type EditorStore from './EditorStore';
5
6export const HOLDER_CLASS = 'cm-scroller-holder';
7export const THUMB_CLASS = 'cm-scroller-thumb';
8export const THUMB_Y_CLASS = 'cm-scroller-thumb-y';
9export const THUMB_X_CLASS = 'cm-scroller-thumb-x';
10export const THUMB_ACTIVE_CLASS = 'active';
11export const GUTTER_DECORATION_CLASS = 'cm-scroller-gutter-decoration';
12export const TOP_DECORATION_CLASS = 'cm-scroller-top-decoration';
13export const SHADOW_WIDTH = 10;
14export const SCROLLBAR_WIDTH = 12;
15
16export default function scrollbarViewPlugin(
17 editorStore: EditorStore,
18): ViewPlugin<PluginValue> {
19 return ViewPlugin.define((view) => {
20 const { scrollDOM } = view;
21 const { ownerDocument, parentElement: parentDOM } = scrollDOM;
22 if (parentDOM === null) {
23 return {};
24 }
25
26 const holder = ownerDocument.createElement('div');
27 holder.className = HOLDER_CLASS;
28 parentDOM.replaceChild(holder, scrollDOM);
29 holder.appendChild(scrollDOM);
30
31 let factorY = 1;
32 let factorX = 1;
33
34 const thumbY = ownerDocument.createElement('div');
35 thumbY.className = `${THUMB_CLASS} ${THUMB_Y_CLASS}`;
36 const scrollY = (event: MouseEvent) => {
37 scrollDOM.scrollBy({ top: event.movementY / factorY });
38 event.preventDefault();
39 };
40 const stopScrollY = () => {
41 thumbY.classList.remove(THUMB_ACTIVE_CLASS);
42 window.removeEventListener('mousemove', scrollY);
43 window.removeEventListener('mouseup', stopScrollY);
44 };
45 thumbY.addEventListener(
46 'mousedown',
47 () => {
48 thumbY.classList.add(THUMB_ACTIVE_CLASS);
49 window.addEventListener('mousemove', scrollY);
50 window.addEventListener('mouseup', stopScrollY, { passive: true });
51 },
52 { passive: true },
53 );
54 holder.appendChild(thumbY);
55
56 const thumbX = ownerDocument.createElement('div');
57 thumbX.className = `${THUMB_CLASS} ${THUMB_X_CLASS}`;
58 const scrollX = (event: MouseEvent) => {
59 scrollDOM.scrollBy({ left: event.movementX / factorX });
60 };
61 const stopScrollX = () => {
62 thumbX.classList.remove(THUMB_ACTIVE_CLASS);
63 window.removeEventListener('mousemove', scrollX);
64 window.removeEventListener('mouseup', stopScrollX);
65 };
66 thumbX.addEventListener(
67 'mousedown',
68 () => {
69 thumbX.classList.add(THUMB_ACTIVE_CLASS);
70 window.addEventListener('mousemove', scrollX);
71 window.addEventListener('mouseup', stopScrollX, { passive: true });
72 },
73 { passive: true },
74 );
75 holder.appendChild(thumbX);
76
77 const gutterDecoration = ownerDocument.createElement('div');
78 gutterDecoration.className = GUTTER_DECORATION_CLASS;
79 holder.appendChild(gutterDecoration);
80
81 const topDecoration = ownerDocument.createElement('div');
82 topDecoration.className = TOP_DECORATION_CLASS;
83 holder.appendChild(topDecoration);
84
85 const disposePanelReaction = reaction(
86 () => editorStore.searchPanel.state,
87 (panelOpen) => {
88 topDecoration.style.display = panelOpen ? 'none' : 'block';
89 },
90 { fireImmediately: true },
91 );
92
93 let observer: ResizeObserver | undefined;
94 let gutters: Element | undefined;
95
96 let requested = false;
97
98 function update() {
99 requested = false;
100
101 if (gutters === undefined) {
102 gutters = scrollDOM.querySelector('.cm-gutters') ?? undefined;
103 if (gutters !== undefined && observer !== undefined) {
104 observer.observe(gutters);
105 }
106 }
107
108 const { height: scrollerHeight, width: scrollerWidth } =
109 scrollDOM.getBoundingClientRect();
110 const { scrollTop, scrollHeight, scrollLeft, scrollWidth } = scrollDOM;
111 const gutterWidth = gutters?.clientWidth ?? 0;
112 let trackYHeight = scrollerHeight;
113
114 if (scrollWidth > scrollerWidth) {
115 // Leave space for horizontal scrollbar.
116 trackYHeight -= SCROLLBAR_WIDTH;
117 // Alwalys leave space for annotation in the vertical scrollbar.
118 const trackXWidth = scrollerWidth - gutterWidth - SCROLLBAR_WIDTH;
119 const thumbWidth = trackXWidth * (scrollerWidth / scrollWidth);
120 factorX = (trackXWidth - thumbWidth) / (scrollWidth - scrollerWidth);
121 thumbX.style.display = 'block';
122 thumbX.style.height = `${SCROLLBAR_WIDTH}px`;
123 thumbX.style.width = `${thumbWidth}px`;
124 thumbX.style.left = `${gutterWidth + scrollLeft * factorX}px`;
125 } else {
126 thumbX.style.display = 'none';
127 }
128
129 if (scrollHeight > scrollerHeight) {
130 const thumbHeight = trackYHeight * (scrollerHeight / scrollHeight);
131 factorY =
132 (trackYHeight - thumbHeight) / (scrollHeight - scrollerHeight);
133 thumbY.style.display = 'block';
134 thumbY.style.height = `${thumbHeight}px`;
135 thumbY.style.width = `${SCROLLBAR_WIDTH}px`;
136 thumbY.style.top = `${scrollTop * factorY}px`;
137 } else {
138 thumbY.style.display = 'none';
139 }
140
141 gutterDecoration.style.left = `${gutterWidth}px`;
142 gutterDecoration.style.width = `${Math.max(
143 0,
144 Math.min(scrollLeft, SHADOW_WIDTH),
145 )}px`;
146
147 topDecoration.style.height = `${Math.max(
148 0,
149 Math.min(scrollTop, SHADOW_WIDTH),
150 )}px`;
151 }
152
153 function requestUpdate() {
154 if (!requested) {
155 requested = true;
156 view.requestMeasure({ read: update });
157 }
158 }
159
160 observer = new ResizeObserver(requestUpdate);
161 observer.observe(scrollDOM);
162
163 scrollDOM.addEventListener('scroll', requestUpdate);
164
165 requestUpdate();
166
167 return {
168 update: requestUpdate,
169 destroy() {
170 disposePanelReaction();
171 observer?.disconnect();
172 scrollDOM.removeEventListener('scroll', requestUpdate);
173 parentDOM.replaceChild(scrollDOM, holder);
174 },
175 };
176 });
177}