1import { Controller } from "@hotwired/stimulus"; 2 3export default class extends Controller { 4 static targets = ["track", "list"]; // Required DOM targets: moving track and original content list. 5 static speedSmoothingPerSecond = 9; // Higher values react faster; lower values feel softer. 6 static values = { 7 speed: { type: Number, default: 20 }, // Base loop duration in seconds (lower = faster). 8 hoverSpeed: { type: Number, default: 0 }, // Hover loop duration in seconds; `0` means target speed is 0. 9 direction: { type: String, default: "left" }, // Scroll direction: `left`, `right`, `up`, or `down`. 10 clones: { type: Number, default: 2 }, // Number of duplicated lists to keep looping seamless. 11 }; 12 13 connect() { 14 this.isHovering = false; // Tracks hover state to choose target speed. 15 this.seamGap = 0; // Pixel gap between list clones, derived from the list's flex gap. 16 this.contentSize = 0; // Width/height in pixels of a single marquee list depending on direction. 17 this.lastContentSize = 0; // Last measured size used by resize restart guard. 18 this.progress = 0; // Current cycle progress in pixels [0, contentSize). 19 this.currentSpeed = 0; // Current speed in pixels/second (smoothed). 20 this.rafId = null; // requestAnimationFrame handle for the marquee loop. 21 this.lastFrameTime = null; // Timestamp of previous frame for delta-time integration. 22 23 this.setupMarquee(); 24 25 // Safari/mobile can trigger many resizes; we only restart when size truly changed. 26 this.resizeObserver = new ResizeObserver(() => { 27 this.handleResize(); 28 }); 29 this.resizeObserver.observe(this.listTarget); 30 } 31 32 disconnect() { 33 this.stopLoop(); 34 this.cleanupAnimation(); 35 if (this.resizeObserver) this.resizeObserver.disconnect(); 36 } 37 38 setupMarquee() { 39 // Remove previous clones before rebuilding. 40 this.removeClones(); 41 42 // Ensure the source list cannot shrink, then duplicate it. 43 this.listTarget.style.flexShrink = "0"; 44 for (let i = 0; i < this.clonesValue; i++) { 45 const clone = this.listTarget.cloneNode(true); 46 clone.setAttribute("aria-hidden", "true"); 47 clone.classList.add("marquee-clone"); 48 clone.style.flexShrink = "0"; 49 clone.removeAttribute("data-marquee-target"); 50 this.trackTarget.appendChild(clone); 51 } 52 53 this.trackTarget.style.display = "flex"; 54 this.trackTarget.style.flexWrap = "nowrap"; 55 this.trackTarget.style.flexDirection = this.isVerticalDirection() ? "column" : "row"; 56 57 this.injectBaseStyles(); 58 59 // Measure after layout settles, then start loop. 60 requestAnimationFrame(() => { 61 const size = this.measureContentSize(); 62 if (size === 0) return; 63 64 this.contentSize = size; 65 this.lastContentSize = size; 66 this.progress = 0; 67 this.currentSpeed = this.targetSpeedPxPerSecond(); 68 69 this.applyTransform(); 70 this.startLoop(); 71 }); 72 } 73 74 startLoop() { 75 this.stopLoop(); 76 this.lastFrameTime = null; 77 this.rafId = requestAnimationFrame((time) => this.tick(time)); 78 } 79 80 stopLoop() { 81 if (this.rafId) { 82 cancelAnimationFrame(this.rafId); 83 this.rafId = null; 84 } 85 this.lastFrameTime = null; 86 } 87 88 tick(timestamp) { 89 if (!this.hasTrackTarget || !this.hasListTarget || this.contentSize === 0) { 90 this.rafId = requestAnimationFrame((time) => this.tick(time)); 91 return; 92 } 93 94 if (this.lastFrameTime === null) { 95 this.lastFrameTime = timestamp; 96 this.rafId = requestAnimationFrame((time) => this.tick(time)); 97 return; 98 } 99 100 // Clamp dt so tab-switch/frame drops do not create visible jumps. 101 const dt = Math.min(0.05, (timestamp - this.lastFrameTime) / 1000); 102 this.lastFrameTime = timestamp; 103 104 const targetSpeed = this.targetSpeedPxPerSecond(); 105 const smoothing = 1 - Math.exp(-this.constructor.speedSmoothingPerSecond * dt); 106 this.currentSpeed += (targetSpeed - this.currentSpeed) * smoothing; 107 108 if (Math.abs(targetSpeed - this.currentSpeed) < 0.05) { 109 this.currentSpeed = targetSpeed; 110 } 111 112 this.progress = (this.progress + this.currentSpeed * dt) % this.contentSize; 113 if (this.progress < 0) this.progress += this.contentSize; 114 115 this.applyTransform(); 116 this.rafId = requestAnimationFrame((time) => this.tick(time)); 117 } 118 119 applyTransform() { 120 if (!this.hasTrackTarget || this.contentSize === 0) return; 121 122 const negativeDirection = this.directionValue === "left" || this.directionValue === "up"; 123 const offset = negativeDirection ? -this.progress : -this.contentSize + this.progress; 124 125 if (this.isVerticalDirection()) { 126 // Up: 0 -> -size. Down: -size -> 0. 127 this.trackTarget.style.transform = `translate3d(0, ${offset}px, 0)`; 128 } else { 129 // Left: 0 -> -size. Right: -size -> 0. 130 this.trackTarget.style.transform = `translate3d(${offset}px, 0, 0)`; 131 } 132 } 133 134 targetSpeedPxPerSecond() { 135 if (this.contentSize === 0) return 0; 136 137 const speedDuration = Math.max(0.001, this.speedValue); 138 const base = this.contentSize / speedDuration; 139 140 if (!this.isHovering) return base; 141 if (this.hoverSpeedValue === 0) return 0; 142 143 const hoverDuration = Math.max(0.001, this.hoverSpeedValue); 144 return this.contentSize / hoverDuration; 145 } 146 147 isVerticalDirection() { 148 return this.directionValue === "up" || this.directionValue === "down"; 149 } 150 151 measureContentSize() { 152 if (!this.hasListTarget) return 0; 153 154 // Keep clone seams visually consistent with in-list spacing. 155 this.seamGap = this.measureSeamGap(); 156 if (this.hasTrackTarget) this.trackTarget.style.gap = `${this.seamGap}px`; 157 158 const listSize = this.isVerticalDirection() ? this.listTarget.offsetHeight : this.listTarget.offsetWidth; 159 return listSize + this.seamGap; 160 } 161 162 measureSeamGap() { 163 if (!this.hasListTarget) return 0; 164 165 const styles = getComputedStyle(this.listTarget); 166 const rawGap = this.isVerticalDirection() ? styles.rowGap : styles.columnGap; 167 const parsedGap = Number.parseFloat(rawGap); 168 return Number.isFinite(parsedGap) ? parsedGap : 0; 169 } 170 171 pauseAnimation() { 172 this.isHovering = true; 173 } 174 175 resumeAnimation() { 176 this.isHovering = false;
177 } 178 179 restartAnimation() { 180 if (!this.hasTrackTarget || !this.hasListTarget) return; 181 this.stopLoop(); 182 this.setupMarquee(); 183 } 184 185 cleanupAnimation() { 186 this.stopLoop(); 187 this.removeClones(); 188 } 189 190 removeClones() { 191 if (!this.hasTrackTarget) return; 192 const clones = this.trackTarget.querySelectorAll(".marquee-clone"); 193 clones.forEach((clone) => clone.remove()); 194 } 195 196 handleResize() { 197 if (!this.hasListTarget) return; 198 199 const size = this.measureContentSize(); 200 if (size === 0) return; 201 if (size === this.lastContentSize) return; 202 203 this.lastContentSize = size; 204 this.restartAnimation(); 205 } 206 207 injectBaseStyles() { 208 const baseStyleId = "marquee-base-styles"; 209 if (document.getElementById(baseStyleId)) return; 210 211 const style = document.createElement("style"); 212 style.id = baseStyleId; 213 style.textContent = ` 214 [data-marquee-target="track"] { 215 will-change: transform; 216 backface-visibility: hidden; 217 perspective: 1000px; 218 } 219 220 [data-marquee-target="list"], 221 .marquee-clone { 222 flex-shrink: 0; 223 min-width: max-content; 224 min-height: max-content; 225 } 226 `; 227 228 document.head.appendChild(style); 229 } 230 231 // Value change callbacks 232 speedValueChanged() { 233 if (this.hasTrackTarget) this.restartAnimation(); 234 } 235 236 directionValueChanged() { 237 if (this.hasTrackTarget) this.restartAnimation(); 238 } 239 240 clonesValueChanged() { 241 if (this.hasTrackTarget) this.restartAnimation(); 242 } 243};
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.