scroll-arrows 0.1.3 → 0.2.0
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/README.md +118 -2
- package/dist/{chunk-GKWBGFLA.js → chunk-LIT577GH.js} +272 -38
- package/dist/index.cjs +274 -35
- package/dist/index.d.cts +69 -3
- package/dist/index.d.ts +69 -3
- package/dist/index.js +6 -3
- package/dist/react.cjs +294 -35
- package/dist/react.d.cts +20 -2
- package/dist/react.d.ts +20 -2
- package/dist/react.js +25 -2
- package/dist/{types-CDd8JqZX.d.cts → types-Cpvz9wtr.d.cts} +79 -1
- package/dist/{types-CDd8JqZX.d.ts → types-Cpvz9wtr.d.ts} +79 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -57,13 +57,94 @@ arrow.destroy();
|
|
|
57
57
|
- **Obstacle routing** — pass `avoid` (an element or array) and the curve bows
|
|
58
58
|
around them with an `avoidPadding` gap instead of cutting through. Single-bend
|
|
59
59
|
router: it clears the worst blocker, not a full path-finder.
|
|
60
|
+
- **Shared-origin fan-out** — when several arrows leave the same element they all
|
|
61
|
+
resolve to the same edge point and stack. Slide each along its edge with
|
|
62
|
+
`startSocketOffset` / `endSocketOffset` (a fraction of the edge length, `0` =
|
|
63
|
+
centered, `±0.5` = corners) to spread them — e.g. `-0.25`, `0`, `+0.25` for
|
|
64
|
+
three siblings off one parent.
|
|
65
|
+
- **Elbow routing** — set `route: 'elbow'` for right-angle connectors (the
|
|
66
|
+
classic tree / org-chart bracket) instead of a smooth curve. Same-axis sockets
|
|
67
|
+
get a centered Z-bracket, perpendicular sockets a single L-corner. Pair with
|
|
68
|
+
explicit `startSocket` / `endSocket` for predictable shapes. Elbow mode ignores
|
|
69
|
+
`avoid` and `curvature`.
|
|
70
|
+
- **Hidden anchors (tabs / accordions)** — an anchor inside a `display:none`
|
|
71
|
+
container has no box, so the arrow can't be drawn yet. Instead of rendering a
|
|
72
|
+
collapsed/garbage line, it draws nothing and **auto-redraws the moment the
|
|
73
|
+
anchor is revealed** (via `IntersectionObserver`). For full control — or for
|
|
74
|
+
engines without `IntersectionObserver` — call `arrow.refresh()` from your
|
|
75
|
+
tab/accordion show handler:
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
const arrow = scrollArrow({ start: '#a', end: '#tab-2-target' });
|
|
79
|
+
|
|
80
|
+
tabButton.addEventListener('click', () => {
|
|
81
|
+
showTabPanel(2); // your code reveals the panel
|
|
82
|
+
arrow.refresh(); // recompute now that the anchor has a box
|
|
83
|
+
});
|
|
84
|
+
```
|
|
85
|
+
|
|
60
86
|
- **Manual mode** — `scroll: false` + `setProgress(0..1)` to drive it yourself
|
|
61
87
|
(e.g. from GSAP/Motion).
|
|
88
|
+
- **Reduced motion** — arrows auto-respect `prefers-reduced-motion: reduce`,
|
|
89
|
+
rendering fully drawn and static (no scroll animation) while still tracking
|
|
90
|
+
layout. Opt out with `respectReducedMotion: false` to keep the animation.
|
|
91
|
+
Works the same for `scrollArrowGroup`.
|
|
62
92
|
- **Labels** — `label` rides along the line at `labelAt` (0..1, default mid)
|
|
63
93
|
and can sit off the line via `labelOffset` (perpendicular px; + = left of the
|
|
64
94
|
draw direction, − = right). Fades in as the pen draws through it.
|
|
65
95
|
`labelBackground` masks a gap in the line behind the text (the excalidraw
|
|
66
96
|
look); style via `labelColor` / `font`.
|
|
97
|
+
- **Staggered groups** — `scrollArrowGroup` owns N arrows and reveals them in
|
|
98
|
+
sequence off one shared trigger (`A then B then C`). `stagger` (0..1) controls
|
|
99
|
+
overlap: `1` draws each in its own slice, `0` draws them together.
|
|
100
|
+
|
|
101
|
+
## Groups (staggered reveal)
|
|
102
|
+
|
|
103
|
+
For diagrams (org/family trees, flows) where a set of arrows should draw in
|
|
104
|
+
order rather than each on its own scroll, use a group. It creates the arrows in
|
|
105
|
+
manual mode and drives their progress as one coordinated reveal.
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
import { scrollArrowGroup } from 'scroll-arrows';
|
|
109
|
+
|
|
110
|
+
const group = scrollArrowGroup({
|
|
111
|
+
arrows: [
|
|
112
|
+
{ start: '#a', end: '#b', roughness: 0.6 },
|
|
113
|
+
{ start: '#b', end: '#c', roughness: 0.6 },
|
|
114
|
+
{ start: '#b', end: '#d', roughness: 0.6 },
|
|
115
|
+
],
|
|
116
|
+
stagger: 1, // 0 = all together, 1 = fully sequential (default)
|
|
117
|
+
scroll: { target: '#diagram' }, // shared trigger; defaults to all endpoints
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// later
|
|
121
|
+
group.destroy();
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Each entry takes the usual per-arrow options. The group forces `scroll: false`
|
|
125
|
+
on each arrow and slices its own progress across them. Pass `scroll: false` to
|
|
126
|
+
drive the whole group yourself with `group.setProgress(0..1)`; `group.refresh()`
|
|
127
|
+
recomputes every arrow's geometry. The default `scroll.target` is a synthetic
|
|
128
|
+
rect spanning every endpoint, so the group reveals as it scrolls into view.
|
|
129
|
+
|
|
130
|
+
## Breakpoints / responsive
|
|
131
|
+
|
|
132
|
+
When a diagram reflows on small screens (absolute overlay → vertical stack), the
|
|
133
|
+
arrows usually should disappear. Use `setEnabled(on)` to suspend and restore an
|
|
134
|
+
arrow **without tearing it down** — disabling hides it and stops all scroll work;
|
|
135
|
+
enabling shows it and recomputes geometry. Wire it to `matchMedia`:
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
const arrow = scrollArrow({ start: '#a', end: '#b' });
|
|
139
|
+
|
|
140
|
+
const mq = window.matchMedia('(max-width: 30rem)');
|
|
141
|
+
const sync = () => arrow.setEnabled(!mq.matches); // off below 30rem
|
|
142
|
+
sync();
|
|
143
|
+
mq.addEventListener('change', sync);
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Pass `enabled: false` to start hidden. `scrollArrowGroup` has the same
|
|
147
|
+
`enabled` option and `setEnabled(on)`, toggling the whole set at once.
|
|
67
148
|
|
|
68
149
|
## React
|
|
69
150
|
|
|
@@ -88,6 +169,19 @@ function Diagram() {
|
|
|
88
169
|
arrow via effect and cleans up on unmount. `useScrollArrow(opts)` is the hook
|
|
89
170
|
form. Pass `deps={[...]}` to re-create when inputs change.
|
|
90
171
|
|
|
172
|
+
For a staggered group, `ScrollArrowGroupLines` (and the `useScrollArrowGroup`
|
|
173
|
+
hook) take an `arrows` array whose `start`/`end` accept refs:
|
|
174
|
+
|
|
175
|
+
```tsx
|
|
176
|
+
<ScrollArrowGroupLines
|
|
177
|
+
arrows={[
|
|
178
|
+
{ start: a, end: b },
|
|
179
|
+
{ start: b, end: c },
|
|
180
|
+
]}
|
|
181
|
+
stagger={1}
|
|
182
|
+
/>
|
|
183
|
+
```
|
|
184
|
+
|
|
91
185
|
## Astro
|
|
92
186
|
|
|
93
187
|
The core is DOM-only, so run it in a client script (it must execute in the
|
|
@@ -112,14 +206,36 @@ For an Astro React island, use the React API and hydrate with `client:visible`:
|
|
|
112
206
|
<Diagram client:visible />
|
|
113
207
|
```
|
|
114
208
|
|
|
209
|
+
## SSR & progressive enhancement
|
|
210
|
+
|
|
211
|
+
scroll-arrows is **progressive-enhancement only by design**. The arrow is an
|
|
212
|
+
overlay `<svg>` created by the client script at runtime — there is no
|
|
213
|
+
server-rendered or build-time output. In SSG/SSR setups (Astro, Next, etc.) the
|
|
214
|
+
connector simply does not exist until the script runs, so:
|
|
215
|
+
|
|
216
|
+
- **No-JS / pre-hydration users see nothing** where an arrow would be. Arrows
|
|
217
|
+
are treated as decorative enhancement, not content.
|
|
218
|
+
- Don't encode meaning solely in an arrow. If a relationship must survive without
|
|
219
|
+
JS (accessibility, SEO, no-JS fallback), express it in the DOM too — adjacent
|
|
220
|
+
copy, a list, a caption, an `aria-label` — and let the arrow decorate it.
|
|
221
|
+
- The library ships no static snapshot. Geometry depends on the live, laid-out
|
|
222
|
+
positions of both anchors (and the viewport), which aren't known at build time,
|
|
223
|
+
so a server-rendered arrow would usually be wrong anyway.
|
|
224
|
+
|
|
225
|
+
If you genuinely need a static connector in the un-hydrated state, hand-author a
|
|
226
|
+
plain `<svg>` in your markup and let scroll-arrows draw over it on hydration —
|
|
227
|
+
the runtime arrow mounts in its own overlay and won't clash with your static one.
|
|
228
|
+
|
|
115
229
|
## API
|
|
116
230
|
|
|
117
231
|
`scrollArrow(options)` / `new ScrollArrow(options)` → instance with
|
|
118
232
|
`setProgress(p)`, `refresh()`, `destroy()`.
|
|
119
233
|
|
|
120
234
|
Key options: `start`, `end`, `container`, `roughness`, `stroke`, `strokeWidth`,
|
|
121
|
-
`seed`, `startSocket`, `endSocket`, `
|
|
122
|
-
`
|
|
235
|
+
`seed`, `startSocket`, `endSocket`, `startSocketOffset`, `endSocketOffset`,
|
|
236
|
+
`curvature`, `route`, `head`, `headSize`, `scroll`, `speed`, `easing`,
|
|
237
|
+
`progress`, `enabled`. Full types ship with the package. `setEnabled(on)` toggles
|
|
238
|
+
an arrow (and a group) on/off without teardown.
|
|
123
239
|
|
|
124
240
|
## Develop
|
|
125
241
|
|
|
@@ -16,20 +16,24 @@ function docRect(el) {
|
|
|
16
16
|
height: r2.height
|
|
17
17
|
};
|
|
18
18
|
}
|
|
19
|
+
function isDegenerateRect(r2) {
|
|
20
|
+
return r2.width <= 0 || r2.height <= 0;
|
|
21
|
+
}
|
|
19
22
|
function center(r2) {
|
|
20
23
|
return { x: r2.left + r2.width / 2, y: r2.top + r2.height / 2 };
|
|
21
24
|
}
|
|
22
|
-
function socketPoint(r2, side) {
|
|
25
|
+
function socketPoint(r2, side, offset = 0) {
|
|
23
26
|
const c = center(r2);
|
|
27
|
+
const o = offset < -0.5 ? -0.5 : offset > 0.5 ? 0.5 : offset;
|
|
24
28
|
switch (side) {
|
|
25
29
|
case "top":
|
|
26
|
-
return { x: c.x, y: r2.top };
|
|
30
|
+
return { x: c.x + o * r2.width, y: r2.top };
|
|
27
31
|
case "bottom":
|
|
28
|
-
return { x: c.x, y: r2.top + r2.height };
|
|
32
|
+
return { x: c.x + o * r2.width, y: r2.top + r2.height };
|
|
29
33
|
case "left":
|
|
30
|
-
return { x: r2.left, y: c.y };
|
|
34
|
+
return { x: r2.left, y: c.y + o * r2.height };
|
|
31
35
|
case "right":
|
|
32
|
-
return { x: r2.left + r2.width, y: c.y };
|
|
36
|
+
return { x: r2.left + r2.width, y: c.y + o * r2.height };
|
|
33
37
|
default:
|
|
34
38
|
return c;
|
|
35
39
|
}
|
|
@@ -62,12 +66,12 @@ function autoSide(self, other) {
|
|
|
62
66
|
}
|
|
63
67
|
return best;
|
|
64
68
|
}
|
|
65
|
-
function resolveEndpoints(startRect, endRect, startSocket, endSocket) {
|
|
69
|
+
function resolveEndpoints(startRect, endRect, startSocket, endSocket, startOffset = 0, endOffset = 0) {
|
|
66
70
|
const s = startSocket === "auto" ? autoSide(startRect, endRect) : startSocket;
|
|
67
71
|
const e = endSocket === "auto" ? autoSide(endRect, startRect) : endSocket;
|
|
68
72
|
return {
|
|
69
|
-
start: socketPoint(startRect, s),
|
|
70
|
-
end: socketPoint(endRect, e),
|
|
73
|
+
start: socketPoint(startRect, s, startOffset),
|
|
74
|
+
end: socketPoint(endRect, e, endOffset),
|
|
71
75
|
startNormal: socketNormal(s),
|
|
72
76
|
endNormal: socketNormal(e)
|
|
73
77
|
};
|
|
@@ -90,6 +94,26 @@ function buildPath(ep, curvature, belly = { x: 0, y: 0 }) {
|
|
|
90
94
|
};
|
|
91
95
|
return `M ${r(start.x)} ${r(start.y)} C ${r(c1.x)} ${r(c1.y)} ${r(c2.x)} ${r(c2.y)} ${r(end.x)} ${r(end.y)}`;
|
|
92
96
|
}
|
|
97
|
+
function buildElbowPath(ep) {
|
|
98
|
+
const { start: s, end: e, startNormal: sn, endNormal: en } = ep;
|
|
99
|
+
const dx = e.x - s.x;
|
|
100
|
+
const dy = e.y - s.y;
|
|
101
|
+
const startVertical = sn.y !== 0 || sn.x === 0 && Math.abs(dy) >= Math.abs(dx);
|
|
102
|
+
const endVertical = en.y !== 0 || en.x === 0 && Math.abs(dy) >= Math.abs(dx);
|
|
103
|
+
let pts;
|
|
104
|
+
if (startVertical && endVertical) {
|
|
105
|
+
const midY = (s.y + e.y) / 2;
|
|
106
|
+
pts = [s, { x: s.x, y: midY }, { x: e.x, y: midY }, e];
|
|
107
|
+
} else if (!startVertical && !endVertical) {
|
|
108
|
+
const midX = (s.x + e.x) / 2;
|
|
109
|
+
pts = [s, { x: midX, y: s.y }, { x: midX, y: e.y }, e];
|
|
110
|
+
} else if (startVertical) {
|
|
111
|
+
pts = [s, { x: s.x, y: e.y }, e];
|
|
112
|
+
} else {
|
|
113
|
+
pts = [s, { x: e.x, y: s.y }, e];
|
|
114
|
+
}
|
|
115
|
+
return `M ${r(pts[0].x)} ${r(pts[0].y)}` + pts.slice(1).map((p) => ` L ${r(p.x)} ${r(p.y)}`).join("");
|
|
116
|
+
}
|
|
93
117
|
function routeOffset(start, end, obstacles, padding = 14) {
|
|
94
118
|
const dx = end.x - start.x;
|
|
95
119
|
const dy = end.y - start.y;
|
|
@@ -165,12 +189,29 @@ function easeInOutCubic(t) {
|
|
|
165
189
|
function clamp01(t) {
|
|
166
190
|
return t < 0 ? 0 : t > 1 ? 1 : t;
|
|
167
191
|
}
|
|
192
|
+
function prefersReducedMotion() {
|
|
193
|
+
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
197
|
+
}
|
|
168
198
|
function scrollProgress(targetRect, range) {
|
|
169
199
|
const vh = window.innerHeight || 1;
|
|
170
200
|
const topFrac = (targetRect.top - window.scrollY) / vh;
|
|
171
201
|
const [enter, leave] = range;
|
|
172
202
|
return clamp01((enter - topFrac) / (enter - leave || 1));
|
|
173
203
|
}
|
|
204
|
+
function staggerWindows(n, stagger) {
|
|
205
|
+
if (n <= 0) return [];
|
|
206
|
+
const s = clamp01(stagger);
|
|
207
|
+
const span = 1 / (1 + (n - 1) * s);
|
|
208
|
+
const step = span * s;
|
|
209
|
+
return Array.from({ length: n }, (_, i) => ({ start: i * step, span }));
|
|
210
|
+
}
|
|
211
|
+
function windowProgress(p, w) {
|
|
212
|
+
if (w.span <= 0) return p > w.start ? 1 : 0;
|
|
213
|
+
return clamp01((p - w.start) / w.span);
|
|
214
|
+
}
|
|
174
215
|
function midpointRect(a, b) {
|
|
175
216
|
const ra = docRect(a);
|
|
176
217
|
const rb = docRect(b);
|
|
@@ -328,14 +369,36 @@ var ScrollArrow = class {
|
|
|
328
369
|
this.svg = getOverlay(this.container);
|
|
329
370
|
this.rc = rough.svg(this.svg);
|
|
330
371
|
this.svg.appendChild(this.group);
|
|
331
|
-
this.
|
|
372
|
+
this.reducedMotion = this.opts.respectReducedMotion !== false && prefersReducedMotion();
|
|
373
|
+
this.progress = this.reducedMotion ? 1 : clamp01(this.opts.progress);
|
|
332
374
|
this.refs = this.resolveRefs();
|
|
333
375
|
this.stroke = options.stroke ?? getComputedStyle(this.container).color ?? "#222";
|
|
334
376
|
this.seed = options.seed ?? deriveSeed(refKey(options.start), refKey(options.end));
|
|
377
|
+
this.enabled = this.opts.enabled ?? true;
|
|
378
|
+
if (!this.enabled) this.group.style.display = "none";
|
|
335
379
|
this.render();
|
|
336
380
|
this.bind();
|
|
337
381
|
this.update();
|
|
338
382
|
}
|
|
383
|
+
/**
|
|
384
|
+
* Suspend or restore the arrow without tearing it down. Disabling hides it and
|
|
385
|
+
* stops all draw/scroll work; enabling shows it and recomputes geometry (so it
|
|
386
|
+
* reflects any layout change that happened while hidden). Idempotent. Wire it
|
|
387
|
+
* to `matchMedia` to switch arrows off below a breakpoint.
|
|
388
|
+
*/
|
|
389
|
+
setEnabled(on) {
|
|
390
|
+
if (on === this.enabled || this.destroyed) return;
|
|
391
|
+
this.enabled = on;
|
|
392
|
+
if (on) {
|
|
393
|
+
this.group.style.display = "";
|
|
394
|
+
this.render();
|
|
395
|
+
this.update();
|
|
396
|
+
} else {
|
|
397
|
+
this.group.style.display = "none";
|
|
398
|
+
cancelAnimationFrame(this.rafId);
|
|
399
|
+
this.rafId = 0;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
339
402
|
/** Manually set draw progress (0..1). Only meaningful when scroll is false. */
|
|
340
403
|
setProgress(p) {
|
|
341
404
|
this.progress = clamp01(p);
|
|
@@ -349,6 +412,7 @@ var ScrollArrow = class {
|
|
|
349
412
|
destroy() {
|
|
350
413
|
this.destroyed = true;
|
|
351
414
|
this.ro?.disconnect();
|
|
415
|
+
this.teardownReveal();
|
|
352
416
|
window.removeEventListener("scroll", this.onScroll, true);
|
|
353
417
|
window.removeEventListener("resize", this.onScroll);
|
|
354
418
|
cancelAnimationFrame(this.rafId);
|
|
@@ -374,17 +438,30 @@ var ScrollArrow = class {
|
|
|
374
438
|
const list = Array.isArray(a) ? a : [a];
|
|
375
439
|
return list.map(resolve).filter((el) => el !== null);
|
|
376
440
|
}
|
|
377
|
-
computeEndpoints() {
|
|
378
|
-
const sr = docRect(this.refs.start);
|
|
379
|
-
const er = docRect(this.refs.end);
|
|
441
|
+
computeEndpoints(sr, er) {
|
|
380
442
|
const ss = this.opts.startSocket ?? "auto";
|
|
381
443
|
const es = this.opts.endSocket ?? "auto";
|
|
382
|
-
return resolveEndpoints(
|
|
444
|
+
return resolveEndpoints(
|
|
445
|
+
sr,
|
|
446
|
+
er,
|
|
447
|
+
ss,
|
|
448
|
+
es,
|
|
449
|
+
this.opts.startSocketOffset ?? 0,
|
|
450
|
+
this.opts.endSocketOffset ?? 0
|
|
451
|
+
);
|
|
383
452
|
}
|
|
384
453
|
render() {
|
|
385
454
|
while (this.group.firstChild) this.group.removeChild(this.group.firstChild);
|
|
386
455
|
this.segments = [];
|
|
387
|
-
|
|
456
|
+
if (!this.enabled) return;
|
|
457
|
+
const sr = docRect(this.refs.start);
|
|
458
|
+
const er = docRect(this.refs.end);
|
|
459
|
+
if (isDegenerateRect(sr) || isDegenerateRect(er)) {
|
|
460
|
+
this.armReveal();
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
this.teardownReveal();
|
|
464
|
+
const ep = this.computeEndpoints(sr, er);
|
|
388
465
|
const origin = overlayOrigin(this.svg);
|
|
389
466
|
const shift = (e) => ({
|
|
390
467
|
start: { x: e.start.x - origin.x, y: e.start.y - origin.y },
|
|
@@ -401,24 +478,29 @@ var ScrollArrow = class {
|
|
|
401
478
|
this.seed,
|
|
402
479
|
this.opts.anchorEnds ?? true
|
|
403
480
|
);
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
481
|
+
let d;
|
|
482
|
+
if (this.opts.route === "elbow") {
|
|
483
|
+
d = buildElbowPath(local);
|
|
484
|
+
} else {
|
|
485
|
+
const obstacles = this.resolveAvoid().map((el) => {
|
|
486
|
+
const dr = docRect(el);
|
|
487
|
+
return {
|
|
488
|
+
left: dr.left - origin.x,
|
|
489
|
+
top: dr.top - origin.y,
|
|
490
|
+
width: dr.width,
|
|
491
|
+
height: dr.height
|
|
492
|
+
};
|
|
493
|
+
});
|
|
494
|
+
const clear = routeOffset(
|
|
495
|
+
local.start,
|
|
496
|
+
local.end,
|
|
497
|
+
obstacles,
|
|
498
|
+
this.opts.avoidPadding ?? 14
|
|
499
|
+
);
|
|
500
|
+
const BOW = 1.6;
|
|
501
|
+
const belly = { x: clear.x * BOW, y: clear.y * BOW };
|
|
502
|
+
d = buildPath(local, curvature, belly);
|
|
503
|
+
}
|
|
422
504
|
this.appendDrawable(this.rc.path(d, roughOpts), "line");
|
|
423
505
|
const head = this.opts.head;
|
|
424
506
|
const size = this.opts.headSize;
|
|
@@ -529,19 +611,37 @@ var ScrollArrow = class {
|
|
|
529
611
|
if (this.labelBgEl) this.labelBgEl.style.opacity = String(op);
|
|
530
612
|
}
|
|
531
613
|
}
|
|
614
|
+
/**
|
|
615
|
+
* Watch the anchors for the moment a hidden one becomes laid out, then redraw.
|
|
616
|
+
* IntersectionObserver fires when a previously `display:none` element gains a
|
|
617
|
+
* box — a transition ResizeObserver does not reliably report. Armed lazily and
|
|
618
|
+
* idempotently; torn down by the next successful render(). Guarded so it no-ops
|
|
619
|
+
* in environments without IntersectionObserver (older engines, some SSR/jsdom).
|
|
620
|
+
*/
|
|
621
|
+
armReveal() {
|
|
622
|
+
if (this.revealObs || this.destroyed) return;
|
|
623
|
+
if (typeof IntersectionObserver === "undefined") return;
|
|
624
|
+
this.revealObs = new IntersectionObserver(() => this.render());
|
|
625
|
+
this.revealObs.observe(this.refs.start);
|
|
626
|
+
this.revealObs.observe(this.refs.end);
|
|
627
|
+
}
|
|
628
|
+
teardownReveal() {
|
|
629
|
+
this.revealObs?.disconnect();
|
|
630
|
+
this.revealObs = void 0;
|
|
631
|
+
}
|
|
532
632
|
bind() {
|
|
533
633
|
const targets = [this.refs.start, this.refs.end, ...this.resolveAvoid()];
|
|
534
634
|
if (this.refs.target) targets.push(this.refs.target);
|
|
535
635
|
this.ro = new ResizeObserver(() => this.render());
|
|
536
636
|
targets.forEach((t) => this.ro.observe(t));
|
|
537
|
-
if (this.opts.scroll !== false) {
|
|
637
|
+
if (this.opts.scroll !== false && !this.reducedMotion) {
|
|
538
638
|
window.addEventListener("scroll", this.onScroll, true);
|
|
539
639
|
window.addEventListener("resize", this.onScroll);
|
|
540
640
|
}
|
|
541
641
|
}
|
|
542
642
|
update() {
|
|
543
|
-
if (this.destroyed) return;
|
|
544
|
-
if (this.opts.scroll === false) {
|
|
643
|
+
if (this.destroyed || !this.enabled) return;
|
|
644
|
+
if (this.opts.scroll === false || this.reducedMotion) {
|
|
545
645
|
this.applyProgress();
|
|
546
646
|
return;
|
|
547
647
|
}
|
|
@@ -563,6 +663,140 @@ function clampAt(t) {
|
|
|
563
663
|
return t < 0 ? 0 : t > 1 ? 1 : t;
|
|
564
664
|
}
|
|
565
665
|
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
666
|
+
// src/group.ts
|
|
667
|
+
var ScrollArrowGroup = class {
|
|
668
|
+
constructor(options) {
|
|
669
|
+
this.elements = [];
|
|
670
|
+
this.target = null;
|
|
671
|
+
this.progress = 0;
|
|
672
|
+
this.rafId = 0;
|
|
673
|
+
this.destroyed = false;
|
|
674
|
+
this.onScroll = () => {
|
|
675
|
+
if (this.rafId) return;
|
|
676
|
+
this.rafId = requestAnimationFrame(() => {
|
|
677
|
+
this.rafId = 0;
|
|
678
|
+
this.update();
|
|
679
|
+
});
|
|
680
|
+
};
|
|
681
|
+
if (!options.arrows || options.arrows.length === 0) {
|
|
682
|
+
throw new Error("[scroll-arrows] group needs at least one arrow");
|
|
683
|
+
}
|
|
684
|
+
this.opts = {
|
|
685
|
+
stagger: 1,
|
|
686
|
+
speed: 1,
|
|
687
|
+
easing: (t) => t,
|
|
688
|
+
...options
|
|
689
|
+
};
|
|
690
|
+
this.reducedMotion = this.opts.respectReducedMotion !== false && prefersReducedMotion();
|
|
691
|
+
this.enabled = this.opts.enabled ?? true;
|
|
692
|
+
this.arrows = options.arrows.map(
|
|
693
|
+
(a) => new ScrollArrow({
|
|
694
|
+
...a,
|
|
695
|
+
scroll: false,
|
|
696
|
+
progress: 0,
|
|
697
|
+
respectReducedMotion: false,
|
|
698
|
+
enabled: this.enabled
|
|
699
|
+
})
|
|
700
|
+
);
|
|
701
|
+
this.windows = staggerWindows(this.arrows.length, this.opts.stagger);
|
|
702
|
+
for (const a of options.arrows) {
|
|
703
|
+
const s = resolve2(a.start);
|
|
704
|
+
const e = resolve2(a.end);
|
|
705
|
+
if (s) this.elements.push(s);
|
|
706
|
+
if (e) this.elements.push(e);
|
|
707
|
+
}
|
|
708
|
+
const scroll = this.opts.scroll;
|
|
709
|
+
if (scroll !== false && scroll?.target) {
|
|
710
|
+
this.target = resolve2(scroll.target);
|
|
711
|
+
}
|
|
712
|
+
if (this.reducedMotion) this.progress = 1;
|
|
713
|
+
this.bind();
|
|
714
|
+
this.update();
|
|
715
|
+
}
|
|
716
|
+
/** Manually set group progress (0..1). Only meaningful when scroll is false. */
|
|
717
|
+
setProgress(p) {
|
|
718
|
+
this.progress = clamp01(p);
|
|
719
|
+
this.distribute();
|
|
720
|
+
}
|
|
721
|
+
/** Recompute geometry for every arrow (call after layout changes). */
|
|
722
|
+
refresh() {
|
|
723
|
+
this.arrows.forEach((a) => a.refresh());
|
|
724
|
+
this.update();
|
|
725
|
+
}
|
|
726
|
+
/**
|
|
727
|
+
* Suspend or restore the whole group without tearing it down. Hides every
|
|
728
|
+
* arrow and stops scroll work when off; restores and recomputes when on.
|
|
729
|
+
* Idempotent. Wire to `matchMedia` for breakpoint-aware diagrams.
|
|
730
|
+
*/
|
|
731
|
+
setEnabled(on) {
|
|
732
|
+
if (on === this.enabled || this.destroyed) return;
|
|
733
|
+
this.enabled = on;
|
|
734
|
+
this.arrows.forEach((a) => a.setEnabled(on));
|
|
735
|
+
if (on) {
|
|
736
|
+
this.bind();
|
|
737
|
+
this.update();
|
|
738
|
+
} else {
|
|
739
|
+
window.removeEventListener("scroll", this.onScroll, true);
|
|
740
|
+
window.removeEventListener("resize", this.onScroll);
|
|
741
|
+
cancelAnimationFrame(this.rafId);
|
|
742
|
+
this.rafId = 0;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
destroy() {
|
|
746
|
+
this.destroyed = true;
|
|
747
|
+
window.removeEventListener("scroll", this.onScroll, true);
|
|
748
|
+
window.removeEventListener("resize", this.onScroll);
|
|
749
|
+
cancelAnimationFrame(this.rafId);
|
|
750
|
+
this.arrows.forEach((a) => a.destroy());
|
|
751
|
+
}
|
|
752
|
+
// --- internals ---------------------------------------------------------
|
|
753
|
+
bind() {
|
|
754
|
+
if (this.opts.scroll === false || this.reducedMotion || !this.enabled)
|
|
755
|
+
return;
|
|
756
|
+
window.addEventListener("scroll", this.onScroll, true);
|
|
757
|
+
window.addEventListener("resize", this.onScroll);
|
|
758
|
+
}
|
|
759
|
+
update() {
|
|
760
|
+
if (this.destroyed || !this.enabled) return;
|
|
761
|
+
if (this.opts.scroll === false || this.reducedMotion) {
|
|
762
|
+
this.distribute();
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
const scroll = this.opts.scroll ?? {};
|
|
766
|
+
const range = scroll.range ?? [0.85, 0.35];
|
|
767
|
+
const rect = this.target ? docRect(this.target) : this.groupRect();
|
|
768
|
+
const raw = scrollProgress(rect, range);
|
|
769
|
+
this.progress = clamp01(raw * this.opts.speed);
|
|
770
|
+
this.distribute();
|
|
771
|
+
}
|
|
772
|
+
/** Push each arrow to its sliced local progress for the current group value. */
|
|
773
|
+
distribute() {
|
|
774
|
+
const eased = clamp01(this.opts.easing(this.progress));
|
|
775
|
+
this.arrows.forEach((arrow, i) => {
|
|
776
|
+
arrow.setProgress(windowProgress(eased, this.windows[i]));
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
/** Synthetic rect spanning every endpoint, used as the default trigger. */
|
|
780
|
+
groupRect() {
|
|
781
|
+
const rects = this.elements.map(docRect);
|
|
782
|
+
if (rects.length === 0) return { left: 0, top: 0, width: 0, height: 0 };
|
|
783
|
+
let left = Infinity;
|
|
784
|
+
let top = Infinity;
|
|
785
|
+
let right = -Infinity;
|
|
786
|
+
let bottom = -Infinity;
|
|
787
|
+
for (const r2 of rects) {
|
|
788
|
+
left = Math.min(left, r2.left);
|
|
789
|
+
top = Math.min(top, r2.top);
|
|
790
|
+
right = Math.max(right, r2.left + r2.width);
|
|
791
|
+
bottom = Math.max(bottom, r2.top + r2.height);
|
|
792
|
+
}
|
|
793
|
+
return { left, top, width: right - left, height: bottom - top };
|
|
794
|
+
}
|
|
795
|
+
};
|
|
796
|
+
function resolve2(ref) {
|
|
797
|
+
return typeof ref === "string" ? document.querySelector(ref) : ref;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
export { ScrollArrow, ScrollArrowGroup, easeInOutCubic };
|
|
801
|
+
//# sourceMappingURL=chunk-LIT577GH.js.map
|
|
802
|
+
//# sourceMappingURL=chunk-LIT577GH.js.map
|