aboutsummaryrefslogtreecommitdiffstats
path: root/subprojects/frontend/src/editor/scrollbarViewPlugin.ts
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 /subprojects/frontend/src/editor/scrollbarViewPlugin.ts
parentrefactor(frontend): editor theme improvements (diff)
downloadrefinery-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.ts177
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 @@
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}