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 +79 -20
- package/README.md +106 -15
- package/dist/wake-marquee.auto.js +1 -1
- package/dist/wake-marquee.js +1 -1
- package/integrations/astro/WakeMarquee.astro +15 -3
- package/package.json +1 -1
- package/src/marquee.js +244 -9
- package/src/motion.js +26 -0
- package/src/wake-marquee.css +6 -0
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
|
-
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
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.
|
|
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=
|
|
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
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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` |
|
|
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
|
-
|
|
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.
|
|
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);})();
|
package/dist/wake-marquee.js
CHANGED
|
@@ -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
|
-
/**
|
|
16
|
-
|
|
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
|
|
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 {
|
|
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 (
|
|
171
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
342
|
-
*
|
|
343
|
-
*
|
|
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.
|
|
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
|
*
|
package/src/wake-marquee.css
CHANGED
|
@@ -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
|
/**
|