diff options
author | Kristóf Marussy <kristof@marussy.com> | 2022-10-31 19:15:21 -0400 |
---|---|---|
committer | Kristóf Marussy <kristof@marussy.com> | 2022-11-05 19:41:17 +0100 |
commit | 216dea1a36d1c05108ac5cfcec84a7808ecf1d6e (patch) | |
tree | 338e11bc8f90f270899f40f8217a70a040375d7c | |
parent | refactor(frontend): editor theme improvements (diff) | |
download | refinery-216dea1a36d1c05108ac5cfcec84a7808ecf1d6e.tar.gz refinery-216dea1a36d1c05108ac5cfcec84a7808ecf1d6e.tar.zst refinery-216dea1a36d1c05108ac5cfcec84a7808ecf1d6e.zip |
feat(frontend): overlay scrollbars for editor
-rw-r--r-- | subprojects/frontend/src/editor/EditorArea.tsx | 22 | ||||
-rw-r--r-- | subprojects/frontend/src/editor/EditorAreaDecorations.tsx | 148 | ||||
-rw-r--r-- | subprojects/frontend/src/editor/EditorTheme.ts | 99 | ||||
-rw-r--r-- | subprojects/frontend/src/editor/createEditorState.ts | 2 | ||||
-rw-r--r-- | subprojects/frontend/src/editor/scrollbarViewPlugin.ts | 177 |
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 @@ | |||
1 | import Box from '@mui/material/Box'; | 1 | import Box from '@mui/material/Box'; |
2 | import { useTheme } from '@mui/material/styles'; | 2 | import { useTheme } from '@mui/material/styles'; |
3 | import { observer } from 'mobx-react-lite'; | 3 | import { observer } from 'mobx-react-lite'; |
4 | import React, { useCallback, useEffect, useState } from 'react'; | 4 | import React, { useCallback, useEffect } from 'react'; |
5 | 5 | ||
6 | import EditorAreaDecorations from './EditorAreaDecorations'; | ||
7 | import type EditorStore from './EditorStore'; | 6 | import type EditorStore from './EditorStore'; |
8 | import EditorTheme from './EditorTheme'; | 7 | import 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 @@ | |||
1 | import { type CSSObject, type Theme, styled } from '@mui/material/styles'; | ||
2 | import React, { memo, useEffect, useState } from 'react'; | ||
3 | |||
4 | const SHADOW_SIZE = 10; | ||
5 | |||
6 | const EditorAreaDecoration = styled('div')({ | ||
7 | position: 'absolute', | ||
8 | pointerEvents: 'none', | ||
9 | }); | ||
10 | |||
11 | function 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 | |||
37 | function 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 | |||
50 | const 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 | |||
66 | const 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 | |||
87 | function convertToOpacity(scroll: number): number { | ||
88 | return Math.max(0, Math.min(1, scroll / SHADOW_SIZE)); | ||
89 | } | ||
90 | |||
91 | export 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'; | |||
4 | import warningSVG from '@material-icons/svg/svg/warning/baseline.svg?raw'; | 4 | import warningSVG from '@material-icons/svg/svg/warning/baseline.svg?raw'; |
5 | import { alpha, styled, type CSSObject } from '@mui/material/styles'; | 5 | import { alpha, styled, type CSSObject } from '@mui/material/styles'; |
6 | 6 | ||
7 | import { | ||
8 | INDENTATION_MARKER_ACTIVE_CLASS, | ||
9 | INDENTATION_MARKER_CLASS, | ||
10 | } from './indentationMarkerViewPlugin'; | ||
11 | |||
12 | function svgURL(svg: string): string { | 7 | function 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 | ||
11 | function 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 | |||
16 | export default styled('div', { | 37 | export 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'; | |||
38 | import SearchPanel from './SearchPanel'; | 38 | import SearchPanel from './SearchPanel'; |
39 | import findOccurrences from './findOccurrences'; | 39 | import findOccurrences from './findOccurrences'; |
40 | import indentationMarkerViewPlugin from './indentationMarkerViewPlugin'; | 40 | import indentationMarkerViewPlugin from './indentationMarkerViewPlugin'; |
41 | import scrollbarViewPlugin from './scrollbarViewPlugin'; | ||
41 | import semanticHighlighting from './semanticHighlighting'; | 42 | import semanticHighlighting from './semanticHighlighting'; |
42 | 43 | ||
43 | export default function createEditorState( | 44 | export 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 @@ | |||
1 | import { type PluginValue, ViewPlugin } from '@codemirror/view'; | ||
2 | import { reaction } from 'mobx'; | ||
3 | |||
4 | import type EditorStore from './EditorStore'; | ||
5 | |||
6 | export const HOLDER_CLASS = 'cm-scroller-holder'; | ||
7 | export const THUMB_CLASS = 'cm-scroller-thumb'; | ||
8 | export const THUMB_Y_CLASS = 'cm-scroller-thumb-y'; | ||
9 | export const THUMB_X_CLASS = 'cm-scroller-thumb-x'; | ||
10 | export const THUMB_ACTIVE_CLASS = 'active'; | ||
11 | export const GUTTER_DECORATION_CLASS = 'cm-scroller-gutter-decoration'; | ||
12 | export const TOP_DECORATION_CLASS = 'cm-scroller-top-decoration'; | ||
13 | export const SHADOW_WIDTH = 10; | ||
14 | export const SCROLLBAR_WIDTH = 12; | ||
15 | |||
16 | export 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 | } | ||