PageSourceSearch

https://railsblocks.com/assets/controllers/marquee_controller-f62e…ab33e95d93223fd084e17b632ead8212e3298994.js

js railsblocks.com collected 2026-09-25 20:23:38 UTC 7,690 bytes, 243 lines download raw bytes

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.