aboutsummaryrefslogtreecommitdiffstats
path: root/subprojects/frontend/src/editor/scrollbarViewPlugin.ts
diff options
context:
space:
mode:
Diffstat (limited to 'subprojects/frontend/src/editor/scrollbarViewPlugin.ts')
-rw-r--r--subprojects/frontend/src/editor/scrollbarViewPlugin.ts363
1 files changed, 0 insertions, 363 deletions
diff --git a/subprojects/frontend/src/editor/scrollbarViewPlugin.ts b/subprojects/frontend/src/editor/scrollbarViewPlugin.ts
deleted file mode 100644
index 878d369d..00000000
--- a/subprojects/frontend/src/editor/scrollbarViewPlugin.ts
+++ /dev/null
@@ -1,363 +0,0 @@
1/*
2 * SPDX-FileCopyrightText: 2021-2023 The Refinery Authors <https://refinery.tools/>
3 *
4 * SPDX-License-Identifier: EPL-2.0
5 */
6
7import { EditorSelection } from '@codemirror/state';
8import {
9 type EditorView,
10 type PluginValue,
11 ViewPlugin,
12} from '@codemirror/view';
13import { reaction } from 'mobx';
14
15import type EditorStore from './EditorStore';
16import { getDiagnostics } from './exposeDiagnostics';
17import findOccurrences from './findOccurrences';
18
19export const HOLDER_CLASS = 'cm-scroller-holder';
20export const SPACER_CLASS = 'cm-scroller-spacer';
21export const TRACK_CLASS = 'cm-scroller-track';
22export const THUMB_CLASS = 'cm-scroller-thumb';
23export const THUMB_ACTIVE_CLASS = 'active';
24export const GUTTER_DECORATION_CLASS = 'cm-scroller-gutter-decoration';
25export const TOP_DECORATION_CLASS = 'cm-scroller-top-decoration';
26export const ANNOTATION_SELECTION_CLASS = 'cm-scroller-selection';
27export const ANNOTATION_DIAGNOSTIC_CLASS = 'cm-scroller-diagnostic';
28export const ANNOTATION_OCCURRENCE_CLASS = 'cm-scroller-occurrence';
29export const SHADOW_WIDTH = 10;
30export const SCROLLBAR_WIDTH = 12;
31export const ANNOTATION_WIDTH = SCROLLBAR_WIDTH / 2;
32export const MIN_ANNOTATION_HEIGHT = 1;
33
34function createScrollbar(
35 holder: HTMLElement,
36 direction: 'x' | 'y',
37 touchCallback: (offsetX: number, offsetY: number) => void,
38 moveCallback: (movementX: number, movementY: number) => void,
39): { track: HTMLElement; thumb: HTMLElement } {
40 const track = holder.ownerDocument.createElement('div');
41 track.className = `${TRACK_CLASS} ${TRACK_CLASS}-${direction}`;
42 holder.appendChild(track);
43
44 const thumb = holder.ownerDocument.createElement('div');
45 thumb.className = `${THUMB_CLASS} ${THUMB_CLASS}-${direction}`;
46 track.appendChild(thumb);
47
48 let pointerId: number | undefined;
49 track.addEventListener('pointerdown', (event) => {
50 if (pointerId !== undefined) {
51 event.preventDefault();
52 return;
53 }
54 ({ pointerId } = event);
55 thumb.classList.add(THUMB_ACTIVE_CLASS);
56 if (event.target === thumb) {
57 // Prevent implicit pointer capture on mobile.
58 thumb.releasePointerCapture(pointerId);
59 } else {
60 touchCallback(event.offsetX, event.offsetY);
61 }
62 track.setPointerCapture(pointerId);
63 });
64
65 track.addEventListener('pointermove', (event) => {
66 if (event.pointerId !== pointerId) {
67 return;
68 }
69 moveCallback(event.movementX, event.movementY);
70 event.preventDefault();
71 });
72
73 function scrollEnd(event: PointerEvent) {
74 if (event.pointerId !== pointerId) {
75 return;
76 }
77 pointerId = undefined;
78 thumb.classList.remove(THUMB_ACTIVE_CLASS);
79 }
80
81 track.addEventListener('pointerup', scrollEnd, { passive: true });
82 track.addEventListener('pointercancel', scrollEnd, { passive: true });
83
84 return { track, thumb };
85}
86
87function rebuildAnnotations(
88 view: EditorView,
89 scrollHeight: number,
90 trackYHeight: number,
91 holder: HTMLElement,
92 annotations: HTMLDivElement[],
93) {
94 const { state } = view;
95 const overlayAnnotationsHeight =
96 (view.contentHeight / scrollHeight) * trackYHeight;
97 const lineHeight = overlayAnnotationsHeight / state.doc.lines;
98
99 let i = 0;
100
101 function getOrCreateAnnotation(from: number, to?: number): HTMLDivElement {
102 const startLine = state.doc.lineAt(from).number;
103 const endLine = to === undefined ? startLine : state.doc.lineAt(to).number;
104 const top = (startLine - 1) * lineHeight;
105 const height = Math.max(
106 MIN_ANNOTATION_HEIGHT,
107 Math.max(1, endLine - startLine) * lineHeight,
108 );
109
110 let annotation: HTMLDivElement | undefined;
111 if (i < annotations.length) {
112 annotation = annotations[i];
113 }
114 if (annotation === undefined) {
115 annotation = holder.ownerDocument.createElement('div');
116 annotations.push(annotation);
117 holder.appendChild(annotation);
118 }
119 i += 1;
120
121 annotation.style.top = `${top}px`;
122 annotation.style.height = `${height}px`;
123
124 return annotation;
125 }
126
127 state.selection.ranges.forEach(({ head }) => {
128 const selectionAnnotation = getOrCreateAnnotation(head);
129 selectionAnnotation.className = ANNOTATION_SELECTION_CLASS;
130 selectionAnnotation.style.width = `${SCROLLBAR_WIDTH}px`;
131 });
132
133 const diagnosticsIter = getDiagnostics(state).iter();
134 while (diagnosticsIter.value !== null) {
135 const diagnosticAnnotation = getOrCreateAnnotation(
136 diagnosticsIter.from,
137 diagnosticsIter.to,
138 );
139 diagnosticAnnotation.className = `${ANNOTATION_DIAGNOSTIC_CLASS} ${ANNOTATION_DIAGNOSTIC_CLASS}-${diagnosticsIter.value.severity}`;
140 diagnosticAnnotation.style.width = `${ANNOTATION_WIDTH}px`;
141 diagnosticsIter.next();
142 }
143
144 const occurrences = view.state.field(findOccurrences);
145 const occurrencesIter = occurrences.iter();
146 while (occurrencesIter.value !== null) {
147 const occurrenceAnnotation = getOrCreateAnnotation(
148 occurrencesIter.from,
149 occurrencesIter.to,
150 );
151 occurrenceAnnotation.className = ANNOTATION_OCCURRENCE_CLASS;
152 occurrenceAnnotation.style.width = `${ANNOTATION_WIDTH}px`;
153 occurrenceAnnotation.style.right = `${ANNOTATION_WIDTH}px`;
154 occurrencesIter.next();
155 }
156
157 annotations
158 .splice(i)
159 .forEach((staleAnnotation) => holder.removeChild(staleAnnotation));
160}
161
162export default function scrollbarViewPlugin(
163 editorStore: EditorStore,
164): ViewPlugin<PluginValue> {
165 return ViewPlugin.define((view) => {
166 const { scrollDOM } = view;
167 const { ownerDocument, parentElement: parentDOM } = scrollDOM;
168 if (parentDOM === null) {
169 return {};
170 }
171
172 const holder = ownerDocument.createElement('div');
173 holder.className = HOLDER_CLASS;
174 parentDOM.replaceChild(holder, scrollDOM);
175 holder.appendChild(scrollDOM);
176
177 const spacer = ownerDocument.createElement('div');
178 spacer.className = SPACER_CLASS;
179 scrollDOM.insertBefore(spacer, scrollDOM.firstChild);
180
181 let gutterWidth = 0;
182
183 scrollDOM.addEventListener('click', (event) => {
184 const scrollX = scrollDOM.scrollLeft + event.offsetX;
185 const scrollY = scrollDOM.scrollTop + event.offsetY;
186 if (scrollX > gutterWidth && scrollY > view.contentHeight) {
187 event.preventDefault();
188 view.focus();
189 editorStore.dispatch({
190 scrollIntoView: true,
191 selection: EditorSelection.create([
192 EditorSelection.cursor(view.state.doc.length),
193 ]),
194 });
195 }
196 });
197
198 let factorY = 1;
199 let factorX = 1;
200
201 const { track: trackY, thumb: thumbY } = createScrollbar(
202 holder,
203 'y',
204 (_offsetX, offsetY) => {
205 const scaledOffset = offsetY / factorY;
206 const { height: scrollerHeight } = scrollDOM.getBoundingClientRect();
207 const target = Math.max(0, scaledOffset - scrollerHeight / 2);
208 scrollDOM.scrollTo({ top: target });
209 },
210 (_movementX, movementY) => {
211 scrollDOM.scrollBy({ top: movementY / factorY });
212 },
213 );
214
215 const { track: trackX, thumb: thumbX } = createScrollbar(
216 holder,
217 'x',
218 (offsetX) => {
219 const scaledOffset = offsetX / factorX;
220 const { width: scrollerWidth } = scrollDOM.getBoundingClientRect();
221 const target = Math.max(0, scaledOffset - scrollerWidth / 2);
222 scrollDOM.scrollTo({ left: target });
223 },
224 (movementX) => {
225 scrollDOM.scrollBy({ left: movementX / factorX });
226 },
227 );
228
229 const gutterDecoration = ownerDocument.createElement('div');
230 gutterDecoration.className = GUTTER_DECORATION_CLASS;
231 holder.appendChild(gutterDecoration);
232
233 const topDecoration = ownerDocument.createElement('div');
234 topDecoration.className = TOP_DECORATION_CLASS;
235 holder.appendChild(topDecoration);
236
237 const disposePanelReaction = reaction(
238 () => editorStore.searchPanel.state,
239 (panelOpen) => {
240 topDecoration.style.display = panelOpen ? 'none' : 'block';
241 },
242 { fireImmediately: true },
243 );
244
245 let gutters: Element | undefined;
246
247 let firstRun = true;
248 let firstRunTimeout: number | undefined;
249 let requested = false;
250 let rebuildRequested = false;
251
252 const annotations: HTMLDivElement[] = [];
253
254 let observer: ResizeObserver | undefined;
255
256 function update() {
257 requested = false;
258
259 if (gutters === undefined) {
260 gutters = scrollDOM.querySelector('.cm-gutters') ?? undefined;
261 if (gutters !== undefined && observer !== undefined) {
262 observer.observe(gutters);
263 }
264 }
265
266 const { height: scrollerHeight, width: scrollerWidth } =
267 scrollDOM.getBoundingClientRect();
268 const { scrollTop, scrollLeft, scrollWidth } = scrollDOM;
269 const scrollHeight =
270 view.contentHeight + scrollerHeight - view.defaultLineHeight;
271 if (firstRun) {
272 if (firstRunTimeout !== undefined) {
273 clearTimeout(firstRunTimeout);
274 }
275 firstRunTimeout = setTimeout(() => {
276 spacer.style.minHeight = `${scrollHeight}px`;
277 firstRun = false;
278 }, 0);
279 } else {
280 spacer.style.minHeight = `${scrollHeight}px`;
281 }
282 gutterWidth = gutters?.clientWidth ?? 0;
283 let trackYHeight = scrollerHeight;
284
285 // Prevent spurious horizontal scrollbar by rounding up to the nearest pixel.
286 if (scrollWidth > Math.ceil(scrollerWidth)) {
287 // Leave space for horizontal scrollbar.
288 trackYHeight -= SCROLLBAR_WIDTH;
289 // Alwalys leave space for annotation in the vertical scrollbar.
290 const trackXWidth = scrollerWidth - gutterWidth - SCROLLBAR_WIDTH;
291 const thumbWidth = trackXWidth * (scrollerWidth / scrollWidth);
292 factorX = (trackXWidth - thumbWidth) / (scrollWidth - scrollerWidth);
293 trackY.style.bottom = `${SCROLLBAR_WIDTH}px`;
294 trackX.style.display = 'block';
295 trackX.style.left = `${gutterWidth}px`;
296 thumbX.style.width = `${thumbWidth}px`;
297 thumbX.style.left = `${scrollLeft * factorX}px`;
298 scrollDOM.style.overflowX = 'scroll';
299 } else {
300 trackY.style.bottom = '0px';
301 trackX.style.display = 'none';
302 scrollDOM.style.overflowX = 'hidden';
303 }
304
305 const thumbHeight = trackYHeight * (scrollerHeight / scrollHeight);
306 factorY = (trackYHeight - thumbHeight) / (scrollHeight - scrollerHeight);
307 thumbY.style.display = 'block';
308 thumbY.style.height = `${thumbHeight}px`;
309 thumbY.style.top = `${scrollTop * factorY}px`;
310
311 gutterDecoration.style.left = `${gutterWidth}px`;
312 gutterDecoration.style.width = `${Math.max(
313 0,
314 Math.min(scrollLeft, SHADOW_WIDTH),
315 )}px`;
316
317 topDecoration.style.height = `${Math.max(
318 0,
319 Math.min(scrollTop, SHADOW_WIDTH),
320 )}px`;
321
322 if (rebuildRequested) {
323 rebuildAnnotations(
324 view,
325 scrollHeight,
326 trackYHeight,
327 holder,
328 annotations,
329 );
330 rebuildRequested = false;
331 }
332 }
333
334 function requestUpdate() {
335 if (!requested) {
336 requested = true;
337 view.requestMeasure({ read: update });
338 }
339 }
340
341 function requestRebuild() {
342 requestUpdate();
343 rebuildRequested = true;
344 }
345
346 observer = new ResizeObserver(requestRebuild);
347 observer.observe(holder);
348
349 scrollDOM.addEventListener('scroll', requestUpdate);
350
351 requestRebuild();
352
353 return {
354 update: requestRebuild,
355 destroy() {
356 disposePanelReaction();
357 observer?.disconnect();
358 scrollDOM.removeEventListener('scroll', requestUpdate);
359 parentDOM.replaceChild(holder, holder);
360 },
361 };
362 });
363}