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.