PageSourceSearch

https://railsblocks.com/assets/controllers/scroll_area_controller-…98c8d0dc8f7ce53a5a916c2f1906b0c9b02c41e9.js

js railsblocks.com collected 2026-09-25 20:24:01 UTC 13,585 bytes, 425 lines download raw bytes

1import { Controller } from "@hotwired/stimulus";
2
3// Connects to data-controller="scroll-area"
4export default class extends Controller {
5  static targets = ["viewport", "scrollbar", "thumb", "root"];
6  static values = {
7    hideDelay: { type: Number, default: 600 }, // The delay in milliseconds before hiding the scrollbars
8  };
9
10  connect() {
11    this.scrolling = false;
12    this.hovering = false;
13    this.hideTimeout = null;
14    this.isDragging = false;
15    this.initialRefreshFrame = null;
16    this.secondaryInitialRefreshFrame = null;
17    this.initialRefreshTimeout = null;
18    this.onWindowLoad = this.onWindowLoad.bind(this);
19
20    this.refreshMeasurements();
21    this.scheduleInitialRefreshes();
22    this.refreshAfterFontsLoad();
23
24    // Observe viewport/root size changes
25    this.resizeObserver = new ResizeObserver(() => {
26      this.refreshMeasurements();
27    });
28
29    if (this.hasViewportTarget) {
30      this.resizeObserver.observe(this.viewportTarget);
31    }
32    if (this.hasRootTarget) {
33      this.resizeObserver.observe(this.rootTarget);
34    }
35
36    // Observe content changes inside viewport (dynamic content, lazy renders)
37    if (this.hasViewportTarget) {
38      this.mutationObserver = new MutationObserver(() => {
39        this.refreshMeasurements();
40      });
41      this.mutationObserver.observe(this.viewportTarget, {
42        childList: true,
43        subtree: true,
44      });
45    }
46
47    window.addEventListener("load", this.onWindowLoad);
48  }
49
50  disconnect() {
51    if (this.resizeObserver) {
52      this.resizeObserver.disconnect();
53    }
54    if (this.mutationObserver) {
55      this.mutationObserver.disconnect();
56    }
57    this.clearScheduledRefreshes();
58    if (this.hideTimeout) {
59      clearTimeout(this.hideTimeout);
60    }
61    window.removeEventListener("load", this.onWindowLoad);
62  }
63
64  // Viewport events
65  onViewportScroll(event) {
66    this.refreshMeasurements();
67    this.setScrolling(true);
68
69    // Clear previous timeout
70    if (this.hideTimeout) {
71      clearTimeout(this.hideTimeout);
72    }
73
74    // Set new timeout - only hide if not hovering
75    this.hideTimeout = setTimeout(() => {
76      // Keep scrollbar visible if still hovering
77      if (!this.hovering) {
78        this.setScrolling(false);
79      }
80    }, this.hideDelayValue);
81  }
82
83  // Mouse enter/leave on root
84  onRootMouseEnter() {
85    this.refreshMeasurements();
86    this.setHovering(true);
87  }
88
89  onRootMouseLeave() {
90    if (!this.isDragging) {
91      this.setHovering(false);
92      // Also clear scrolling state when mouse leaves
93      this.setScrolling(false);
94    }
95  }
96
97  // Scrollbar track click - jump to position
98  onScrollbarClick(event) {
99    // Don't handle if clicking on thumb
100    if (event.target.dataset.scrollAreaTarget === "thumb") return;
101
102    const scrollbar = event.currentTarget;
103    const orientation = this.getOrientation(scrollbar);
104    const isVertical = orientation === "vertical";
105    const viewport = this.viewportTarget;
106
107    // Get click position relative to scrollbar
108    const rect = scrollbar.getBoundingClientRect();
109    const clickPos = isVertical ? event.clientY - rect.top : event.clientX - rect.left;
110
111    const scrollbarSize = isVertical ? scrollbar.offsetHeight : scrollbar.offsetWidth;
112    const viewportSize = isVertical ? viewport.clientHeight : viewport.clientWidth;
113    const contentSize = isVertical ? viewport.scrollHeight : viewport.scrollWidth;
114    const maxScrollableDistance = contentSize - viewportSize;
115
116    if (maxScrollableDistance <= 0 || scrollbarSize <= 0 || contentSize <= 0) return;
117
118    // Calculate thumb size
119    const thumbSize = Math.max((viewportSize / contentSize) * scrollbarSize, 20);
120    const clampedThumbSize = Math.min(thumbSize, scrollbarSize);
121
122    // Calculate scroll position (center thumb on click position)
123    const maxScroll = Math.max(scrollbarSize - clampedThumbSize, 0);
124    if (maxScroll <= 0) return;
125
126    const thumbPosition = Math.max(0, Math.min(clickPos - clampedThumbSize / 2, maxScroll));
127    const scrollRatio = thumbPosition / maxScroll;
128
129    // Apply scroll
130    if (isVertical) {
131      viewport.scrollTop = scrollRatio * maxScrollableDistance;
132    } else {
133      viewport.scrollLeft = scrollRatio * maxScrollableDistance;
134    }
135  }
136
137  // Thumb drag events
138  onThumbMouseDown(event) {
139    // Ignore right-click
140    if (event.button !== 0) return;
141
142    event.preventDefault();
143    event.stopPropagation(); // Prevent scrollbar click from firing
144    this.isDragging = true;
145    this.setHovering(true);
146
147    const thumb = event.currentTarget;
148    const scrollbar = thumb.closest('[data-scroll-area-target="scrollbar"]');
149    const orientation = this.getOrientation(scrollbar);
150    const isVertical = orientation === "vertical";
151    const viewport = this.viewportTarget;
152
153    // Get initial positions
154    const startPos = isVertical ? event.clientY : event.clientX;
155    const startScroll = isVertical ? viewport.scrollTop : viewport.scrollLeft;
156
157    // Calculate scrollbar metrics
158    const scrollbarSize = isVertical ? scrollbar.offsetHeight : scrollbar.offsetWidth;
159    const viewportSize = isVertical ? viewport.clientHeight : viewport.clientWidth;
160    const contentSize = isVertical ? viewport.scrollHeight : viewport.scrollWidth;
161    const maxScrollableDistance = contentSize - viewportSize;
162    const rawThumbSize = Math.max((viewportSize / contentSize) * scrollbarSize, 20);
163    const thumbSize = Math.min(rawThumbSize, scrollbarSize);
164    const maxThumbTravel = scrollbarSize - thumbSize;
165
166    if (maxScrollableDistance <= 0 || scrollbarSize <= 0 || maxThumbTravel <= 0) {
167      this.isDragging = false;
168      this.setHovering(false);
169      return;
170    }
171
172    const onMouseMove = (e) => {
173      const currentPos = isVertical ? e.clientY : e.clientX;
174      const delta = currentPos - startPos;
175      const scrollDelta = (delta / maxThumbTravel) * maxScrollableDistance;
176
177      if (isVertical) {
178        viewport.scrollTop = startScroll + scrollDelta;
179      } else {
180        viewport.scrollLeft = startScroll + scrollDelta;
181      }
182    };
183
184    const onMouseUp = (e) => {
185      this.isDragging = false;
186
187      // Check if mouse is still over the root element
188      if (this.hasRootTarget) {
189        const rect = this.rootTarget.getBoundingClientRect();
190        const isStillInside =
191          e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom;
192
193        // Only set hovering to false if mouse left the container
194        if (!isStillInside) {
195          this.setHovering(false);
196        }
197      }
198
199      cleanup();
200    };
201
202    const onContextMenu = (e) => {
203      // Cancel drag on right-click
204      this.isDragging = false;
205      this.setHovering(false);
206      cleanup();
207    };
208
209    const cleanup = () => {
210      document.removeEventListener("mousemove", onMouseMove);
211      document.removeEventListener("mouseup", onMouseUp);
212      document.removeEventListener("contextmenu", onContextMenu);
213    };
214
215    document.addEventListener("mousemove", onMouseMove);
216    document.addEventListener("mouseup", onMouseUp);
217    document.addEventListener("contextmenu", onContextMenu);
218  }
219
220  // Update methods
221  refreshMeasurements() {
222    this.updateOverflow();
223    this.updateAllScrollbars();
224  }
225
226  onWindowLoad() {
227    this.refreshMeasurements();
228  }
229
230  refreshAfterFontsLoad() {
231    if (!document.fonts?.ready) return;
232
233    document.fonts.ready
234      .then(() => {
235        if (this.isConnected) {
236          this.refreshMeasurements();
237        }
238      })
239      .catch(() => {
240        // Ignore font loading errors; fall back to resize/scroll observers.
241      });
242  }
243
244  scheduleInitialRefreshes() {
245    this.clearScheduledRefreshes();
246
247    // Run after first and second paint to capture late layout changes.
248    this.initialRefreshFrame = requestAnimationFrame(() => {
249      this.refreshMeasurements();
250      this.secondaryInitialRefreshFrame = requestAnimationFrame(() => {
251        this.refreshMeasurements();
252      });
253    });
254
255    // Catch slower layout shifts (e.g., async content or class toggles).
256    this.initialRefreshTimeout = setTimeout(() => {
257      this.refreshMeasurements();
258      this.initialRefreshTimeout = null;
259    }, 150);
260  }
261
262  clearScheduledRefreshes() {
263    if (this.initialRefreshFrame) {
264      cancelAnimationFrame(this.initialRefreshFrame);
265      this.initialRefreshFrame = null;
266    }
267    if (this.secondaryInitialRefreshFrame) {
268      cancelAnimationFrame(this.secondaryInitialRefreshFrame);
269      this.secondaryInitialRefreshFrame = null;
270    }
271    if (this.initialRefreshTimeout) {
272      clearTimeout(this.initialRefreshTimeout);
273      this.initialRefreshTimeout = null;
274    }
275  }
276
277  updateOverflow() {
278    if (!this.hasViewportTarget) return;
279
280    const viewport = this.viewportTarget;
281    const hasOverflowX = viewport.scrollWidth > viewport.clientWidth;
282    const hasOverflowY = viewport.scrollHeight > viewport.clientHeight;
283
284    // Update root data attributes
285    if (this.hasRootTarget) {
286      this.rootTarget.dataset.hasOverflowX = hasOverflowX;
287      this.rootTarget.dataset.hasOverflowY = hasOverflowY;
288
289      // Calculate overflow distances
290      const overflowXStart = viewport.scrollLeft;
291      const overflowXEnd = viewport.scrollWidth - viewport.clientWidth - viewport.scrollLeft;
292      const overflowYStart = viewport.scrollTop;
293      const overflowYEnd = viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop;
294
295      // Add small threshold to handle rounding errors (1px tolerance)
296      const threshold = 1;
297
298      // Set data attributes for overflow edges
299      if (overflowXStart > threshold) {
300        this.rootTarget.dataset.overflowXStart = "";
301      } else {
302        delete this.rootTarget.dataset.overflowXStart;
303      }
304
305      if (overflowXEnd > threshold) {
306        this.rootTarget.dataset.overflowXEnd = "";
307      } else {
308        delete this.rootTarget.dataset.overflowXEnd;
309      }
310
311      if (overflowYStart > threshold) {
312        this.rootTarget.dataset.overflowYStart = "";
313      } else {
314        delete this.rootTarget.dataset.overflowYStart;
315      }
316
317      if (overflowYEnd > threshold) {
318        this.rootTarget.dataset.overflowYEnd = "";
319      } else {
320        delete this.rootTarget.dataset.overflowYEnd;
321      }
322
323      // Set CSS variables for overflow distances
324      this.rootTarget.style.setProperty("--scroll-area-overflow-x-start", `${overflowXStart}px`);
325      this.rootTarget.style.setProperty("--scroll-area-overflow-x-end", `${overflowXEnd}px`);
326      this.rootTarget.style.setProperty("--scroll-area-overflow-y-start", `${overflowYStart}px`);
327      this.rootTarget.style.setProperty("--scroll-area-overflow-y-end", `${overflowYEnd}px`);
328    }
329  }
330
331  updateAllScrollbars() {
332    if (!this.hasScrollbarTarget) return;
333
334    this.scrollbarTargets.forEach((scrollbar) => {
335      this.updateScrollbarPosition(scrollbar);
336      this.updateScrollbarVisibility(scrollbar);
337    });
338  }
339
340  updateScrollbarPosition(scrollbar) {
341    if (!this.hasViewportTarget) return;
342
343    const viewport = this.viewportTarget;
344    const thumb = this.getThumbForScrollbar(scrollbar);
345    if (!thumb) return;
346
347    const orientation = this.getOrientation(scrollbar);
348    const isVertical = orientation === "vertical";
349
350    const scrollbarSize = isVertical ? scrollbar.offsetHeight : scrollbar.offsetWidth;
351    const viewportSize = isVertical ? viewport.clientHeight : viewport.clientWidth;
352    const contentSize = isVertical ? viewport.scrollHeight : viewport.scrollWidth;
353    const maxScrollableDistance = contentSize - viewportSize;
354
355    const scrollRatio =
356      maxScrollableDistance > 0 ? (isVertical ? viewport.scrollTop : viewport.scrollLeft) / maxScrollableDistance : 0;
357
358    if (scrollbarSize <= 0 || contentSize <= 0) return;
359
360    // Calculate thumb size
361    const thumbSize = Math.max((viewportSize / contentSize) * scrollbarSize, 20); // Minimum 20px
362    const clampedThumbSize = Math.min(thumbSize, scrollbarSize);
363    const maxScroll = Math.max(scrollbarSize - clampedThumbSize, 0);
364    const thumbPosition = scrollRatio * maxScroll;
365
366    if (isVertical) {
367      thumb.style.height = `${clampedThumbSize}px`;
368      thumb.style.transform = `translateY(${thumbPosition}px)`;
369    } else {
370      thumb.style.width = `${clampedThumbSize}px`;
371      thumb.style.transform = `translateX(${thumbPosition}px)`;
372    }
373  }
374
375  updateScrollbarVisibility(scrollbar) {
376    if (!this.hasViewportTarget) return;
377
378    const viewport = this.viewportTarget;
379    const orientation = this.getOrientation(scrollbar);
380    const isVertical = orientation === "vertical";
381
382    const hasOverflow = isVertical
383      ? viewport.scrollHeight > viewport.clientHeight
384      : viewport.scrollWidth > viewport.clientWidth;
385
386    scrollbar.dataset.visible = hasOverflow;
387  }
388
389  setScrolling(scrolling) {
390    this.scrolling = scrolling;
391    this.updateScrollbarState();
392  }
393
394  setHovering(hovering) {
395    this.hovering = hovering;
396    this.updateScrollbarState();
397  }
398
399  updateScrollbarState() {
400    if (!this.hasScrollbarTarget) return;
401
402    this.scrollbarTargets.forEach((scrollbar) => {
403      if (this.scrolling) {
404        scrollbar.dataset.scrolling = "";
405      } else {
406        delete scrollbar.dataset.scrolling;
407      }
408
409      if (this.hovering) {
410        scrollbar.dataset.hovering = "";
411      } else {
412        delete scrollbar.dataset.hovering;
413      }
414    });
415  }
416
417  // Helper methods
418  getOrientation(element) {
419    return element.dataset.scrollAreaOrientationValue || "vertical";
420  }
421
422  getThumbForScrollbar(scrollbar) {
423    return scrollbar.querySelector('[data-scroll-area-target="thumb"]');
424  }
425};

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.