scroll-arrows 0.1.0 → 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 +141 -14
- package/dist/{chunk-HLZXSGP5.js → chunk-LIT577GH.js} +288 -43
- package/dist/index.cjs +290 -40
- package/dist/index.d.cts +69 -3
- package/dist/index.d.ts +69 -3
- package/dist/index.js +6 -3
- package/dist/react.cjs +310 -40
- package/dist/react.d.cts +22 -5
- package/dist/react.d.ts +22 -5
- package/dist/react.js +25 -2
- package/dist/{types-DehQP2Hx.d.cts → types-Cpvz9wtr.d.cts} +81 -3
- package/dist/{types-DehQP2Hx.d.ts → types-Cpvz9wtr.d.ts} +81 -3
- package/package.json +14 -3
package/README.md
CHANGED
|
@@ -2,8 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/dancj/scroll-arrows/actions/workflows/ci.yml)
|
|
4
4
|
[](https://www.npmjs.com/package/scroll-arrows)
|
|
5
|
-
[](https://bundlejs.com/?q=scroll-arrows)
|
|
6
|
+
[](./LICENSE)
|
|
7
|
+
|
|
8
|
+
<p align="center">
|
|
9
|
+
<picture>
|
|
10
|
+
<source media="(prefers-color-scheme: dark)" srcset="./assets/hero-arrow-dark.svg" />
|
|
11
|
+
<img alt="A hand-drawn arrow drawing itself" src="./assets/hero-arrow-light.svg" width="640" />
|
|
12
|
+
</picture>
|
|
13
|
+
</p>
|
|
7
14
|
|
|
8
15
|
Hand-drawn arrows that **draw themselves between two elements as you scroll**.
|
|
9
16
|
A single `roughness` knob slides from clean straight lines (0) to scratchy,
|
|
@@ -20,15 +27,15 @@ npm install scroll-arrows
|
|
|
20
27
|
## Vanilla
|
|
21
28
|
|
|
22
29
|
```ts
|
|
23
|
-
import { scrollArrow } from
|
|
30
|
+
import { scrollArrow } from 'scroll-arrows';
|
|
24
31
|
|
|
25
32
|
const arrow = scrollArrow({
|
|
26
|
-
start:
|
|
27
|
-
end:
|
|
28
|
-
roughness: 0.7,
|
|
29
|
-
stroke:
|
|
33
|
+
start: '#box-a', // Element or CSS selector
|
|
34
|
+
end: '#box-b',
|
|
35
|
+
roughness: 0.7, // 0 clean → 1 scratchy
|
|
36
|
+
stroke: '#e7e9ee',
|
|
30
37
|
strokeWidth: 2.5,
|
|
31
|
-
head:
|
|
38
|
+
head: 'end', // "start" | "end" | "both" | "none"
|
|
32
39
|
});
|
|
33
40
|
|
|
34
41
|
// later
|
|
@@ -50,19 +57,100 @@ arrow.destroy();
|
|
|
50
57
|
- **Obstacle routing** — pass `avoid` (an element or array) and the curve bows
|
|
51
58
|
around them with an `avoidPadding` gap instead of cutting through. Single-bend
|
|
52
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
|
+
|
|
53
86
|
- **Manual mode** — `scroll: false` + `setProgress(0..1)` to drive it yourself
|
|
54
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`.
|
|
55
92
|
- **Labels** — `label` rides along the line at `labelAt` (0..1, default mid)
|
|
56
93
|
and can sit off the line via `labelOffset` (perpendicular px; + = left of the
|
|
57
94
|
draw direction, − = right). Fades in as the pen draws through it.
|
|
58
95
|
`labelBackground` masks a gap in the line behind the text (the excalidraw
|
|
59
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.
|
|
60
148
|
|
|
61
149
|
## React
|
|
62
150
|
|
|
63
151
|
```tsx
|
|
64
|
-
import { useRef } from
|
|
65
|
-
import { ScrollArrowLine } from
|
|
152
|
+
import { useRef } from 'react';
|
|
153
|
+
import { ScrollArrowLine } from 'scroll-arrows/react';
|
|
66
154
|
|
|
67
155
|
function Diagram() {
|
|
68
156
|
const a = useRef<HTMLDivElement>(null);
|
|
@@ -81,6 +169,19 @@ function Diagram() {
|
|
|
81
169
|
arrow via effect and cleans up on unmount. `useScrollArrow(opts)` is the hook
|
|
82
170
|
form. Pass `deps={[...]}` to re-create when inputs change.
|
|
83
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
|
+
|
|
84
185
|
## Astro
|
|
85
186
|
|
|
86
187
|
The core is DOM-only, so run it in a client script (it must execute in the
|
|
@@ -105,14 +206,36 @@ For an Astro React island, use the React API and hydrate with `client:visible`:
|
|
|
105
206
|
<Diagram client:visible />
|
|
106
207
|
```
|
|
107
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
|
+
|
|
108
229
|
## API
|
|
109
230
|
|
|
110
231
|
`scrollArrow(options)` / `new ScrollArrow(options)` → instance with
|
|
111
232
|
`setProgress(p)`, `refresh()`, `destroy()`.
|
|
112
233
|
|
|
113
234
|
Key options: `start`, `end`, `container`, `roughness`, `stroke`, `strokeWidth`,
|
|
114
|
-
`seed`, `startSocket`, `endSocket`, `
|
|
115
|
-
`
|
|
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.
|
|
116
239
|
|
|
117
240
|
## Develop
|
|
118
241
|
|
|
@@ -137,8 +260,12 @@ titles):
|
|
|
137
260
|
version from the newest `v*` tag, bumps `package.json` + prepends a
|
|
138
261
|
`CHANGELOG.md` entry on a `release-<version>` branch, tags `v<version>`, and
|
|
139
262
|
opens a sync PR back to `staging`.
|
|
140
|
-
4.
|
|
141
|
-
|
|
263
|
+
4. `release-changelog.yml` then dispatches `release.yml` via `workflow_dispatch`
|
|
264
|
+
(a tag pushed under `GITHUB_TOKEN` cannot trigger `on: push: tags` — GitHub's
|
|
265
|
+
anti-recursion guard), passing the tag. `release.yml` publishes to npm via
|
|
266
|
+
**trusted publishing (OIDC)** with provenance (version pinned to the tag). A
|
|
267
|
+
`v*` tag pushed manually with your own credentials also triggers `release.yml`
|
|
268
|
+
directly via `on: push: tags`.
|
|
142
269
|
|
|
143
270
|
Release logic lives in `scripts/*.mjs` (pure helpers + injectable-deps
|
|
144
271
|
orchestrators), unit-tested under vitest.
|
|
@@ -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
|
};
|
|
@@ -80,10 +84,36 @@ function buildPath(ep, curvature, belly = { x: 0, y: 0 }) {
|
|
|
80
84
|
const reach = dist * (0.3 + curvature * 0.4);
|
|
81
85
|
const sn = startNormal.x || startNormal.y ? startNormal : unit(dx, dy);
|
|
82
86
|
const en = endNormal.x || endNormal.y ? endNormal : unit(-dx, -dy);
|
|
83
|
-
const c1 = {
|
|
84
|
-
|
|
87
|
+
const c1 = {
|
|
88
|
+
x: start.x + sn.x * reach + belly.x,
|
|
89
|
+
y: start.y + sn.y * reach + belly.y
|
|
90
|
+
};
|
|
91
|
+
const c2 = {
|
|
92
|
+
x: end.x + en.x * reach + belly.x,
|
|
93
|
+
y: end.y + en.y * reach + belly.y
|
|
94
|
+
};
|
|
85
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)}`;
|
|
86
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
|
+
}
|
|
87
117
|
function routeOffset(start, end, obstacles, padding = 14) {
|
|
88
118
|
const dx = end.x - start.x;
|
|
89
119
|
const dy = end.y - start.y;
|
|
@@ -159,12 +189,29 @@ function easeInOutCubic(t) {
|
|
|
159
189
|
function clamp01(t) {
|
|
160
190
|
return t < 0 ? 0 : t > 1 ? 1 : t;
|
|
161
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
|
+
}
|
|
162
198
|
function scrollProgress(targetRect, range) {
|
|
163
199
|
const vh = window.innerHeight || 1;
|
|
164
200
|
const topFrac = (targetRect.top - window.scrollY) / vh;
|
|
165
201
|
const [enter, leave] = range;
|
|
166
202
|
return clamp01((enter - topFrac) / (enter - leave || 1));
|
|
167
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
|
+
}
|
|
168
215
|
function midpointRect(a, b) {
|
|
169
216
|
const ra = docRect(a);
|
|
170
217
|
const rb = docRect(b);
|
|
@@ -201,7 +248,8 @@ function getOverlay(container) {
|
|
|
201
248
|
(_a = document.body.style).position || (_a.position = "relative");
|
|
202
249
|
} else {
|
|
203
250
|
const pos = getComputedStyle(container).position;
|
|
204
|
-
if (pos === "static")
|
|
251
|
+
if (pos === "static")
|
|
252
|
+
container.style.position = "relative";
|
|
205
253
|
}
|
|
206
254
|
container.appendChild(svg);
|
|
207
255
|
overlays.set(container, svg);
|
|
@@ -321,14 +369,36 @@ var ScrollArrow = class {
|
|
|
321
369
|
this.svg = getOverlay(this.container);
|
|
322
370
|
this.rc = rough.svg(this.svg);
|
|
323
371
|
this.svg.appendChild(this.group);
|
|
324
|
-
this.
|
|
372
|
+
this.reducedMotion = this.opts.respectReducedMotion !== false && prefersReducedMotion();
|
|
373
|
+
this.progress = this.reducedMotion ? 1 : clamp01(this.opts.progress);
|
|
325
374
|
this.refs = this.resolveRefs();
|
|
326
375
|
this.stroke = options.stroke ?? getComputedStyle(this.container).color ?? "#222";
|
|
327
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";
|
|
328
379
|
this.render();
|
|
329
380
|
this.bind();
|
|
330
381
|
this.update();
|
|
331
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
|
+
}
|
|
332
402
|
/** Manually set draw progress (0..1). Only meaningful when scroll is false. */
|
|
333
403
|
setProgress(p) {
|
|
334
404
|
this.progress = clamp01(p);
|
|
@@ -342,6 +412,7 @@ var ScrollArrow = class {
|
|
|
342
412
|
destroy() {
|
|
343
413
|
this.destroyed = true;
|
|
344
414
|
this.ro?.disconnect();
|
|
415
|
+
this.teardownReveal();
|
|
345
416
|
window.removeEventListener("scroll", this.onScroll, true);
|
|
346
417
|
window.removeEventListener("resize", this.onScroll);
|
|
347
418
|
cancelAnimationFrame(this.rafId);
|
|
@@ -367,17 +438,30 @@ var ScrollArrow = class {
|
|
|
367
438
|
const list = Array.isArray(a) ? a : [a];
|
|
368
439
|
return list.map(resolve).filter((el) => el !== null);
|
|
369
440
|
}
|
|
370
|
-
computeEndpoints() {
|
|
371
|
-
const sr = docRect(this.refs.start);
|
|
372
|
-
const er = docRect(this.refs.end);
|
|
441
|
+
computeEndpoints(sr, er) {
|
|
373
442
|
const ss = this.opts.startSocket ?? "auto";
|
|
374
443
|
const es = this.opts.endSocket ?? "auto";
|
|
375
|
-
return resolveEndpoints(
|
|
444
|
+
return resolveEndpoints(
|
|
445
|
+
sr,
|
|
446
|
+
er,
|
|
447
|
+
ss,
|
|
448
|
+
es,
|
|
449
|
+
this.opts.startSocketOffset ?? 0,
|
|
450
|
+
this.opts.endSocketOffset ?? 0
|
|
451
|
+
);
|
|
376
452
|
}
|
|
377
453
|
render() {
|
|
378
454
|
while (this.group.firstChild) this.group.removeChild(this.group.firstChild);
|
|
379
455
|
this.segments = [];
|
|
380
|
-
|
|
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);
|
|
381
465
|
const origin = overlayOrigin(this.svg);
|
|
382
466
|
const shift = (e) => ({
|
|
383
467
|
start: { x: e.start.x - origin.x, y: e.start.y - origin.y },
|
|
@@ -394,24 +478,29 @@ var ScrollArrow = class {
|
|
|
394
478
|
this.seed,
|
|
395
479
|
this.opts.anchorEnds ?? true
|
|
396
480
|
);
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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
|
+
}
|
|
415
504
|
this.appendDrawable(this.rc.path(d, roughOpts), "line");
|
|
416
505
|
const head = this.opts.head;
|
|
417
506
|
const size = this.opts.headSize;
|
|
@@ -457,8 +546,12 @@ var ScrollArrow = class {
|
|
|
457
546
|
let y = pt.y;
|
|
458
547
|
if (offset && total > 0) {
|
|
459
548
|
const eps = Math.min(1, total / 2);
|
|
460
|
-
const before = this.lineEl.getPointAtLength(
|
|
461
|
-
|
|
549
|
+
const before = this.lineEl.getPointAtLength(
|
|
550
|
+
Math.max(0, at * total - eps)
|
|
551
|
+
);
|
|
552
|
+
const after = this.lineEl.getPointAtLength(
|
|
553
|
+
Math.min(total, at * total + eps)
|
|
554
|
+
);
|
|
462
555
|
const n = unitNormal(before, after);
|
|
463
556
|
x += n.x * offset;
|
|
464
557
|
y += n.y * offset;
|
|
@@ -518,19 +611,37 @@ var ScrollArrow = class {
|
|
|
518
611
|
if (this.labelBgEl) this.labelBgEl.style.opacity = String(op);
|
|
519
612
|
}
|
|
520
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
|
+
}
|
|
521
632
|
bind() {
|
|
522
633
|
const targets = [this.refs.start, this.refs.end, ...this.resolveAvoid()];
|
|
523
634
|
if (this.refs.target) targets.push(this.refs.target);
|
|
524
635
|
this.ro = new ResizeObserver(() => this.render());
|
|
525
636
|
targets.forEach((t) => this.ro.observe(t));
|
|
526
|
-
if (this.opts.scroll !== false) {
|
|
637
|
+
if (this.opts.scroll !== false && !this.reducedMotion) {
|
|
527
638
|
window.addEventListener("scroll", this.onScroll, true);
|
|
528
639
|
window.addEventListener("resize", this.onScroll);
|
|
529
640
|
}
|
|
530
641
|
}
|
|
531
642
|
update() {
|
|
532
|
-
if (this.destroyed) return;
|
|
533
|
-
if (this.opts.scroll === false) {
|
|
643
|
+
if (this.destroyed || !this.enabled) return;
|
|
644
|
+
if (this.opts.scroll === false || this.reducedMotion) {
|
|
534
645
|
this.applyProgress();
|
|
535
646
|
return;
|
|
536
647
|
}
|
|
@@ -552,6 +663,140 @@ function clampAt(t) {
|
|
|
552
663
|
return t < 0 ? 0 : t > 1 ? 1 : t;
|
|
553
664
|
}
|
|
554
665
|
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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
|