1import { Controller } from "@hotwired/stimulus" 2 3const MOTION_PRESETS = [ 4 { 5 panFromX: -4.2, 6 panFromY: -2.6, 7 panToX: 4.8, 8 panToY: 2.9, 9 softPanFromX: -2.3, 10 softPanFromY: -1.4, 11 softPanToX: 2.6, 12 softPanToY: 1.6, 13 zoomFrom: 1.06, 14 zoomTo: 1.21, 15 softZoomFromDelta: 0.03, 16 softZoomToDelta: 0.12 17 }, 18 { 19 panFromX: 4.4, 20 panFromY: -2.8, 21 panToX: -4.6, 22 panToY: 2.8, 23 softPanFromX: 2.4, 24 softPanFromY: -1.5, 25 softPanToX: -2.5, 26 softPanToY: 1.5, 27 zoomFrom: 1.07, 28 zoomTo: 1.22, 29 softZoomFromDelta: 0.04, 30 softZoomToDelta: 0.13 31 }, 32 { 33 panFromX: -2.6, 34 panFromY: 3.3, 35 panToX: 3.2, 36 panToY: -3.6, 37 softPanFromX: -1.6, 38 softPanFromY: 2.1, 39 softPanToX: 1.9, 40 softPanToY: -2.2, 41 zoomFrom: 1.05, 42 zoomTo: 1.2, 43 softZoomFromDelta: 0.03, 44 softZoomToDelta: 0.11 45 }, 46 { 47 panFromX: 3.5, 48 panFromY: 2.9, 49 panToX: -3.4, 50 panToY: -3.2, 51 softPanFromX: 2, 52 softPanFromY: 1.8, 53 softPanToX: -2, 54 softPanToY: -1.9, 55 zoomFrom: 1.06, 56 zoomTo: 1.21, 57 softZoomFromDelta: 0.03, 58 softZoomToDelta: 0.12 59 } 60] 61 62export default class extends Controller { 63 static targets = ["slide", "dot"] 64 static values = { 65 interval: Number, 66 transition: Number 67 } 68 69 connect() { 70 this.index = 0 71 this.motionPresetIndex = 0 72 this.preloadedSlideImages = [] 73 this.preloadedSlideSrcs = new Set() 74 this.isInViewport = true 75 this.visibilityObserver = null 76 this.restartAnimationFrame = null 77 this.tickTimer = null 78 this.aspectRefreshFrame = null 79 this.onVisibilityChange = () => { 80 if (document.hidden) { 81 this.stopAuto() 82 } else if (this.isInViewport) { 83 this.startAuto(this.cycleDelayMs()) 84 } 85 } 86 this.onResize = () => this.scheduleAspectModeRefresh() 87 88 // We intentionally avoid hover pause; on coarse/touch devices and mixed-input 89 // browsers it can intermittently pause without a matching resume event. 90 document.addEventListener("visibilitychange", this.onVisibilityChange) 91 window.addEventListener("resize", this.onResize) 92 93 this.setupVisibilityObserver() 94 this.updateSlideAspectModes() 95 this.show(this.index) 96 if (this.isInViewport) this.startAuto(this.cycleDelayMs()) 97 } 98 99 disconnect() { 100 document.removeEventListener("visibilitychange", this.onVisibilityChange) 101 window.removeEventListener("resize", this.onResize) 102 if (this.visibilityObserver) this.visibilityObserver.disconnect() 103 this.visibilityObserver = null 104 if (this.restartAnimationFrame) window.cancelAnimationFrame(this.restartAnimationFrame) 105 this.restartAnimationFrame = null 106 if (this.aspectRefreshFrame) window.cancelAnimationFrame(this.aspectRefreshFrame) 107 this.aspectRefreshFrame = null 108 this.preloadedSlideImages = [] 109 this.preloadedSlideSrcs.clear() 110 this.stopAuto() 111 } 112 113 next(event) { 114 event?.preventDefault() 115 this.advance(1, true) 116 } 117 118 prev(event) { 119 event?.preventDefault() 120 this.advance(-1, true) 121 } 122 123 go(event) { 124 event?.preventDefault() 125 const nextIndex = Number.parseInt(event.currentTarget?.dataset?.heroCarouselIndex, 10) 126 if (!Number.isFinite(nextIndex)) return 127 this.show(nextIndex) 128 this.startAuto(this.cycleDelayMs()) 129 } 130 131 stopAuto() { 132 if (!this.tickTimer) return 133 window.clearTimeout(this.tickTimer) 134 this.tickTimer = null 135 } 136 137 startAuto(delayMs) { 138 if (this.slideTargets.length <= 1) return 139 this.stopAuto() 140 const safeDelay = Number.isFinite(delayMs) ? delayMs : this.cycleDelayMs() 141 this.tickTimer = window.setTimeout(() => this.tick(), safeDelay) 142 } 143 144 tick() { 145 this.tickTimer = null 146 if (this.slideTargets.length <= 1) return 147 this.advance(1) 148 this.startAuto(this.cycleDelayMs()) 149 } 150 151 advance(step, restartTimer = false) { 152 const count = this.slideTargets.length 153 if (count <= 1) return 154 this.show((this.index + step + count) % count) 155 if (restartTimer) this.startAuto(this.cycleDelayMs()) 156 } 157 158 show(index) { 159 const count = this.slideTargets.length 160 if (count === 0) return 161 162 const nextIndex = ((index % count) + count) % count 163 this.index = nextIndex 164
165 this.slideTargets.forEach((slide, idx) => { 166 const isActive = idx === this.index 167 slide.classList.toggle("is-active", isActive) 168 slide.classList.toggle("opacity-100", isActive) 169 slide.classList.toggle("pointer-events-auto", isActive) 170 slide.classList.toggle("opacity-0", !isActive) 171 slide.classList.toggle("pointer-events-none", !isActive) 172 slide.setAttribute("aria-hidden", isActive ? "false" : "true") 173 174 if (isActive) this.restartSlideMotion(slide) 175 }) 176 177 this.dotTargets.forEach((dot, idx) => { 178 const isActive = idx === this.index 179 dot.classList.toggle("bg-white", isActive) 180 dot.classList.toggle("bg-white/40", !isActive) 181 dot.setAttribute("aria-current", isActive ? "true" : "false") 182 }) 183 184 this.applyAnimationPlayState() 185 this.preloadSlideAt(this.index + 1) 186 } 187 188 intervalMs() { 189 const parsed = Number.parseInt(this.intervalValue, 10) 190 return Number.isFinite(parsed) && parsed >= 1500 ? parsed : 6000 191 } 192 193 transitionMs() { 194 const parsed = Number.parseInt(this.transitionValue, 10) 195 return Number.isFinite(parsed) && parsed >= 0 ? parsed : 700 196 } 197 198 cycleDelayMs() { 199 const delay = this.intervalMs() - this.transitionMs() 200 return delay >= 500 ? delay : this.intervalMs() 201 } 202 203 restartSlideMotion(slide) { 204 if (!slide.classList.contains("hero-carousel-slide")) return 205 206 const image = slide.querySelector(".hero-carousel-slide-image") 207 if (!image) return 208 209 this.applyMotionPreset(slide) 210 211 if (this.restartAnimationFrame) window.cancelAnimationFrame(this.restartAnimationFrame) 212 image.style.animation = "none" 213 this.restartAnimationFrame = window.requestAnimationFrame(() => { 214 image.style.animation = "" 215 image.style.animationPlayState = this.isInViewport ? "running" : "paused" 216 this.restartAnimationFrame = null 217 }) 218 } 219 220 applyMotionPreset(slide) { 221 const preset = MOTION_PRESETS[this.motionPresetIndex % MOTION_PRESETS.length] 222 this.motionPresetIndex += 1 223 224 slide.style.setProperty("--hero-pan-x-start", `${preset.panFromX}%`) 225 slide.style.setProperty("--hero-pan-y-start", `${preset.panFromY}%`) 226 slide.style.setProperty("--hero-pan-x-end", `${preset.panToX}%`) 227 slide.style.setProperty("--hero-pan-y-end", `${preset.panToY}%`) 228 229 slide.style.setProperty("--hero-pan-out-x-start", `${preset.panToX}%`) 230 slide.style.setProperty("--hero-pan-out-y-start", `${preset.panToY}%`) 231 slide.style.setProperty("--hero-pan-out-x-end", `${preset.panFromX}%`) 232 slide.style.setProperty("--hero-pan-out-y-end", `${preset.panFromY}%`) 233 234 slide.style.setProperty("--hero-pan-soft-x-start", `${preset.softPanFromX}%`) 235 slide.style.setProperty("--hero-pan-soft-y-start", `${preset.softPanFromY}%`) 236 slide.style.setProperty("--hero-pan-soft-x-end", `${preset.softPanToX}%`) 237 slide.style.setProperty("--hero-pan-soft-y-end", `${preset.softPanToY}%`) 238 239 slide.style.setProperty("--hero-pan-soft-out-x-start", `${preset.softPanToX}%`) 240 slide.style.setProperty("--hero-pan-soft-out-y-start", `${preset.softPanToY}%`) 241 slide.style.setProperty("--hero-pan-soft-out-x-end", `${preset.softPanFromX}%`) 242 slide.style.setProperty("--hero-pan-soft-out-y-end", `${preset.softPanFromY}%`) 243 244 slide.style.setProperty("--hero-zoom-start", String(preset.zoomFrom)) 245 slide.style.setProperty("--hero-zoom-end", String(preset.zoomTo)) 246 slide.style.setProperty("--hero-zoom-out-start", String(preset.zoomTo)) 247 slide.style.setProperty("--hero-zoom-out-end", String(preset.zoomFrom)) 248 249 slide.style.setProperty("--hero-soft-zoom-start-delta", String(preset.softZoomFromDelta)) 250 slide.style.setProperty("--hero-soft-zoom-end-delta", String(preset.softZoomToDelta)) 251 slide.style.setProperty("--hero-soft-zoom-out-start-delta", String(preset.softZoomToDelta)) 252 slide.style.setProperty("--hero-soft-zoom-out-end-delta", String(preset.softZoomFromDelta)) 253 } 254 255 preloadSlideAt(index) { 256 const count = this.slideTargets.length 257 if (count <= 1) return 258 259 const nextIndex = ((index % count) + count) % count 260 const slide = this.slideTargets[nextIndex] 261 if (!slide) return 262 263 const image = slide.querySelector(".hero-carousel-slide-image") 264 const src = image?.currentSrc || image?.src 265 if (!src || this.preloadedSlideSrcs.has(src)) return 266 267 const preloader = new Image() 268 preloader.decoding = "async" 269 preloader.src = src 270 this.preloadedSlideImages.push(preloader) 271 this.preloadedSlideSrcs.add(src) 272 } 273 274 setupVisibilityObserver() { 275 if (!("IntersectionObserver" in window)) return 276 277 this.visibilityObserver = new IntersectionObserver((entries) => { 278 const entry = entries[0] 279 if (!entry) return 280 281 const inViewport = entry.isIntersecting && entry.intersectionRatio > 0.12 282 if (inViewport === this.isInViewport) return 283 284 this.isInViewport = inViewport 285 this.applyAnimationPlayState() 286 287 if (this.isInViewport) { 288 this.restartSlideMotion(this.slideTargets[this.index]) 289 this.startAuto(this.cycleDelayMs()) 290 } else { 291 this.stopAuto() 292 } 293 }, { threshold: [0, 0.12, 0.25] }) 294 295 this.visibilityObserver.observe(this.element) 296 } 297 298 applyAnimationPlayState() { 299 const state = this.isInViewport ? "running" : "paused"
300 this.slideTargets.forEach((slide) => { 301 const image = slide.querySelector(".hero-carousel-slide-image") 302 if (image) image.style.animationPlayState = state 303 }) 304 } 305 306 scheduleAspectModeRefresh() { 307 if (this.aspectRefreshFrame) return 308 this.aspectRefreshFrame = window.requestAnimationFrame(() => { 309 this.aspectRefreshFrame = null 310 this.updateSlideAspectModes() 311 }) 312 } 313 314 updateSlideAspectModes() { 315 this.slideTargets.forEach((slide) => { 316 const image = slide.querySelector(".hero-carousel-slide-image") 317 if (!image) return 318 319 if (!image.complete || image.naturalWidth <= 0 || image.naturalHeight <= 0) { 320 if (image.dataset.aspectListenerBound !== "true") { 321 image.addEventListener("load", () => this.scheduleAspectModeRefresh(), { once: true }) 322 image.dataset.aspectListenerBound = "true" 323 } 324 return 325 } 326 327 const imageRatio = image.naturalWidth / image.naturalHeight 328 const slideWidth = slide.clientWidth 329 const slideHeight = slide.clientHeight 330 if (!(slideWidth > 0 && slideHeight > 0)) return 331 332 const slideRatio = slideWidth / slideHeight 333 const cropFraction = 334 imageRatio > slideRatio ? (1 - (slideRatio / imageRatio)) : (1 - (imageRatio / slideRatio)) 335 const shouldContainEdges = cropFraction >= 0.2 336 const horizontalBars = imageRatio < slideRatio 337 338 if (shouldContainEdges) { 339 const containFillFraction = horizontalBars ? (imageRatio / slideRatio) : (slideRatio / imageRatio) 340 const targetFillFraction = 0.86 341 const rawScale = targetFillFraction / containFillFraction 342 const containScale = Math.min(1.42, Math.max(1.08, rawScale)) 343 slide.style.setProperty("--hero-contain-scale", containScale.toFixed(3)) 344 } else { 345 slide.style.removeProperty("--hero-contain-scale") 346 } 347 348 slide.classList.toggle("hero-carousel-slide--contain-edges", shouldContainEdges) 349 slide.classList.toggle("hero-carousel-slide--contain-edges-x", shouldContainEdges && horizontalBars) 350 slide.classList.toggle("hero-carousel-slide--contain-edges-y", shouldContainEdges && !horizontalBars) 351 }) 352 } 353}
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.