wake-marquee 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,30 +8,75 @@ While the version is below `1.0.0` the API may still change in a minor release.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.2.1] - 2026-09-01
12
+
13
+ Two ways the row could still rearrange itself in front of the reader
14
+ after it had already started.
15
+
11
16
  ### Fixed
12
17
 
13
- - The row no longer jumps as it starts. Three separate causes, all of them
14
- the same shape: work spread across frames that the browser paints one by
15
- one.
16
- - Measuring, cloning and the first transform were left to three different
17
- observer callbacks. They now happen together, in the task that starts the
18
- row, for any row already on screen.
19
- - The track's overhang was applied at construction while the transform that
20
- cancels it was written on first sight. Between the two, the row sat a full
21
- wake amplitude off to one side. Both now happen in the same frame.
22
- - The first animated frame landed on the loop's zero point rather than on
23
- the position the static row already occupied. It is now aligned to it,
24
- which matters most for a row reached by anchor link or a restored scroll
25
- position, where the two are furthest apart.
26
- - A lane that resizes under a running row, from a late web font or an image
27
- with no `width` and `height`, no longer shifts the row by the difference.
28
- The offset is restated in the new period, holding the phase.
18
+ - A row of images with no `width` and `height` no longer jerks sideways once
19
+ for every image that arrives. An unloaded, unsized `<img>` lays out zero
20
+ pixels wide, and the lane's width is the loop period, so the row was
21
+ starting on a period made of its gaps and nothing else: eight logos over a
22
+ phone connection measured 384px at the first frame and 1608px a second and a
23
+ half later, and yanked by up to 216px on each step in between. A row whose
24
+ items reserve no space now waits for them and hands over once, which is
25
+ invisible because what stands there meanwhile is the static row that was on
26
+ screen anyway. The wait is capped, so a request that never answers costs a
27
+ late start rather than a row that never runs. It is the safe behaviour and
28
+ not the good one, so it also warns, once per page: the fix is two attributes
29
+ in the markup, and they spare the rest of the page the same layout shift.
30
+ - A lane added by a resize is given the loop's current transform before it is
31
+ attached. `ResizeObserver` is notified after the same frame's
32
+ `requestAnimationFrame` callbacks, so a lane cloned from one was painted once
33
+ at its untransformed position, up to a whole period right of its siblings.
34
+
35
+ ## [0.2.0] - 2026-09-01
36
+
37
+ Three things adopting `0.1.0` in a real project turned up.
38
+
39
+ ### Added
40
+
41
+ - `getMarquee(element)` returns the marquee running on an element, or `null`.
42
+ `initMarquees()` hands back only the instances that call created, so the
43
+ declarative integrations were a one-way door: `wake-marquee/astro` starts the
44
+ rows in a hoisted script with nowhere to return a handle to, and calling
45
+ `initMarquees()` again from application code returns an empty array because
46
+ every row is already running. There was no way to reach `pause()`, `refresh()`
47
+ or `destroy()` at all without giving up the integration and hand-rolling the
48
+ markup.
49
+ - `speed` accepts a percentage of the container width per second, `'8%'` as
50
+ well as `60`. Pixels a second is a physical unit on a page whose elements are
51
+ not physically constant: a logo row is 40px tall with a 72px gap on a desktop
52
+ and 16px with a 32px gap on a phone, so one value is calm on the first and
53
+ hectic on the second, with three times as many items going past. The
54
+ percentage is resolved against the width the wake is already measuring every
55
+ frame, so it follows a resize and costs nothing.
56
+ - A console warning when the item spacing has collapsed to zero while
57
+ `--wake-gap` asks for more. An unlayered global reset outranks every cascade
58
+ layer whatever the specificity says, so `* { margin: 0 }` quietly beats the
59
+ library's `margin-inline-end` and the items sit flush. Nothing else gives it
60
+ away: the row still loops and the value still reads correctly in the devtools,
61
+ which makes it look like a mistake in the consuming project. Said once per
62
+ page, and never when `--wake-gap` is genuinely `0`.
29
63
 
30
64
  ### Changed
31
65
 
32
- - `wake-marquee/astro` renders `--wake-gap` and `--wake-fade` as inline custom
33
- properties, so the static row is laid out correctly before the script runs.
34
- The `data-wake-*` attributes still carry the same values for the script.
66
+ - The README's layer-order guidance was only under "Tailwind CSS v4", which is
67
+ not where somebody with a hand-written reset goes looking. Styling now has a
68
+ "Cascade layers" section covering both, and it records why the item spacing
69
+ stays inside the layer: it is the rule you are most likely to want to
70
+ override, and unlayered library CSS would beat your own utilities.
71
+ - The demo's size figure is weighed at build time rather than written down.
72
+
73
+ ### Notes
74
+
75
+ - A duration form of `speed`, `'9.6s'` for one container width, was considered
76
+ and dropped. It reverses the option on itself next to the number form, where
77
+ `'12s'` would be slower than `'6s'` while `120` is faster than `60`, and it
78
+ invites the reading "per lap", which would be the lane width and would mean
79
+ adding one logo slowed the row down.
35
80
 
36
81
  ## [0.1.0] - 2026-09-01
37
82
 
@@ -58,6 +103,14 @@ First release.
58
103
  than one per row. The loop stops when nothing is on screen.
59
104
  - Rows are measured and cloned on first sight, not at construction, so a page
60
105
  of ten marquees does no work for the nine nobody has scrolled to.
106
+ - The handover from the static row to the running one happens inside a single
107
+ task, so no half-built state is ever painted, and the first frame is aligned
108
+ to the position the static row already occupied rather than to the loop's
109
+ own zero point. A row reached by anchor link or a restored scroll position
110
+ starts as cleanly as one scrolled to.
111
+ - A lane resized under a running row, by a late web font or an image with no
112
+ `width` and `height`, does not shift it: the offset is restated in the new
113
+ period, holding the phase.
61
114
  - `wake-marquee/css`: the loop geometry in an `@layer wake-marquee` cascade
62
115
  layer, 407 B gzipped. It owns nothing about how items look.
63
116
  - `wake-marquee/astro`: Astro component rendering the same attributes.
@@ -70,6 +123,10 @@ First release.
70
123
 
71
124
  ### Notes
72
125
 
126
+ - Spacing and fade width change the layout of the static row, so declaring
127
+ them only in `data-wake-gap` or `data-wake-fade` leaves the items shifting
128
+ when the script runs. Set the matching custom property too; the Astro
129
+ component does it for you. Documented in the README.
73
130
  - The spacing between items is a `margin-inline-end` on the item rather than
74
131
  `gap` on the row. The lane's width is the loop period, so the space after
75
132
  the last item has to belong to the lane; with `gap` there is none between
@@ -77,5 +134,7 @@ First release.
77
134
  - Right-to-left writing modes are not supported. Flex rows follow the writing
78
135
  direction while the transforms are physical, so the two disagree.
79
136
 
80
- [Unreleased]: https://github.com/robin-gogolok/wake-marquee/compare/v0.1.0...HEAD
137
+ [Unreleased]: https://github.com/robin-gogolok/wake-marquee/compare/v0.2.1...HEAD
138
+ [0.2.1]: https://github.com/robin-gogolok/wake-marquee/compare/v0.2.0...v0.2.1
139
+ [0.2.0]: https://github.com/robin-gogolok/wake-marquee/compare/v0.1.0...v0.2.0
81
140
  [0.1.0]: https://github.com/robin-gogolok/wake-marquee/releases/tag/v0.1.0
package/README.md CHANGED
@@ -5,7 +5,7 @@ An endless marquee that answers to the scroll. It reverses when the reader scrol
5
5
  **[Live demo →](https://robin-gogolok.github.io/wake-marquee/)**
6
6
 
7
7
  ```
8
- 3.0 kB gzipped JS
8
+ 3.5 kB gzipped JS
9
9
  0.4 kB gzipped CSS
10
10
  0 runtime dependencies
11
11
  1 rAF loop, however many rows are on the page
@@ -45,9 +45,9 @@ Write the container and its items. The direct children become the items; the lib
45
45
 
46
46
  ```html
47
47
  <div data-wake-marquee data-wake-speed="55" data-wake="10" data-wake-fade="6rem">
48
- <img src="/logos/a.svg" alt="Nordlicht">
49
- <img src="/logos/b.svg" alt="Kvist">
50
- <img src="/logos/c.svg" alt="Halden & Co">
48
+ <img src="/logos/a.svg" alt="Nordlicht" width="150" height="48">
49
+ <img src="/logos/b.svg" alt="Kvist" width="110" height="48">
50
+ <img src="/logos/c.svg" alt="Halden & Co" width="175" height="48">
51
51
  </div>
52
52
  ```
53
53
 
@@ -75,11 +75,20 @@ const row = createMarquee(document.querySelector('#logos'), {
75
75
  ---
76
76
  import WakeMarquee from 'wake-marquee/astro'
77
77
  ---
78
- <WakeMarquee speed={55} wake={10} fade="6rem" aria-label="Brands we stock">
79
- {brands.map((b) => <img src={b.logo} alt={b.name} />)}
78
+ <WakeMarquee speed="8%" wake={10} fade="6rem" aria-label="Brands we stock">
79
+ {brands.map((b) => <img src={b.logo} alt={b.name} width={b.width} height={48} />)}
80
80
  </WakeMarquee>
81
81
  ```
82
82
 
83
+ The component's script starts the row and keeps no handle, because a hoisted
84
+ Astro script has nowhere to hand one to. Ask for it by element instead:
85
+
86
+ ```js
87
+ import { getMarquee } from 'wake-marquee'
88
+
89
+ getMarquee(document.querySelector('#logos'))?.pause()
90
+ ```
91
+
83
92
  ### React
84
93
 
85
94
  The library takes an element, so a ref and an effect are the whole integration. No adapter package.
@@ -133,6 +142,36 @@ The easing is framerate independent, so the same turn takes the same time on a 6
133
142
 
134
143
  Set `reverse: false` to keep a fixed heading and keep the wake.
135
144
 
145
+ ## Speed that survives a phone
146
+
147
+ `speed: 150` is 150 pixels a second, and pixels a second is a physical unit on
148
+ a page whose elements are not physically constant. The same row of logos is
149
+ 40px tall with a 72px gap on a desktop and 16px with a 32px gap on a phone, so
150
+ that one number crosses a 1440px container in nine and a half seconds and a
151
+ 390px one in under three, with three times as many items going past. Calm on
152
+ the desktop, hectic on the phone, and no breakpoint anywhere in sight because
153
+ the mismatch is in the unit rather than in the value.
154
+
155
+ So `speed` also takes a percentage, meaning that fraction of the container
156
+ width per second:
157
+
158
+ ```html
159
+ <div data-wake-marquee data-wake-speed="8%">
160
+ ```
161
+
162
+ The width is the one measured every frame for the wake, so this costs nothing
163
+ and follows a resize, a rotation or a container that changes under the row.
164
+
165
+ A rate rather than a duration on purpose. "One container width per 9.6
166
+ seconds" reads well on its own and reverses the option on itself the moment it
167
+ sits beside the number form: `'12s'` would be slower than `'6s'` while `120` is
168
+ faster than `60`. It also invites the reading "per lap", which would be the
169
+ lane width, so adding one logo would slow the row down.
170
+
171
+ There is no floor. A percentage on a very narrow viewport is a very slow row,
172
+ and `8%` of 390px is 31 px/s, which may be slower than you want it. Pick the
173
+ percentage for the width you care about most and check the other end.
174
+
136
175
  ## Starting without a flash
137
176
 
138
177
  A row has two lives: the static one the server sends, and the running one the
@@ -160,10 +199,16 @@ Same for `data-wake-fade` and `--wake-fade`. The library leaves a property
160
199
  that is already set alone, so there is no conflict. `wake-marquee/astro` does
161
200
  this for you.
162
201
 
163
- **Give images `width` and `height`.** An unsized image changes the lane width
164
- when it decodes, and the lane width is the loop period. The library restates
165
- the offset in the new period so the row does not leap, but the items around it
166
- still move: only the attributes prevent that.
202
+ **Give images `width` and `height`.** An unsized image is zero pixels wide
203
+ until it decodes, and the lane width is the loop period. A row of eight
204
+ unsized logos measures its gaps and nothing else at the first frame, then
205
+ grows into its real width one logo at a time.
206
+
207
+ The library will not start on a measurement like that: a row whose items
208
+ reserve no space waits for them, keeps standing as the static row the reader
209
+ was already looking at, and starts once. That is the safe behaviour, not the
210
+ good one, and it says so in the console. With the attributes there is nothing
211
+ to wait for and the row starts immediately.
167
212
 
168
213
  ## API
169
214
 
@@ -174,7 +219,7 @@ Turns an element and its children into a marquee. Returns a handle.
174
219
  | Option | Default | |
175
220
  |---|---|---|
176
221
  | `direction` | `'left'` | `'left'` or `'right'`, while scrolling down |
177
- | `speed` | `60` | Travel speed in pixels per second |
222
+ | `speed` | `60` | Pixels per second, or a percentage of the container width per second (`'8%'`) |
178
223
  | `wake` | `8` | Scroll displacement, as a percentage of the container width. `0` turns it off |
179
224
  | `reverse` | `true` | Whether scrolling up reverses travel |
180
225
  | `ease` | `5` | How sharply a reversal settles, per second. Around `1` is a long, heavy turn |
@@ -195,6 +240,24 @@ The handle:
195
240
  | `refresh()` | Re-measure and top up the clones. Call it after changing the items yourself |
196
241
  | `destroy()` | Undo everything: clones, wrappers, observers, listeners, attributes |
197
242
 
243
+ ### `getMarquee(element)`
244
+
245
+ The marquee running on an element, or `null`.
246
+
247
+ ```js
248
+ import { getMarquee } from 'wake-marquee'
249
+
250
+ const row = getMarquee(document.querySelector('#logos'))
251
+ row?.pause()
252
+ ```
253
+
254
+ `initMarquees()` returns only the instances that call created, which is the
255
+ right answer for something safe to call repeatedly and the wrong one for
256
+ anybody who needs a handle later: asking a second time over the same content
257
+ hands back an empty array, because every row is already running. This is the
258
+ way back in, and it is what makes the declarative path as capable as the
259
+ imperative one.
260
+
198
261
  ### `initMarquees({ root?, defaults? })`
199
262
 
200
263
  Finds every `[data-wake-marquee]` under `root` and starts it, reading each element's own attributes for its options. Skips rows that are already running, so it is safe to call again after new content arrives. Returns only the handles it created.
@@ -216,7 +279,7 @@ Every option except `scroller` and `respectMotionPreference` can be declared in
216
279
  |---|---|
217
280
  | `data-wake-marquee` | Marks the container. Also the stylesheet's hook |
218
281
  | `data-wake-direction="right"` | `direction` |
219
- | `data-wake-speed="55"` | `speed` |
282
+ | `data-wake-speed="55"` or `"8%"` | `speed` |
220
283
  | `data-wake="10"` | `wake` |
221
284
  | `data-wake-ease="2.5"` | `ease` |
222
285
  | `data-wake-gap="4rem"` | `gap` |
@@ -255,11 +318,39 @@ The library owns the loop geometry and nothing else. Items look however you styl
255
318
 
256
319
  The spacing is a `margin-inline-end` on each item rather than `gap` on the row, and that is load-bearing rather than a style choice: **the lane's width is the loop's repeat distance**, so the space after the last item has to be part of the lane. With `gap`, there is no space between the last item of one lane and the first of the next, and the seam is visible on every pass.
257
320
 
258
- All rules live in an `@layer wake-marquee` cascade layer, so your own unlayered CSS wins without `!important`.
321
+ ### Cascade layers
322
+
323
+ All rules live in an `@layer wake-marquee` cascade layer, so your own unlayered CSS wins without `!important`. That is worth knowing in both directions, because the rule behind it is absolute: **an unlayered declaration beats a layered one whatever the specificity says.**
324
+
325
+ The one rule of the library's that this reaches is the item spacing. An ordinary global reset
326
+
327
+ ```css
328
+ *, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
329
+ ```
330
+
331
+ beats `margin-inline-end: var(--wake-gap)` at 0,0,0 against 0,1,0, and the gap collapses to zero. Nothing else gives it away: the row still loops, `--wake-gap` still resolves correctly in the devtools, and the items simply sit flush against one another. It reads as a mistake in your own stylesheet, so the library says so in the console when it sees it.
332
+
333
+ Put the reset in a layer, and declare the order before the library's import:
334
+
335
+ ```css
336
+ @layer reset, wake-marquee;
337
+
338
+ @import "wake-marquee/css";
339
+
340
+ @layer reset {
341
+ *, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
342
+ }
343
+ ```
344
+
345
+ The order declaration comes first and the imports come next, because `@import`
346
+ has to precede every rule that is not a layer statement. Where the reset is a
347
+ file of its own, `@import "reset.css" layer(reset);` does the same job.
348
+
349
+ The spacing stays in the layer rather than being lifted out of it, because it is also the rule you are most likely to want to override: a `me-10` utility or a class of your own on the items should win, and unlayered library CSS would beat both. The library measures whatever spacing ends up applied, so any of that is fine. What it cannot do is guess that you meant a gap and got none.
259
350
 
260
351
  ### Tailwind CSS v4
261
352
 
262
- Declare the layer order before the imports, or the library will outrank every utility:
353
+ The same rule, the other way round. Declare the layer order before the imports, or the library will outrank every utility:
263
354
 
264
355
  ```css
265
356
  @layer theme, base, components, wake-marquee, utilities;
@@ -313,7 +404,7 @@ The lane is measured once per resize, and its width is the loop period. Content
313
404
 
314
405
  | | Size | Scroll-linked | Needs JS |
315
406
  |---|---|---|---|
316
- | **wake-marquee** | 3.4 kB | yes, direction and displacement | yes |
407
+ | **wake-marquee** | 3.9 kB | yes, direction and displacement | yes |
317
408
  | CSS `@keyframes` marquee | 0 | no | no |
318
409
  | [Marquee3k](https://github.com/ezekielaquino/Marquee3000) | ~2 kB | no | yes |
319
410
  | [react-fast-marquee](https://github.com/justin-chu/react-fast-marquee) | ~4 kB | no | yes, React only |
@@ -1 +1 @@
1
- var WakeMarquee=(()=>{var l=Object.defineProperty;var D=Object.getOwnPropertyDescriptor;var H=Object.getOwnPropertyNames;var B=Object.prototype.hasOwnProperty;var b=i=>{throw TypeError(i)};var Q=(i,e)=>{for(var t in e)l(i,t,{get:e[t],enumerable:!0})},_=(i,e,t,s)=>{if(e&&typeof e=="object"||typeof e=="function")for(let n of H(e))!B.call(i,n)&&n!==t&&l(i,n,{get:()=>e[n],enumerable:!(s=D(e,n))||s.enumerable});return i};var G=i=>_(l({},"__esModule",{value:!0}),i);var U=(i,e,t)=>e.has(i)||b("Cannot "+t);var y=(i,e,t)=>e.has(i)?b("Cannot add the same private member more than once"):e instanceof WeakSet?e.add(i):e.set(i,t);var o=(i,e,t)=>(U(i,e,"access private method"),t);var ie={};Q(ie,{initMarquees:()=>f});function j(i,e,t){return i<e?e:i>t?t:i}function x(i,e,t=1){return!(e>0)||!Number.isFinite(i)?1:Math.ceil(i/e)+1+t}function E(i,e,t,s){return s>0?((i+e*t)%s+s)%s:0}function M(i,e,t,s){return i+(e-i)*(1-Math.exp(-t*s))}function A(i,e,t){let s=t+e;return s>0?j((t-i)/s,0,1):0}function C(i,e,t){return-t*e*(1-2*i)}function S(i,e=.1){return!Number.isFinite(i)||i<=0?0:Math.min(i/1e3,e)}var K=Object.freeze({direction:"left",speed:60,wake:8,reverse:!0,ease:5,gap:null,fade:!1,pauseOnHover:!1,scroller:null,respectMotionPreference:!0}),L="data-wake-marquee",V="wake-lane",O="wake-track",Y=1,J="200px 0px";function X(i){return i.firstElementChild?.classList.contains(O)===!0}var u=new Set,p=new Map,m=0,c=0;function q(i){return i===window?window.scrollY:i.scrollTop}function Z(i){let e=p.get(i);if(!e){p.set(i,{last:q(i),sign:1});return}let t=q(i);Math.abs(t-e.last)>.5&&(e.sign=t>e.last?1:-1,e.last=t)}function P(i){return p.get(i)?.sign??1}function F(){return typeof matchMedia=="function"&&matchMedia("(prefers-reduced-motion: reduce)").matches}function W(i={}){let e={...K};for(let[t,s]of Object.entries(i))s!==void 0&&(e[t]=s);if(e.direction!=="left"&&e.direction!=="right")throw new RangeError(`wake-marquee: direction must be "left" or "right", received "${e.direction}"`);if(!Number.isFinite(e.speed)||e.speed<0)throw new RangeError(`wake-marquee: speed must be a non-negative number, received ${e.speed}`);if(!Number.isFinite(e.wake)||e.wake<0)throw new RangeError(`wake-marquee: wake must be a non-negative number, received ${e.wake}`);if(!Number.isFinite(e.ease)||e.ease<=0)throw new RangeError(`wake-marquee: ease must be a positive number, received ${e.ease}`);return e.scroller=e.scroller??(typeof window>"u"?null:window),e}function ee(i){let e=i.dataset,t={};return e.wakeDirection&&(t.direction=e.wakeDirection),e.wakeSpeed&&(t.speed=Number(e.wakeSpeed)),e.wake&&(t.wake=Number(e.wake)),e.wakeEase&&(t.ease=Number(e.wakeEase)),e.wakeGap&&(t.gap=e.wakeGap),e.wakeFade&&(t.fade=e.wakeFade),e.wakePauseOnHover!==void 0&&(t.pauseOnHover=!0),e.wakeReverse!==void 0&&(t.reverse=e.wakeReverse!=="false"),t}var r,R,N,g,I,v,T,$,k,w=class{constructor(e,t){y(this,r);this.element=e,this.options=W(t),this.dirSign=this.options.direction==="right"?1:-1,this.dirFactor=this.dirSign,this.offset=0,this.period=0,this.wakePx=0,this.amplitude=0,this.aligned=!1,this.visible=!1,this.measured=!1,this.paused=!1,this.hovered=!1,this.destroyed=!1,this.status="",o(this,r,R).call(this),o(this,r,I).call(this),u.add(this),o(this,r,v).call(this)}prime(){if(this.destroyed||this.measured)return this;let e=window.innerHeight,t=this.element.getBoundingClientRect(),s=200;return t.bottom<-s||t.top>e+s?this:(this.visible=!0,this.element.toggleAttribute("data-wake-active",!0),o(this,r,g).call(this),this)}refresh(){if(this.destroyed)return;let e=this.lane.getBoundingClientRect().width;if(!(e>0))return;this.period>0&&e!==this.period&&(this.offset=this.offset/this.period*e),this.period=e,this.measured=!0,o(this,r,N).call(this);let t=x(this.track.getBoundingClientRect().width,e,Y);for(;this.lanes.length<t;){let s=this.lane.cloneNode(!0);s.inert=!0,s.setAttribute("aria-hidden","true"),"inert"in s||s.querySelectorAll("a, button, input, select, textarea, [tabindex]").forEach(n=>n.setAttribute("tabindex","-1")),s.querySelectorAll('img[loading="lazy"]').forEach(n=>n.setAttribute("loading","eager")),this.track.appendChild(s),this.lanes.push(s)}for(;this.lanes.length>t;)this.lanes.pop()?.remove();d()}read(e){if(!o(this,r,k).call(this))return;let t=this.element.getBoundingClientRect(),s=A(t.top,t.height,e);this.amplitude=this.options.wake*t.width/100,this.wakePx=C(s,this.amplitude,this.dirSign)}write(e,t){if(!o(this,r,k).call(this))return;this.aligned||o(this,r,$).call(this);let s=this.hovered?0:this.options.reverse?this.dirSign*t:this.dirSign;this.dirFactor=M(this.dirFactor,s,e,this.options.ease),this.offset=E(this.offset,this.dirFactor*this.options.speed,e,this.period);let a=`translate3d(${(this.offset-this.period).toFixed(2)}px, 0, 0)`;for(let z of this.lanes)z.style.transform=a;this.track.style.transform=`translate3d(${this.wakePx.toFixed(2)}px, 0, 0)`;let h=t===1?"forward":"reversed";h!==this.status&&(this.status=h,this.element.setAttribute("data-wake-travel",h))}play(){return this.destroyed?this:this.options.respectMotionPreference&&F()?this:(this.paused=!1,d(),this)}pause(){return this.paused=!0,this}destroy(){if(!this.destroyed){this.destroyed=!0,u.delete(this),this.intersection?.disconnect(),this.resize?.disconnect(),this.onEnter&&this.element.removeEventListener("pointerenter",this.onEnter),this.onLeave&&this.element.removeEventListener("pointerleave",this.onLeave),this.motionQuery&&this.onMotionChange&&this.motionQuery.removeEventListener("change",this.onMotionChange);for(let e of this.lanes.slice(1))e.remove();for(;this.lane.firstChild;)this.element.appendChild(this.lane.firstChild);this.track.remove(),this.element.removeAttribute("data-wake-travel"),this.element.removeAttribute("data-wake-active");for(let e of this.undo)e();this.undo=[],this.lanes=[]}}};r=new WeakSet,R=function(){let e=this.element,t=e.ownerDocument;for(this.track=t.createElement("div"),this.track.className=O,this.lane=t.createElement("div"),this.lane.className=V;e.firstChild;)this.lane.appendChild(e.firstChild);this.track.appendChild(this.lane),e.appendChild(this.track),this.lanes=[this.lane],this.undo=[];let s=(a,h)=>{e.hasAttribute(a)||(e.setAttribute(a,h),this.undo.push(()=>e.removeAttribute(a)))},n=(a,h)=>{e.style.getPropertyValue(a)||(e.style.setProperty(a,h),this.undo.push(()=>e.style.removeProperty(a)))};s(L,""),this.options.gap&&n("--wake-gap",this.options.gap),this.options.fade&&(n("--wake-fade",this.options.fade),s("data-wake-fade",""))},N=function(){this.track.style.marginInlineStart=`-${this.options.wake}%`,this.track.style.width=`${100+this.options.wake*2}%`},g=function(){this.refresh(),this.period>0&&!this.paused&&(this.read(window.innerHeight),this.write(0,P(this.options.scroller)))},I=function(){let e=this.element;this.intersection=new IntersectionObserver(t=>{for(let s of t)this.visible=s.isIntersecting,e.toggleAttribute("data-wake-active",s.isIntersecting),s.isIntersecting&&!this.measured&&o(this,r,g).call(this);d()},{rootMargin:J,threshold:0}),this.intersection.observe(e),this.resize=new ResizeObserver(()=>{this.measured&&this.refresh()}),this.resize.observe(this.lane),this.resize.observe(e),this.options.pauseOnHover&&matchMedia("(hover: hover)").matches&&(this.onEnter=()=>{this.hovered=!0},this.onLeave=()=>{this.hovered=!1,d()},e.addEventListener("pointerenter",this.onEnter),e.addEventListener("pointerleave",this.onLeave)),this.options.respectMotionPreference&&typeof matchMedia=="function"&&(this.motionQuery=matchMedia("(prefers-reduced-motion: reduce)"),this.onMotionChange=()=>o(this,r,v).call(this),this.motionQuery.addEventListener("change",this.onMotionChange))},v=function(){if(!this.options.respectMotionPreference){d();return}F()?(this.paused=!0,o(this,r,T).call(this)):(this.paused=!1,d())},T=function(){this.offset=0,this.wakePx=0,this.aligned=!1;for(let e of this.lanes)e.style.transform="";this.track.style.transform=""},$=function(){this.aligned=!0;let e=this.amplitude-this.wakePx;this.offset=(e%this.period+this.period)%this.period},k=function(){return!this.destroyed&&!this.paused&&this.visible&&this.period>0};function te(i){let e=c===0?0:S(i-c);c=i;let t=window.innerHeight;for(let s of u)s.options.reverse&&Z(s.options.scroller),s.read(t);for(let s of u)s.write(e,s.options.reverse?P(s.options.scroller):1);m=0,d()}function d(){if(m!==0)return;let i=!1;for(let e of u)if(!e.destroyed&&!e.paused&&e.visible){i=!0;break}if(!i){c=0;return}m=requestAnimationFrame(te)}function f({root:i=document,defaults:e={}}={}){let t=[];for(let s of i.querySelectorAll(`[${L}]`))X(s)||t.push(new w(s,{...e,...ee(s)}));for(let s of t)s.prime();return t}typeof document<"u"&&(document.readyState==="loading"?document.addEventListener("DOMContentLoaded",()=>f(),{once:!0}):f());return G(ie);})();
1
+ var WakeMarquee=(()=>{var p=Object.defineProperty;var V=Object.getOwnPropertyDescriptor;var K=Object.getOwnPropertyNames;var Y=Object.prototype.hasOwnProperty;var b=i=>{throw TypeError(i)};var J=(i,e)=>{for(var t in e)p(i,t,{get:e[t],enumerable:!0})},W=(i,e,t,s)=>{if(e&&typeof e=="object"||typeof e=="function")for(let o of K(e))!Y.call(i,o)&&o!==t&&p(i,o,{get:()=>e[o],enumerable:!(s=V(e,o))||s.enumerable});return i};var X=i=>W(p({},"__esModule",{value:!0}),i);var Z=(i,e,t)=>e.has(i)||b("Cannot "+t);var E=(i,e,t)=>e.has(i)?b("Cannot add the same private member more than once"):e instanceof WeakSet?e.add(i):e.set(i,t);var n=(i,e,t)=>(Z(i,e,"access private method"),t);var fe={};J(fe,{initMarquees:()=>f});function ee(i,e,t){return i<e?e:i>t?t:i}function x(i,e,t=1){return!(e>0)||!Number.isFinite(i)?1:Math.ceil(i/e)+1+t}function M(i,e){if(typeof i=="number")return i;let t=parseFloat(i);return Number.isFinite(t)&&e>0?t*e/100:0}function C(i,e,t,s){return s>0?((i+e*t)%s+s)%s:0}function S(i,e,t,s){return i+(e-i)*(1-Math.exp(-t*s))}function A(i,e,t){let s=t+e;return s>0?ee((t-i)/s,0,1):0}function q(i,e,t){return-t*e*(1-2*i)}function F(i,e=.1){return!Number.isFinite(i)||i<=0?0:Math.min(i/1e3,e)}var te=Object.freeze({direction:"left",speed:60,wake:8,reverse:!0,ease:5,gap:null,fade:!1,pauseOnHover:!1,scroller:null,respectMotionPreference:!0}),T="data-wake-marquee",ie="wake-lane",I="wake-track",se=1,re="200px 0px",ne=3e3,oe=/^\d*\.?\d+%$/;function ae(i){return typeof i=="string"?oe.test(i):Number.isFinite(i)&&i>=0}function he(i){return i.firstElementChild?.classList.contains(I)===!0}var u=new Set,P=new WeakMap,m=new Map,w=0,c=0,L=!1,R=!1;function N(i){return i===window?window.scrollY:i.scrollTop}function de(i){let e=m.get(i);if(!e){m.set(i,{last:N(i),sign:1});return}let t=N(i);Math.abs(t-e.last)>.5&&(e.sign=t>e.last?1:-1,e.last=t)}function z(i){return m.get(i)?.sign??1}function O(){return typeof matchMedia=="function"&&matchMedia("(prefers-reduced-motion: reduce)").matches}function ue(i={}){let e={...te};for(let[t,s]of Object.entries(i))s!==void 0&&(e[t]=s);if(e.direction!=="left"&&e.direction!=="right")throw new RangeError(`wake-marquee: direction must be "left" or "right", received "${e.direction}"`);if(typeof e.speed=="string"&&(e.speed=e.speed.trim()),!ae(e.speed)){let t=typeof e.speed=="string"?`"${e.speed}"`:e.speed;throw new RangeError(`wake-marquee: speed must be a non-negative number of pixels per second, or a percentage of the container width per second such as "8%", received ${t}`)}if(!Number.isFinite(e.wake)||e.wake<0)throw new RangeError(`wake-marquee: wake must be a non-negative number, received ${e.wake}`);if(!Number.isFinite(e.ease)||e.ease<=0)throw new RangeError(`wake-marquee: ease must be a positive number, received ${e.ease}`);return e.scroller=e.scroller??(typeof window>"u"?null:window),e}function ce(i){let e=i.dataset,t={};if(e.wakeDirection&&(t.direction=e.wakeDirection),e.wakeSpeed){let s=e.wakeSpeed.trim();t.speed=Number.isFinite(Number(s))?Number(s):s}return e.wake&&(t.wake=Number(e.wake)),e.wakeEase&&(t.ease=Number(e.wakeEase)),e.wakeGap&&(t.gap=e.wakeGap),e.wakeFade&&(t.fade=e.wakeFade),e.wakePauseOnHover!==void 0&&(t.pauseOnHover=!0),e.wakeReverse!==void 0&&(t.reverse=e.wakeReverse!=="false"),t}var r,$,D,B,v,l,H,U,k,_,G,Q,y,g=class{constructor(e,t){E(this,r);this.element=e,this.options=ue(t),this.dirSign=this.options.direction==="right"?1:-1,this.dirFactor=this.dirSign,this.offset=0,this.period=0,this.speedPx=0,this.wakePx=0,this.amplitude=0,this.aligned=!1,this.waiting=null,this.visible=!1,this.measured=!1,this.paused=!1,this.hovered=!1,this.destroyed=!1,this.status="",n(this,r,$).call(this),n(this,r,U).call(this),u.add(this),P.set(e,this),n(this,r,k).call(this)}prime(){if(this.destroyed||this.measured)return this;let e=window.innerHeight,t=this.element.getBoundingClientRect(),s=200;return t.bottom<-s||t.top>e+s?this:(this.visible=!0,this.element.toggleAttribute("data-wake-active",!0),n(this,r,v).call(this),this)}refresh(){if(this.destroyed)return;let e=this.lane.getBoundingClientRect().width;if(!(e>0))return;this.period>0&&e!==this.period&&(this.offset=this.offset/this.period*e),this.measured||n(this,r,G).call(this),this.period=e,this.measured=!0,n(this,r,D).call(this);let t=x(this.track.getBoundingClientRect().width,e,se);for(;this.lanes.length<t;){let s=this.lane.cloneNode(!0);s.inert=!0,s.setAttribute("aria-hidden","true"),"inert"in s||s.querySelectorAll("a, button, input, select, textarea, [tabindex]").forEach(o=>o.setAttribute("tabindex","-1")),s.querySelectorAll('img[loading="lazy"]').forEach(o=>o.setAttribute("loading","eager")),s.style.transform=this.lane.style.transform,this.track.appendChild(s),this.lanes.push(s)}for(;this.lanes.length>t;)this.lanes.pop()?.remove();d()}read(e){if(!n(this,r,y).call(this))return;let t=this.element.getBoundingClientRect(),s=A(t.top,t.height,e);this.amplitude=this.options.wake*t.width/100,this.wakePx=q(s,this.amplitude,this.dirSign),this.speedPx=M(this.options.speed,t.width)}write(e,t){if(!n(this,r,y).call(this))return;this.aligned||n(this,r,Q).call(this);let s=this.hovered?0:this.options.reverse?this.dirSign*t:this.dirSign;this.dirFactor=S(this.dirFactor,s,e,this.options.ease),this.offset=C(this.offset,this.dirFactor*this.speedPx,e,this.period);let a=`translate3d(${(this.offset-this.period).toFixed(2)}px, 0, 0)`;for(let j of this.lanes)j.style.transform=a;this.track.style.transform=`translate3d(${this.wakePx.toFixed(2)}px, 0, 0)`;let h=t===1?"forward":"reversed";h!==this.status&&(this.status=h,this.element.setAttribute("data-wake-travel",h))}play(){return this.destroyed?this:this.options.respectMotionPreference&&O()?this:(this.paused=!1,d(),this)}pause(){return this.paused=!0,this}destroy(){if(!this.destroyed){this.destroyed=!0,u.delete(this),P.delete(this.element),this.waiting?.(),this.intersection?.disconnect(),this.resize?.disconnect(),this.onEnter&&this.element.removeEventListener("pointerenter",this.onEnter),this.onLeave&&this.element.removeEventListener("pointerleave",this.onLeave),this.motionQuery&&this.onMotionChange&&this.motionQuery.removeEventListener("change",this.onMotionChange);for(let e of this.lanes.slice(1))e.remove();for(;this.lane.firstChild;)this.element.appendChild(this.lane.firstChild);this.track.remove(),this.element.removeAttribute("data-wake-travel"),this.element.removeAttribute("data-wake-active");for(let e of this.undo)e();this.undo=[],this.lanes=[]}}};r=new WeakSet,$=function(){let e=this.element,t=e.ownerDocument;for(this.track=t.createElement("div"),this.track.className=I,this.lane=t.createElement("div"),this.lane.className=ie;e.firstChild;)this.lane.appendChild(e.firstChild);this.track.appendChild(this.lane),e.appendChild(this.track),this.lanes=[this.lane],this.undo=[];let s=(a,h)=>{e.hasAttribute(a)||(e.setAttribute(a,h),this.undo.push(()=>e.removeAttribute(a)))},o=(a,h)=>{e.style.getPropertyValue(a)||(e.style.setProperty(a,h),this.undo.push(()=>e.style.removeProperty(a)))};s(T,""),this.options.gap&&o("--wake-gap",this.options.gap),this.options.fade&&(o("--wake-fade",this.options.fade),s("data-wake-fade",""))},D=function(){this.track.style.marginInlineStart=`-${this.options.wake}%`,this.track.style.width=`${100+this.options.wake*2}%`},B=function(){let e=[];for(let t of this.lane.querySelectorAll("img"))!t.complete&&t.getBoundingClientRect().width===0&&e.push(t);return e},v=function(){if(this.waiting)return;let e=n(this,r,B).call(this);if(e.length===0){n(this,r,l).call(this);return}n(this,r,H).call(this);let t=e.length,s=()=>{--t>0||(a(),n(this,r,l).call(this))},o=setTimeout(()=>{a(),n(this,r,l).call(this)},ne),a=()=>{this.waiting=null,clearTimeout(o);for(let h of e)h.removeEventListener("load",s),h.removeEventListener("error",s)};this.waiting=a;for(let h of e)h.addEventListener("load",s),h.addEventListener("error",s)},l=function(){this.destroyed||(this.refresh(),this.period>0&&!this.paused&&(this.read(window.innerHeight),this.write(0,z(this.options.scroller))))},H=function(){R||(R=!0,console.warn("wake-marquee: an item holds no width until it loads, so the loop period is not measurable yet and the row is waiting. Give each <img> its width and height attributes, or an aspect-ratio, and it starts immediately."))},U=function(){let e=this.element;this.intersection=new IntersectionObserver(t=>{for(let s of t)this.visible=s.isIntersecting,e.toggleAttribute("data-wake-active",s.isIntersecting),s.isIntersecting&&!this.measured&&n(this,r,v).call(this);d()},{rootMargin:re,threshold:0}),this.intersection.observe(e),this.resize=new ResizeObserver(()=>{this.measured&&this.refresh()}),this.resize.observe(this.lane),this.resize.observe(e),this.options.pauseOnHover&&matchMedia("(hover: hover)").matches&&(this.onEnter=()=>{this.hovered=!0},this.onLeave=()=>{this.hovered=!1,d()},e.addEventListener("pointerenter",this.onEnter),e.addEventListener("pointerleave",this.onLeave)),this.options.respectMotionPreference&&typeof matchMedia=="function"&&(this.motionQuery=matchMedia("(prefers-reduced-motion: reduce)"),this.onMotionChange=()=>n(this,r,k).call(this),this.motionQuery.addEventListener("change",this.onMotionChange))},k=function(){if(!this.options.respectMotionPreference){d();return}O()?(this.paused=!0,n(this,r,_).call(this)):(this.paused=!1,d())},_=function(){this.offset=0,this.wakePx=0,this.aligned=!1;for(let e of this.lanes)e.style.transform="";this.track.style.transform=""},G=function(){if(L)return;let e=this.lane.firstElementChild;if(!e)return;let t=getComputedStyle(e),s=t.getPropertyValue("--wake-gap").trim();!((s===""?32:parseFloat(s))>0)||parseFloat(t.marginInlineEnd)!==0||(L=!0,console.warn("wake-marquee: the items have no spacing. An unlayered CSS reset outranks @layer wake-marquee whatever the specificity says. Put the reset in a layer and name the order first: @layer reset, wake-marquee;"))},Q=function(){this.aligned=!0;let e=this.amplitude-this.wakePx;this.offset=(e%this.period+this.period)%this.period},y=function(){return!this.destroyed&&!this.paused&&this.visible&&this.period>0};function le(i){let e=c===0?0:F(i-c);c=i;let t=window.innerHeight;for(let s of u)s.options.reverse&&de(s.options.scroller),s.read(t);for(let s of u)s.write(e,s.options.reverse?z(s.options.scroller):1);w=0,d()}function d(){if(w!==0)return;let i=!1;for(let e of u)if(!e.destroyed&&!e.paused&&e.visible){i=!0;break}if(!i){c=0;return}w=requestAnimationFrame(le)}function f({root:i=document,defaults:e={}}={}){let t=[];for(let s of i.querySelectorAll(`[${T}]`))he(s)||t.push(new g(s,{...e,...ce(s)}));for(let s of t)s.prime();return t}typeof document<"u"&&(document.readyState==="loading"?document.addEventListener("DOMContentLoaded",()=>f(),{once:!0}):f());return X(fe);})();
@@ -1 +1 @@
1
- var v=i=>{throw TypeError(i)};var z=(i,e,t)=>e.has(i)||v("Cannot "+t);var k=(i,e,t)=>e.has(i)?v("Cannot add the same private member more than once"):e instanceof WeakSet?e.add(i):e.set(i,t);var n=(i,e,t)=>(z(i,e,"access private method"),t);function D(i,e,t){return i<e?e:i>t?t:i}function b(i,e,t=1){return!(e>0)||!Number.isFinite(i)?1:Math.ceil(i/e)+1+t}function y(i,e,t,s){return s>0?((i+e*t)%s+s)%s:0}function x(i,e,t,s){return i+(e-i)*(1-Math.exp(-t*s))}function E(i,e,t){let s=t+e;return s>0?D((t-i)/s,0,1):0}function A(i,e,t){return-t*e*(1-2*i)}function M(i,e=.1){return!Number.isFinite(i)||i<=0?0:Math.min(i/1e3,e)}var H=Object.freeze({direction:"left",speed:60,wake:8,reverse:!0,ease:5,gap:null,fade:!1,pauseOnHover:!1,scroller:null,respectMotionPreference:!0}),q="data-wake-marquee",B="wake-lane",F="wake-track",Q=1,_="200px 0px";function P(i){return i.firstElementChild?.classList.contains(F)===!0}var u=new Set,l=new Map,p=0,c=0;function C(i){return i===window?window.scrollY:i.scrollTop}function G(i){let e=l.get(i);if(!e){l.set(i,{last:C(i),sign:1});return}let t=C(i);Math.abs(t-e.last)>.5&&(e.sign=t>e.last?1:-1,e.last=t)}function R(i){return l.get(i)?.sign??1}function S(){return typeof matchMedia=="function"&&matchMedia("(prefers-reduced-motion: reduce)").matches}function U(i={}){let e={...H};for(let[t,s]of Object.entries(i))s!==void 0&&(e[t]=s);if(e.direction!=="left"&&e.direction!=="right")throw new RangeError(`wake-marquee: direction must be "left" or "right", received "${e.direction}"`);if(!Number.isFinite(e.speed)||e.speed<0)throw new RangeError(`wake-marquee: speed must be a non-negative number, received ${e.speed}`);if(!Number.isFinite(e.wake)||e.wake<0)throw new RangeError(`wake-marquee: wake must be a non-negative number, received ${e.wake}`);if(!Number.isFinite(e.ease)||e.ease<=0)throw new RangeError(`wake-marquee: ease must be a positive number, received ${e.ease}`);return e.scroller=e.scroller??(typeof window>"u"?null:window),e}function j(i){let e=i.dataset,t={};return e.wakeDirection&&(t.direction=e.wakeDirection),e.wakeSpeed&&(t.speed=Number(e.wakeSpeed)),e.wake&&(t.wake=Number(e.wake)),e.wakeEase&&(t.ease=Number(e.wakeEase)),e.wakeGap&&(t.gap=e.wakeGap),e.wakeFade&&(t.fade=e.wakeFade),e.wakePauseOnHover!==void 0&&(t.pauseOnHover=!0),e.wakeReverse!==void 0&&(t.reverse=e.wakeReverse!=="false"),t}var r,L,O,w,N,m,I,T,g,f=class{constructor(e,t){k(this,r);this.element=e,this.options=U(t),this.dirSign=this.options.direction==="right"?1:-1,this.dirFactor=this.dirSign,this.offset=0,this.period=0,this.wakePx=0,this.amplitude=0,this.aligned=!1,this.visible=!1,this.measured=!1,this.paused=!1,this.hovered=!1,this.destroyed=!1,this.status="",n(this,r,L).call(this),n(this,r,N).call(this),u.add(this),n(this,r,m).call(this)}prime(){if(this.destroyed||this.measured)return this;let e=window.innerHeight,t=this.element.getBoundingClientRect(),s=200;return t.bottom<-s||t.top>e+s?this:(this.visible=!0,this.element.toggleAttribute("data-wake-active",!0),n(this,r,w).call(this),this)}refresh(){if(this.destroyed)return;let e=this.lane.getBoundingClientRect().width;if(!(e>0))return;this.period>0&&e!==this.period&&(this.offset=this.offset/this.period*e),this.period=e,this.measured=!0,n(this,r,O).call(this);let t=b(this.track.getBoundingClientRect().width,e,Q);for(;this.lanes.length<t;){let s=this.lane.cloneNode(!0);s.inert=!0,s.setAttribute("aria-hidden","true"),"inert"in s||s.querySelectorAll("a, button, input, select, textarea, [tabindex]").forEach(a=>a.setAttribute("tabindex","-1")),s.querySelectorAll('img[loading="lazy"]').forEach(a=>a.setAttribute("loading","eager")),this.track.appendChild(s),this.lanes.push(s)}for(;this.lanes.length>t;)this.lanes.pop()?.remove();d()}read(e){if(!n(this,r,g).call(this))return;let t=this.element.getBoundingClientRect(),s=E(t.top,t.height,e);this.amplitude=this.options.wake*t.width/100,this.wakePx=A(s,this.amplitude,this.dirSign)}write(e,t){if(!n(this,r,g).call(this))return;this.aligned||n(this,r,T).call(this);let s=this.hovered?0:this.options.reverse?this.dirSign*t:this.dirSign;this.dirFactor=x(this.dirFactor,s,e,this.options.ease),this.offset=y(this.offset,this.dirFactor*this.options.speed,e,this.period);let o=`translate3d(${(this.offset-this.period).toFixed(2)}px, 0, 0)`;for(let $ of this.lanes)$.style.transform=o;this.track.style.transform=`translate3d(${this.wakePx.toFixed(2)}px, 0, 0)`;let h=t===1?"forward":"reversed";h!==this.status&&(this.status=h,this.element.setAttribute("data-wake-travel",h))}play(){return this.destroyed?this:this.options.respectMotionPreference&&S()?this:(this.paused=!1,d(),this)}pause(){return this.paused=!0,this}destroy(){if(!this.destroyed){this.destroyed=!0,u.delete(this),this.intersection?.disconnect(),this.resize?.disconnect(),this.onEnter&&this.element.removeEventListener("pointerenter",this.onEnter),this.onLeave&&this.element.removeEventListener("pointerleave",this.onLeave),this.motionQuery&&this.onMotionChange&&this.motionQuery.removeEventListener("change",this.onMotionChange);for(let e of this.lanes.slice(1))e.remove();for(;this.lane.firstChild;)this.element.appendChild(this.lane.firstChild);this.track.remove(),this.element.removeAttribute("data-wake-travel"),this.element.removeAttribute("data-wake-active");for(let e of this.undo)e();this.undo=[],this.lanes=[]}}};r=new WeakSet,L=function(){let e=this.element,t=e.ownerDocument;for(this.track=t.createElement("div"),this.track.className=F,this.lane=t.createElement("div"),this.lane.className=B;e.firstChild;)this.lane.appendChild(e.firstChild);this.track.appendChild(this.lane),e.appendChild(this.track),this.lanes=[this.lane],this.undo=[];let s=(o,h)=>{e.hasAttribute(o)||(e.setAttribute(o,h),this.undo.push(()=>e.removeAttribute(o)))},a=(o,h)=>{e.style.getPropertyValue(o)||(e.style.setProperty(o,h),this.undo.push(()=>e.style.removeProperty(o)))};s(q,""),this.options.gap&&a("--wake-gap",this.options.gap),this.options.fade&&(a("--wake-fade",this.options.fade),s("data-wake-fade",""))},O=function(){this.track.style.marginInlineStart=`-${this.options.wake}%`,this.track.style.width=`${100+this.options.wake*2}%`},w=function(){this.refresh(),this.period>0&&!this.paused&&(this.read(window.innerHeight),this.write(0,R(this.options.scroller)))},N=function(){let e=this.element;this.intersection=new IntersectionObserver(t=>{for(let s of t)this.visible=s.isIntersecting,e.toggleAttribute("data-wake-active",s.isIntersecting),s.isIntersecting&&!this.measured&&n(this,r,w).call(this);d()},{rootMargin:_,threshold:0}),this.intersection.observe(e),this.resize=new ResizeObserver(()=>{this.measured&&this.refresh()}),this.resize.observe(this.lane),this.resize.observe(e),this.options.pauseOnHover&&matchMedia("(hover: hover)").matches&&(this.onEnter=()=>{this.hovered=!0},this.onLeave=()=>{this.hovered=!1,d()},e.addEventListener("pointerenter",this.onEnter),e.addEventListener("pointerleave",this.onLeave)),this.options.respectMotionPreference&&typeof matchMedia=="function"&&(this.motionQuery=matchMedia("(prefers-reduced-motion: reduce)"),this.onMotionChange=()=>n(this,r,m).call(this),this.motionQuery.addEventListener("change",this.onMotionChange))},m=function(){if(!this.options.respectMotionPreference){d();return}S()?(this.paused=!0,n(this,r,I).call(this)):(this.paused=!1,d())},I=function(){this.offset=0,this.wakePx=0,this.aligned=!1;for(let e of this.lanes)e.style.transform="";this.track.style.transform=""},T=function(){this.aligned=!0;let e=this.amplitude-this.wakePx;this.offset=(e%this.period+this.period)%this.period},g=function(){return!this.destroyed&&!this.paused&&this.visible&&this.period>0};function K(i){let e=c===0?0:M(i-c);c=i;let t=window.innerHeight;for(let s of u)s.options.reverse&&G(s.options.scroller),s.read(t);for(let s of u)s.write(e,s.options.reverse?R(s.options.scroller):1);p=0,d()}function d(){if(p!==0)return;let i=!1;for(let e of u)if(!e.destroyed&&!e.paused&&e.visible){i=!0;break}if(!i){c=0;return}p=requestAnimationFrame(K)}function Z(i,e={}){if(!i||i.nodeType!==1)throw new TypeError("wake-marquee: createMarquee expects an element");if(P(i))throw new Error("wake-marquee: this element is already a marquee, call destroy() first");return new f(i,e).prime()}function W({root:i=document,defaults:e={}}={}){let t=[];for(let s of i.querySelectorAll(`[${q}]`))P(s)||t.push(new f(s,{...e,...j(s)}));for(let s of t)s.prime();return t}export{f as Marquee,Z as createMarquee,H as defaults,W as initMarquees,U as normalizeOptions,j as readOptions};
1
+ var y=i=>{throw TypeError(i)};var j=(i,e,t)=>e.has(i)||y("Cannot "+t);var b=(i,e,t)=>e.has(i)?y("Cannot add the same private member more than once"):e instanceof WeakSet?e.add(i):e.set(i,t);var n=(i,e,t)=>(j(i,e,"access private method"),t);function V(i,e,t){return i<e?e:i>t?t:i}function E(i,e,t=1){return!(e>0)||!Number.isFinite(i)?1:Math.ceil(i/e)+1+t}function x(i,e){if(typeof i=="number")return i;let t=parseFloat(i);return Number.isFinite(t)&&e>0?t*e/100:0}function C(i,e,t,s){return s>0?((i+e*t)%s+s)%s:0}function M(i,e,t,s){return i+(e-i)*(1-Math.exp(-t*s))}function S(i,e,t){let s=t+e;return s>0?V((t-i)/s,0,1):0}function A(i,e,t){return-t*e*(1-2*i)}function q(i,e=.1){return!Number.isFinite(i)||i<=0?0:Math.min(i/1e3,e)}var K=Object.freeze({direction:"left",speed:60,wake:8,reverse:!0,ease:5,gap:null,fade:!1,pauseOnHover:!1,scroller:null,respectMotionPreference:!0}),N="data-wake-marquee",Y="wake-lane",O="wake-track",J=1,W="200px 0px",X=3e3,Z=/^\d*\.?\d+%$/;function ee(i){return typeof i=="string"?Z.test(i):Number.isFinite(i)&&i>=0}function T(i){return i.firstElementChild?.classList.contains(O)===!0}var u=new Set,p=new WeakMap,m=new Map,w=0,l=0,F=!1,P=!1;function L(i){return i===window?window.scrollY:i.scrollTop}function te(i){let e=m.get(i);if(!e){m.set(i,{last:L(i),sign:1});return}let t=L(i);Math.abs(t-e.last)>.5&&(e.sign=t>e.last?1:-1,e.last=t)}function I(i){return m.get(i)?.sign??1}function R(){return typeof matchMedia=="function"&&matchMedia("(prefers-reduced-motion: reduce)").matches}function ie(i={}){let e={...K};for(let[t,s]of Object.entries(i))s!==void 0&&(e[t]=s);if(e.direction!=="left"&&e.direction!=="right")throw new RangeError(`wake-marquee: direction must be "left" or "right", received "${e.direction}"`);if(typeof e.speed=="string"&&(e.speed=e.speed.trim()),!ee(e.speed)){let t=typeof e.speed=="string"?`"${e.speed}"`:e.speed;throw new RangeError(`wake-marquee: speed must be a non-negative number of pixels per second, or a percentage of the container width per second such as "8%", received ${t}`)}if(!Number.isFinite(e.wake)||e.wake<0)throw new RangeError(`wake-marquee: wake must be a non-negative number, received ${e.wake}`);if(!Number.isFinite(e.ease)||e.ease<=0)throw new RangeError(`wake-marquee: ease must be a positive number, received ${e.ease}`);return e.scroller=e.scroller??(typeof window>"u"?null:window),e}function se(i){let e=i.dataset,t={};if(e.wakeDirection&&(t.direction=e.wakeDirection),e.wakeSpeed){let s=e.wakeSpeed.trim();t.speed=Number.isFinite(Number(s))?Number(s):s}return e.wake&&(t.wake=Number(e.wake)),e.wakeEase&&(t.ease=Number(e.wakeEase)),e.wakeGap&&(t.gap=e.wakeGap),e.wakeFade&&(t.fade=e.wakeFade),e.wakePauseOnHover!==void 0&&(t.pauseOnHover=!0),e.wakeReverse!==void 0&&(t.reverse=e.wakeReverse!=="false"),t}var r,z,$,D,g,c,B,H,v,U,_,G,k,f=class{constructor(e,t){b(this,r);this.element=e,this.options=ie(t),this.dirSign=this.options.direction==="right"?1:-1,this.dirFactor=this.dirSign,this.offset=0,this.period=0,this.speedPx=0,this.wakePx=0,this.amplitude=0,this.aligned=!1,this.waiting=null,this.visible=!1,this.measured=!1,this.paused=!1,this.hovered=!1,this.destroyed=!1,this.status="",n(this,r,z).call(this),n(this,r,H).call(this),u.add(this),p.set(e,this),n(this,r,v).call(this)}prime(){if(this.destroyed||this.measured)return this;let e=window.innerHeight,t=this.element.getBoundingClientRect(),s=200;return t.bottom<-s||t.top>e+s?this:(this.visible=!0,this.element.toggleAttribute("data-wake-active",!0),n(this,r,g).call(this),this)}refresh(){if(this.destroyed)return;let e=this.lane.getBoundingClientRect().width;if(!(e>0))return;this.period>0&&e!==this.period&&(this.offset=this.offset/this.period*e),this.measured||n(this,r,_).call(this),this.period=e,this.measured=!0,n(this,r,$).call(this);let t=E(this.track.getBoundingClientRect().width,e,J);for(;this.lanes.length<t;){let s=this.lane.cloneNode(!0);s.inert=!0,s.setAttribute("aria-hidden","true"),"inert"in s||s.querySelectorAll("a, button, input, select, textarea, [tabindex]").forEach(h=>h.setAttribute("tabindex","-1")),s.querySelectorAll('img[loading="lazy"]').forEach(h=>h.setAttribute("loading","eager")),s.style.transform=this.lane.style.transform,this.track.appendChild(s),this.lanes.push(s)}for(;this.lanes.length>t;)this.lanes.pop()?.remove();d()}read(e){if(!n(this,r,k).call(this))return;let t=this.element.getBoundingClientRect(),s=S(t.top,t.height,e);this.amplitude=this.options.wake*t.width/100,this.wakePx=A(s,this.amplitude,this.dirSign),this.speedPx=x(this.options.speed,t.width)}write(e,t){if(!n(this,r,k).call(this))return;this.aligned||n(this,r,G).call(this);let s=this.hovered?0:this.options.reverse?this.dirSign*t:this.dirSign;this.dirFactor=M(this.dirFactor,s,e,this.options.ease),this.offset=C(this.offset,this.dirFactor*this.speedPx,e,this.period);let o=`translate3d(${(this.offset-this.period).toFixed(2)}px, 0, 0)`;for(let Q of this.lanes)Q.style.transform=o;this.track.style.transform=`translate3d(${this.wakePx.toFixed(2)}px, 0, 0)`;let a=t===1?"forward":"reversed";a!==this.status&&(this.status=a,this.element.setAttribute("data-wake-travel",a))}play(){return this.destroyed?this:this.options.respectMotionPreference&&R()?this:(this.paused=!1,d(),this)}pause(){return this.paused=!0,this}destroy(){if(!this.destroyed){this.destroyed=!0,u.delete(this),p.delete(this.element),this.waiting?.(),this.intersection?.disconnect(),this.resize?.disconnect(),this.onEnter&&this.element.removeEventListener("pointerenter",this.onEnter),this.onLeave&&this.element.removeEventListener("pointerleave",this.onLeave),this.motionQuery&&this.onMotionChange&&this.motionQuery.removeEventListener("change",this.onMotionChange);for(let e of this.lanes.slice(1))e.remove();for(;this.lane.firstChild;)this.element.appendChild(this.lane.firstChild);this.track.remove(),this.element.removeAttribute("data-wake-travel"),this.element.removeAttribute("data-wake-active");for(let e of this.undo)e();this.undo=[],this.lanes=[]}}};r=new WeakSet,z=function(){let e=this.element,t=e.ownerDocument;for(this.track=t.createElement("div"),this.track.className=O,this.lane=t.createElement("div"),this.lane.className=Y;e.firstChild;)this.lane.appendChild(e.firstChild);this.track.appendChild(this.lane),e.appendChild(this.track),this.lanes=[this.lane],this.undo=[];let s=(o,a)=>{e.hasAttribute(o)||(e.setAttribute(o,a),this.undo.push(()=>e.removeAttribute(o)))},h=(o,a)=>{e.style.getPropertyValue(o)||(e.style.setProperty(o,a),this.undo.push(()=>e.style.removeProperty(o)))};s(N,""),this.options.gap&&h("--wake-gap",this.options.gap),this.options.fade&&(h("--wake-fade",this.options.fade),s("data-wake-fade",""))},$=function(){this.track.style.marginInlineStart=`-${this.options.wake}%`,this.track.style.width=`${100+this.options.wake*2}%`},D=function(){let e=[];for(let t of this.lane.querySelectorAll("img"))!t.complete&&t.getBoundingClientRect().width===0&&e.push(t);return e},g=function(){if(this.waiting)return;let e=n(this,r,D).call(this);if(e.length===0){n(this,r,c).call(this);return}n(this,r,B).call(this);let t=e.length,s=()=>{--t>0||(o(),n(this,r,c).call(this))},h=setTimeout(()=>{o(),n(this,r,c).call(this)},X),o=()=>{this.waiting=null,clearTimeout(h);for(let a of e)a.removeEventListener("load",s),a.removeEventListener("error",s)};this.waiting=o;for(let a of e)a.addEventListener("load",s),a.addEventListener("error",s)},c=function(){this.destroyed||(this.refresh(),this.period>0&&!this.paused&&(this.read(window.innerHeight),this.write(0,I(this.options.scroller))))},B=function(){P||(P=!0,console.warn("wake-marquee: an item holds no width until it loads, so the loop period is not measurable yet and the row is waiting. Give each <img> its width and height attributes, or an aspect-ratio, and it starts immediately."))},H=function(){let e=this.element;this.intersection=new IntersectionObserver(t=>{for(let s of t)this.visible=s.isIntersecting,e.toggleAttribute("data-wake-active",s.isIntersecting),s.isIntersecting&&!this.measured&&n(this,r,g).call(this);d()},{rootMargin:W,threshold:0}),this.intersection.observe(e),this.resize=new ResizeObserver(()=>{this.measured&&this.refresh()}),this.resize.observe(this.lane),this.resize.observe(e),this.options.pauseOnHover&&matchMedia("(hover: hover)").matches&&(this.onEnter=()=>{this.hovered=!0},this.onLeave=()=>{this.hovered=!1,d()},e.addEventListener("pointerenter",this.onEnter),e.addEventListener("pointerleave",this.onLeave)),this.options.respectMotionPreference&&typeof matchMedia=="function"&&(this.motionQuery=matchMedia("(prefers-reduced-motion: reduce)"),this.onMotionChange=()=>n(this,r,v).call(this),this.motionQuery.addEventListener("change",this.onMotionChange))},v=function(){if(!this.options.respectMotionPreference){d();return}R()?(this.paused=!0,n(this,r,U).call(this)):(this.paused=!1,d())},U=function(){this.offset=0,this.wakePx=0,this.aligned=!1;for(let e of this.lanes)e.style.transform="";this.track.style.transform=""},_=function(){if(F)return;let e=this.lane.firstElementChild;if(!e)return;let t=getComputedStyle(e),s=t.getPropertyValue("--wake-gap").trim();!((s===""?32:parseFloat(s))>0)||parseFloat(t.marginInlineEnd)!==0||(F=!0,console.warn("wake-marquee: the items have no spacing. An unlayered CSS reset outranks @layer wake-marquee whatever the specificity says. Put the reset in a layer and name the order first: @layer reset, wake-marquee;"))},G=function(){this.aligned=!0;let e=this.amplitude-this.wakePx;this.offset=(e%this.period+this.period)%this.period},k=function(){return!this.destroyed&&!this.paused&&this.visible&&this.period>0};function re(i){let e=l===0?0:q(i-l);l=i;let t=window.innerHeight;for(let s of u)s.options.reverse&&te(s.options.scroller),s.read(t);for(let s of u)s.write(e,s.options.reverse?I(s.options.scroller):1);w=0,d()}function d(){if(w!==0)return;let i=!1;for(let e of u)if(!e.destroyed&&!e.paused&&e.visible){i=!0;break}if(!i){l=0;return}w=requestAnimationFrame(re)}function de(i,e={}){if(!i||i.nodeType!==1)throw new TypeError("wake-marquee: createMarquee expects an element");if(T(i))throw new Error("wake-marquee: this element is already a marquee, call destroy() first");return new f(i,e).prime()}function ue({root:i=document,defaults:e={}}={}){let t=[];for(let s of i.querySelectorAll(`[${N}]`))T(s)||t.push(new f(s,{...e,...se(s)}));for(let s of t)s.prime();return t}function le(i){return i&&p.get(i)||null}export{f as Marquee,de as createMarquee,K as defaults,le as getMarquee,ue as initMarquees,ie as normalizeOptions,se as readOptions};
@@ -8,12 +8,22 @@
8
8
  * Options are rendered as `data-wake-*` attributes and read back by the
9
9
  * script, so the server sends a plain clipped row and the client enhances it.
10
10
  * The row is on screen and correctly laid out before any JavaScript arrives.
11
+ *
12
+ * The script below starts the row and keeps no handle, because a hoisted Astro
13
+ * script has nowhere to hand one to. Ask for it by element instead:
14
+ *
15
+ * import { getMarquee } from 'wake-marquee'
16
+ * getMarquee(document.querySelector('#logos'))?.pause()
11
17
  */
12
18
  interface Props {
13
19
  /** Travel direction while scrolling down. Scrolling up reverses it. */
14
20
  direction?: 'left' | 'right';
15
- /** Travel speed in pixels per second. */
16
- speed?: number;
21
+ /**
22
+ * Travel speed in pixels per second, or a percentage of the container width
23
+ * per second (`"8%"`). The percentage keeps the pace the same on a phone
24
+ * and on a desktop; a fixed pixel speed does not.
25
+ */
26
+ speed?: number | `${number}%`;
17
27
  /** Scroll-driven displacement, in percent of the container width. 0 turns it off. */
18
28
  wake?: number;
19
29
  /** Whether scrolling up reverses travel. */
@@ -74,7 +84,9 @@ const style = [gap && `--wake-gap:${gap}`, fade && `--wake-fade:${fade}`]
74
84
 
75
85
  <script>
76
86
  // Astro hoists and deduplicates this, so a page with ten marquees still
77
- // ships one copy of the library and runs one initialisation pass.
87
+ // ships one copy of the library and runs one initialisation pass. The
88
+ // handles it returns are dropped on purpose: there is no way to pass them
89
+ // back to the page from here, and getMarquee(element) finds them again.
78
90
  import { initMarquees } from 'wake-marquee';
79
91
  initMarquees();
80
92
  </script>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wake-marquee",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "An endless marquee that answers to the scroll: it reverses when the reader scrolls back, and drags against its own direction of travel as it crosses the viewport.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/marquee.js CHANGED
@@ -23,13 +23,24 @@
23
23
  * @module wake-marquee
24
24
  */
25
25
 
26
- import { advance, clamp, easeDirection, frameDelta, laneCount, viewProgress, wakeOffset } from './motion.js';
26
+ import {
27
+ advance,
28
+ clamp,
29
+ easeDirection,
30
+ frameDelta,
31
+ laneCount,
32
+ resolveSpeed,
33
+ viewProgress,
34
+ wakeOffset,
35
+ } from './motion.js';
27
36
 
28
37
  /**
29
38
  * @typedef {object} MarqueeOptions
30
39
  * @property {'left' | 'right'} [direction='left'] Travel direction while the
31
40
  * reader scrolls down. Scrolling up reverses it unless `reverse` is off.
32
- * @property {number} [speed=60] Travel speed in pixels per second.
41
+ * @property {number | string} [speed=60] Travel speed in pixels per second,
42
+ * or a percentage of the container width per second, e.g. `'8%'`. The
43
+ * percentage is the one that reads the same on a phone and on a desktop.
33
44
  * @property {number} [wake=8] Scroll-driven displacement, as a percentage of
34
45
  * the container width. `0` turns the wake off and leaves a plain loop.
35
46
  * @property {boolean} [reverse=true] Whether scrolling up reverses travel.
@@ -73,6 +84,32 @@ const LANE_BUFFER = 1;
73
84
  /** How far outside the viewport an instance starts running, in px. */
74
85
  const ROOT_MARGIN = '200px 0px';
75
86
 
87
+ /**
88
+ * How long a row waits for an image that reserves no space of its own before
89
+ * it gives up and starts anyway, in ms. Generous on purpose: standing still a
90
+ * moment longer is invisible, and starting early is the bug this guards.
91
+ */
92
+ const MEDIA_TIMEOUT = 3000;
93
+
94
+ /** The relative form of `speed`: `'8%'` of the container width, per second. */
95
+ const PERCENT = /^\d*\.?\d+%$/;
96
+
97
+ /**
98
+ * Is this a speed the loop can run at: px per second, or a percentage of the
99
+ * container width per second?
100
+ *
101
+ * Deliberately no time form. `'9.6s'` for one container width reads well until
102
+ * it sits next to the number form, where it reverses the direction of the
103
+ * option: `'12s'` would be slower than `'6s'` while `120` is faster than `60`.
104
+ *
105
+ * @param {unknown} value
106
+ * @returns {boolean}
107
+ */
108
+ function isSpeed(value) {
109
+ if (typeof value === 'string') return PERCENT.test(value);
110
+ return Number.isFinite(value) && /** @type {number} */ (value) >= 0;
111
+ }
112
+
76
113
  /**
77
114
  * Has this element already been turned into a marquee?
78
115
  *
@@ -91,12 +128,26 @@ function isInitialised(el) {
91
128
  /** @type {Set<Marquee>} Every live instance, driven by the one shared loop. */
92
129
  const registry = new Set();
93
130
 
131
+ /**
132
+ * @type {WeakMap<Element, Marquee>} The same instances, indexed by element, so
133
+ * a handle can be found again by whoever ends up needing one. Weak because the
134
+ * key is the caller's element: a row removed from the document should not be
135
+ * held alive by this.
136
+ */
137
+ const byElement = new WeakMap();
138
+
94
139
  /** @type {Map<Window | HTMLElement, {last: number, sign: number}>} */
95
140
  const scrollers = new Map();
96
141
 
97
142
  let rafId = 0;
98
143
  let lastFrame = 0;
99
144
 
145
+ /** Warned about collapsed spacing once? A global reset breaks every row. */
146
+ let warnedSpacing = false;
147
+
148
+ /** Warned about an image with no reserved space once? Same reasoning. */
149
+ let warnedUnsized = false;
150
+
100
151
  /**
101
152
  * Read the scroll offset of either the window or an overflow container.
102
153
  * @param {Window | HTMLElement} scroller
@@ -167,8 +218,13 @@ export function normalizeOptions(options = {}) {
167
218
  if (config.direction !== 'left' && config.direction !== 'right') {
168
219
  throw new RangeError(`wake-marquee: direction must be "left" or "right", received "${config.direction}"`);
169
220
  }
170
- if (!Number.isFinite(config.speed) || config.speed < 0) {
171
- throw new RangeError(`wake-marquee: speed must be a non-negative number, received ${config.speed}`);
221
+ if (typeof config.speed === 'string') config.speed = config.speed.trim();
222
+ if (!isSpeed(config.speed)) {
223
+ const received = typeof config.speed === 'string' ? `"${config.speed}"` : config.speed;
224
+ throw new RangeError(
225
+ 'wake-marquee: speed must be a non-negative number of pixels per second, or a percentage ' +
226
+ `of the container width per second such as "8%", received ${received}`,
227
+ );
172
228
  }
173
229
  if (!Number.isFinite(config.wake) || config.wake < 0) {
174
230
  throw new RangeError(`wake-marquee: wake must be a non-negative number, received ${config.wake}`);
@@ -197,7 +253,13 @@ export function readOptions(el) {
197
253
  const options = {};
198
254
 
199
255
  if (d.wakeDirection) options.direction = /** @type {'left' | 'right'} */ (d.wakeDirection);
200
- if (d.wakeSpeed) options.speed = Number(d.wakeSpeed);
256
+ // A percentage passes through as the string it is. Anything else that is
257
+ // not a number passes through too, so normalisation can name it in the
258
+ // error rather than reporting the NaN that Number() would have made of it.
259
+ if (d.wakeSpeed) {
260
+ const speed = d.wakeSpeed.trim();
261
+ options.speed = Number.isFinite(Number(speed)) ? Number(speed) : speed;
262
+ }
201
263
  if (d.wake) options.wake = Number(d.wake);
202
264
  if (d.wakeEase) options.ease = Number(d.wakeEase);
203
265
  if (d.wakeGap) options.gap = d.wakeGap;
@@ -231,6 +293,8 @@ class Marquee {
231
293
  this.offset = 0;
232
294
  /** Width of one lane in px: the loop's repeat distance. */
233
295
  this.period = 0;
296
+ /** Last resolved travel speed in px per second. */
297
+ this.speedPx = 0;
234
298
  /** Last written wake displacement in px, kept so a paused frame holds. */
235
299
  this.wakePx = 0;
236
300
  /** Last measured wake amplitude in px. */
@@ -238,6 +302,13 @@ class Marquee {
238
302
  /** Has the first frame been lined up with the static row it replaces? */
239
303
  this.aligned = false;
240
304
 
305
+ /**
306
+ * @type {(() => void) | null} Cancels the wait for an image that has not
307
+ * reserved its space yet, while there is one. Doubles as the guard that
308
+ * keeps a second observer callback from starting a second wait.
309
+ */
310
+ this.waiting = null;
311
+
241
312
  this.visible = false;
242
313
  this.measured = false;
243
314
  this.paused = false;
@@ -250,6 +321,7 @@ class Marquee {
250
321
  this.#observe();
251
322
 
252
323
  registry.add(this);
324
+ byElement.set(root, this);
253
325
  this.#applyMotionPreference();
254
326
  }
255
327
 
@@ -338,11 +410,85 @@ class Marquee {
338
410
  }
339
411
 
340
412
  /**
341
- * Take the row from its static state to its running one inside a single
342
- * task: overhang, measurement, clones and the first transform together.
343
- * Anything left over for the next frame gets painted on its own.
413
+ * The images in the lane that will move the loop period when they arrive.
414
+ *
415
+ * An `<img>` with no width and height in the markup lays out zero pixels
416
+ * wide until it has decoded, and the lane's width *is* the period. Measure
417
+ * now and the row runs on a period that is a fraction of its real one, then
418
+ * grows into it a logo at a time: eight logos, eight sideways yanks, spread
419
+ * over however long the slowest of them takes. A row of eight unsized
420
+ * wordmarks measures 384px at the first frame and 1608px a second and a
421
+ * half later, and jerks by up to 216px on each step in between.
422
+ *
423
+ * Deliberately a measurement rather than a plain `!complete`. An image that
424
+ * already holds its own box cannot change the period whatever it turns out
425
+ * to contain, so markup that reserves its space needs no waiting at all and
426
+ * keeps the single synchronous handover it has today.
427
+ *
428
+ * @returns {HTMLImageElement[]}
429
+ */
430
+ #unsizedImages() {
431
+ const pending = [];
432
+ for (const img of this.lane.querySelectorAll('img')) {
433
+ if (!img.complete && img.getBoundingClientRect().width === 0) pending.push(img);
434
+ }
435
+ return pending;
436
+ }
437
+
438
+ /**
439
+ * Take the row from its static state to its running one, once the period it
440
+ * would measure is the period it is going to keep.
441
+ *
442
+ * Held back while the lane still contains images that reserve no space. The
443
+ * wait costs nothing anybody can see: what stands there in the meantime is
444
+ * the static clipped row, which is what was on screen anyway, and it now
445
+ * becomes a moving row exactly once instead of stepping into place.
344
446
  */
345
447
  #activate() {
448
+ if (this.waiting) return;
449
+
450
+ const pending = this.#unsizedImages();
451
+ if (pending.length === 0) {
452
+ this.#handover();
453
+ return;
454
+ }
455
+ this.#warnUnsized();
456
+
457
+ let left = pending.length;
458
+ const done = () => {
459
+ if (--left > 0) return;
460
+ release();
461
+ this.#handover();
462
+ };
463
+ // Never wait indefinitely: a request that never answers must not leave the
464
+ // row static for the rest of the session. Past the cap the old behaviour
465
+ // takes over, and refresh() restates the phase when the image does land.
466
+ const timer = setTimeout(() => {
467
+ release();
468
+ this.#handover();
469
+ }, MEDIA_TIMEOUT);
470
+ const release = () => {
471
+ this.waiting = null;
472
+ clearTimeout(timer);
473
+ for (const img of pending) {
474
+ img.removeEventListener('load', done);
475
+ img.removeEventListener('error', done);
476
+ }
477
+ };
478
+
479
+ this.waiting = release;
480
+ for (const img of pending) {
481
+ img.addEventListener('load', done);
482
+ img.addEventListener('error', done);
483
+ }
484
+ }
485
+
486
+ /**
487
+ * Overhang, measurement, clones and the first transform, in one task.
488
+ * Anything left over for the next frame gets painted on its own.
489
+ */
490
+ #handover() {
491
+ if (this.destroyed) return;
346
492
  this.refresh();
347
493
  if (this.period > 0 && !this.paused) {
348
494
  this.read(window.innerHeight);
@@ -350,6 +496,27 @@ class Marquee {
350
496
  }
351
497
  }
352
498
 
499
+ /**
500
+ * Say something when an item holds no space until it loads.
501
+ *
502
+ * The row recovers on its own now, but it recovers by standing still until
503
+ * the last image lands, which on a slow connection is the row not starting
504
+ * for a second or two. The fix belongs in the markup, costs one attribute
505
+ * pair, and is worth knowing about: `width` and `height` on the `<img>` also
506
+ * spare the reader the layout shift the rest of the page pays for.
507
+ *
508
+ * Once per page, like the spacing warning.
509
+ */
510
+ #warnUnsized() {
511
+ if (warnedUnsized) return;
512
+ warnedUnsized = true;
513
+ console.warn(
514
+ 'wake-marquee: an item holds no width until it loads, so the loop period is not ' +
515
+ 'measurable yet and the row is waiting. Give each <img> its width and height ' +
516
+ 'attributes, or an aspect-ratio, and it starts immediately.',
517
+ );
518
+ }
519
+
353
520
  #observe() {
354
521
  const root = this.element;
355
522
 
@@ -480,6 +647,7 @@ class Marquee {
480
647
  this.offset = (this.offset / this.period) * period;
481
648
  }
482
649
 
650
+ if (!this.measured) this.#checkSpacing();
483
651
  this.period = period;
484
652
  this.measured = true;
485
653
  this.#setOverhang();
@@ -504,6 +672,11 @@ class Marquee {
504
672
  // hole in the row. The source is identical to the original, so this is
505
673
  // a cache hit rather than a second download.
506
674
  clone.querySelectorAll('img[loading="lazy"]').forEach((img) => img.setAttribute('loading', 'eager'));
675
+ // A ResizeObserver is notified after the same frame's rAF callbacks, so
676
+ // a lane added from one would be painted once before the loop could
677
+ // transform it, sitting up to a whole period right of its siblings for
678
+ // that frame. Matching it to them before it is attached costs nothing.
679
+ clone.style.transform = this.lane.style.transform;
507
680
  this.track.appendChild(clone);
508
681
  this.lanes.push(clone);
509
682
  }
@@ -515,6 +688,40 @@ class Marquee {
515
688
  schedule();
516
689
  }
517
690
 
691
+ /**
692
+ * Say something when a global CSS reset has eaten the spacing between items.
693
+ *
694
+ * `margin-inline-end` on the item lives in `@layer wake-marquee`, and every
695
+ * unlayered declaration beats every layered one whatever the specificity
696
+ * says, so an ordinary `* { margin: 0 }` reset wins over it. Nothing breaks
697
+ * loudly: the row still loops, `--wake-gap` still resolves correctly in the
698
+ * devtools, and the items simply sit flush against one another. A failure
699
+ * that quiet, in the interaction between two stylesheets, is worth a line in
700
+ * the console; it otherwise reads as a mistake in the consuming project.
701
+ *
702
+ * Runs once per instance, and never again on any of them once it has fired.
703
+ */
704
+ #checkSpacing() {
705
+ if (warnedSpacing) return;
706
+ const item = this.lane.firstElementChild;
707
+ if (!item) return;
708
+
709
+ const style = getComputedStyle(item);
710
+ // Unset resolves to the stylesheet's own 2rem, which is not zero either.
711
+ const declared = style.getPropertyValue('--wake-gap').trim();
712
+ const asked = declared === '' ? 32 : parseFloat(declared);
713
+ // A calc() or a var() chain parses as NaN. Unknowable, so say nothing:
714
+ // a false warning about someone else's CSS is worse than none.
715
+ if (!(asked > 0) || parseFloat(style.marginInlineEnd) !== 0) return;
716
+
717
+ warnedSpacing = true;
718
+ console.warn(
719
+ 'wake-marquee: the items have no spacing. An unlayered CSS reset outranks ' +
720
+ '@layer wake-marquee whatever the specificity says. Put the reset in a layer ' +
721
+ 'and name the order first: @layer reset, wake-marquee;',
722
+ );
723
+ }
724
+
518
725
  /** Read geometry. Never writes, so it cannot force a layout mid-frame. */
519
726
  read(viewport) {
520
727
  if (!this.#running()) return;
@@ -524,6 +731,11 @@ class Marquee {
524
731
  // overhang the track was given, and that is measured in these same px.
525
732
  this.amplitude = (this.options.wake * rect.width) / 100;
526
733
  this.wakePx = wakeOffset(progress, this.amplitude, this.dirSign);
734
+ // Resolved here rather than in write(), because a percentage speed is a
735
+ // fraction of this same width and this is where the width is honest. It
736
+ // is re-read every frame, so an option changed at runtime takes effect on
737
+ // the next one, and a container resized under a running row is followed.
738
+ this.speedPx = resolveSpeed(this.options.speed, rect.width);
527
739
  }
528
740
 
529
741
  /**
@@ -554,7 +766,7 @@ class Marquee {
554
766
  const target = this.hovered ? 0 : this.options.reverse ? this.dirSign * scrollDir : this.dirSign;
555
767
  this.dirFactor = easeDirection(this.dirFactor, target, dt, this.options.ease);
556
768
 
557
- this.offset = advance(this.offset, this.dirFactor * this.options.speed, dt, this.period);
769
+ this.offset = advance(this.offset, this.dirFactor * this.speedPx, dt, this.period);
558
770
 
559
771
  // One period of lead, so travelling left never exposes the origin.
560
772
  const x = this.offset - this.period;
@@ -598,7 +810,9 @@ class Marquee {
598
810
  if (this.destroyed) return;
599
811
  this.destroyed = true;
600
812
  registry.delete(this);
813
+ byElement.delete(this.element);
601
814
 
815
+ this.waiting?.();
602
816
  this.intersection?.disconnect();
603
817
  this.resize?.disconnect();
604
818
  if (this.onEnter) this.element.removeEventListener('pointerenter', this.onEnter);
@@ -713,4 +927,25 @@ export function initMarquees({ root = document, defaults = {} } = {}) {
713
927
  return created;
714
928
  }
715
929
 
930
+ /**
931
+ * The marquee running on an element, or `null` if there is none.
932
+ *
933
+ * `initMarquees()` returns only the instances that call created, which is the
934
+ * right answer for something safe to call repeatedly and the wrong one for
935
+ * anybody who needs a handle later: the second call over the same content
936
+ * returns an empty array, because every row is already running. Without a way
937
+ * back in, a declarative integration is a one-way door: `wake-marquee/astro`
938
+ * starts the rows for you and there is nothing to pause, refresh or destroy.
939
+ *
940
+ * @param {Element | null | undefined} element
941
+ * @returns {Marquee | null}
942
+ *
943
+ * @example
944
+ * const row = getMarquee(document.querySelector('#logos'))
945
+ * row?.pause()
946
+ */
947
+ export function getMarquee(element) {
948
+ return (element && byElement.get(element)) || null;
949
+ }
950
+
716
951
  export { DEFAULTS as defaults, Marquee };
package/src/motion.js CHANGED
@@ -37,6 +37,32 @@ export function laneCount(trackWidth, period, buffer = 1) {
37
37
  return Math.ceil(trackWidth / period) + 1 + buffer;
38
38
  }
39
39
 
40
+ /**
41
+ * Resolve the `speed` option to pixels per second.
42
+ *
43
+ * A number is already px/s. A percentage is that fraction of the container
44
+ * width per second, and it exists because px/s is a physical unit on a page
45
+ * whose elements are not physically constant: a logo row 40px tall with a
46
+ * 72px gap on a desktop is 16px tall with a 32px gap on a phone, so a fixed
47
+ * 150 px/s crosses a 1440px container in ten seconds and a 390px one in under
48
+ * three, with three times as many items passing per second. One value, calm on
49
+ * the desktop and hectic on the phone. A percentage scales with everything
50
+ * else on the page and reads as the same pace on both.
51
+ *
52
+ * A rate rather than a duration on purpose. `speed` already means "larger is
53
+ * faster" in its number form, and seconds-per-width would reverse that inside
54
+ * the same option: `'12s'` slower than `'6s'` while `120` is faster than `60`.
55
+ *
56
+ * @param {number | string} speed px per second, or `'8%'` of the container.
57
+ * @param {number} width Container width in px.
58
+ * @returns {number} px per second.
59
+ */
60
+ export function resolveSpeed(speed, width) {
61
+ if (typeof speed === 'number') return speed;
62
+ const percent = parseFloat(speed);
63
+ return Number.isFinite(percent) && width > 0 ? (percent * width) / 100 : 0;
64
+ }
65
+
40
66
  /**
41
67
  * Move the loop on by one frame and wrap it back into `[0, period)`.
42
68
  *
@@ -4,6 +4,12 @@
4
4
  * Everything lives in one cascade layer, so your own unlayered CSS wins
5
5
  * without reaching for !important. Declare the order yourself if you use
6
6
  * layers too: @layer theme, wake-marquee, utilities;
7
+ *
8
+ * The same rule cuts the other way, and it is the one thing to watch for: an
9
+ * unlayered global reset beats every layer whatever the specificity says, so
10
+ * `* { margin: 0 }` collapses the item spacing below to zero and the items sit
11
+ * flush. Put the reset in a layer of its own and name the order first:
12
+ * @layer reset, wake-marquee;
7
13
  */
8
14
  @layer wake-marquee {
9
15
  /**