diff options
Diffstat (limited to 'subprojects/frontend/src/editor/scrollbarViewPlugin.ts')
-rw-r--r-- | subprojects/frontend/src/editor/scrollbarViewPlugin.ts | 363 |
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 | |||
7 | import { EditorSelection } from '@codemirror/state'; | ||
8 | import { | ||
9 | type EditorView, | ||
10 | type PluginValue, | ||
11 | ViewPlugin, | ||
12 | } from '@codemirror/view'; | ||
13 | import { reaction } from 'mobx'; | ||
14 | |||
15 | import type EditorStore from './EditorStore'; | ||
16 | import { getDiagnostics } from './exposeDiagnostics'; | ||
17 | import findOccurrences from './findOccurrences'; | ||
18 | |||
19 | export const HOLDER_CLASS = 'cm-scroller-holder'; | ||
20 | export const SPACER_CLASS = 'cm-scroller-spacer'; | ||
21 | export const TRACK_CLASS = 'cm-scroller-track'; | ||
22 | export const THUMB_CLASS = 'cm-scroller-thumb'; | ||
23 | export const THUMB_ACTIVE_CLASS = 'active'; | ||
24 | export const GUTTER_DECORATION_CLASS = 'cm-scroller-gutter-decoration'; | ||
25 | export const TOP_DECORATION_CLASS = 'cm-scroller-top-decoration'; | ||
26 | export const ANNOTATION_SELECTION_CLASS = 'cm-scroller-selection'; | ||
27 | export const ANNOTATION_DIAGNOSTIC_CLASS = 'cm-scroller-diagnostic'; | ||
28 | export const ANNOTATION_OCCURRENCE_CLASS = 'cm-scroller-occurrence'; | ||
29 | export const SHADOW_WIDTH = 10; | ||
30 | export const SCROLLBAR_WIDTH = 12; | ||
31 | export const ANNOTATION_WIDTH = SCROLLBAR_WIDTH / 2; | ||
32 | export const MIN_ANNOTATION_HEIGHT = 1; | ||
33 | |||
34 | function 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 | |||
87 | function 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 | |||
162 | export 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 | } | ||