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 /subprojects/frontend/src/editor/scrollbarViewPlugin.ts | |
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
Diffstat (limited to 'subprojects/frontend/src/editor/scrollbarViewPlugin.ts')
-rw-r--r-- | subprojects/frontend/src/editor/scrollbarViewPlugin.ts | 177 |
1 files changed, 177 insertions, 0 deletions
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 | } | ||