intently 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/LICENSE +21 -0
- package/README.md +157 -0
- package/dist/chunk-WUH3J7BB.js +462 -0
- package/dist/chunk-WUH3J7BB.js.map +1 -0
- package/dist/core-N6dbpDzv.d.cts +77 -0
- package/dist/core-N6dbpDzv.d.ts +77 -0
- package/dist/index.cjs +469 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +40 -0
- package/dist/index.d.ts +40 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/react.cjs +483 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.d.cts +24 -0
- package/dist/react.d.ts +24 -0
- package/dist/react.js +20 -0
- package/dist/react.js.map +1 -0
- package/package.json +67 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sean Geng
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# intently
|
|
2
|
+
|
|
3
|
+
**Intent-aware prefetching.** Most prefetchers load every link in the viewport, or wait for a hover. intently watches where the cursor is actually *heading* — proximity and trajectory, the same prediction that powers a focus ring that warms as you approach — and prefetches (or **prerenders**, via the native Speculation Rules API) the link a beat before you click. Zero-config, ~4KB, framework-agnostic.
|
|
4
|
+
|
|
5
|
+
[**intentlyjs.com**](https://intentlyjs.com) · [npm](https://www.npmjs.com/package/intently) · [GitHub](https://github.com/seangeng/intently) · [the writeup](https://seangeng.com/writing/prefetching-on-intent)
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm i intently
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
One call binds every eligible same-origin link on the page:
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
import { intently } from "intently";
|
|
17
|
+
|
|
18
|
+
intently();
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
That's it. Move toward a link and intently prefetches its destination during the
|
|
22
|
+
~200ms between intent and click, so the navigation feels instant. React:
|
|
23
|
+
|
|
24
|
+
```tsx
|
|
25
|
+
import { useIntently } from "intently/react";
|
|
26
|
+
|
|
27
|
+
function App() {
|
|
28
|
+
useIntently(); // once, near the root
|
|
29
|
+
return <Routes />;
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Why another prefetcher
|
|
34
|
+
|
|
35
|
+
There are good ones, and intently borrows the best of each:
|
|
36
|
+
|
|
37
|
+
- [**quicklink**](https://github.com/GoogleChromeLabs/quicklink) prefetches links
|
|
38
|
+
*in the viewport* when the browser is idle. Great coverage, but it can't tell
|
|
39
|
+
the one link you want from the fifty you don't.
|
|
40
|
+
- [**instant.page**](https://instant.page) prefetches on *hover* (a 65ms dwell).
|
|
41
|
+
Precise, but hover is late — the decision is already made by the time you've
|
|
42
|
+
parked the cursor.
|
|
43
|
+
- [**ForesightJS**](https://github.com/spaansba/ForesightJS) predicts intent from
|
|
44
|
+
mouse *trajectory* — the right signal — but you register elements yourself and
|
|
45
|
+
wire up the prefetch.
|
|
46
|
+
|
|
47
|
+
intently combines them: **zero-config auto-binding** (drop it in, every `<a>` is
|
|
48
|
+
covered) + **trajectory + proximity prediction** (the link you're aimed at, not
|
|
49
|
+
the one you happen to be near) + a **tiered loader that uses the Speculation
|
|
50
|
+
Rules API** for real prefetch *and* prerender, degrading to `<link rel=prefetch>`
|
|
51
|
+
and then `fetch` where that isn't supported.
|
|
52
|
+
|
|
53
|
+
The trajectory idea is old — there are
|
|
54
|
+
[patents on cursor extrapolation](https://patents.google.com/patent/US8566696)
|
|
55
|
+
going back years, and ForesightJS does it well today. intently's bet is that
|
|
56
|
+
*prediction + the right modern loader + nothing to configure* is the combination
|
|
57
|
+
worth shipping.
|
|
58
|
+
|
|
59
|
+
## How it works
|
|
60
|
+
|
|
61
|
+
Three things, kept small:
|
|
62
|
+
|
|
63
|
+
**1. Predict.** On every `pointermove`, intently keeps a smoothed velocity. For
|
|
64
|
+
each on-screen link it computes two scores — *proximity* (distance to the link's
|
|
65
|
+
nearest edge, on a squared falloff) and *trajectory* (the dot product of your
|
|
66
|
+
heading with the direction to the link, gated by a forward cone). The higher of
|
|
67
|
+
the two is the confidence that this link is your next click. (This is the same
|
|
68
|
+
math as the [input-anticipation](https://seangeng.com/writing/interfaces-that-anticipate-input)
|
|
69
|
+
focus ring — here it drives a fetch instead of a glow.)
|
|
70
|
+
|
|
71
|
+
**2. Tier by confidence.** Crossing `intentThreshold` (default `0.5`) prefetches.
|
|
72
|
+
Sustained high confidence past `prerenderThreshold` (default `0.85`) upgrades to
|
|
73
|
+
a *prerender* — the next page is fully built in a hidden tab, so the click is
|
|
74
|
+
instant, not just fast. Prerender is expensive, so it stays rare and high-bar.
|
|
75
|
+
|
|
76
|
+
**3. Load with the best available backend.**
|
|
77
|
+
|
|
78
|
+
| Tier | Used when | Does |
|
|
79
|
+
|------|-----------|------|
|
|
80
|
+
| Speculation Rules API | Chromium | real `prefetch` **and** `prerender`, cross-document, browser-prioritized |
|
|
81
|
+
| `<link rel="prefetch">` | most browsers | prefetch only |
|
|
82
|
+
| `fetch()` low priority | last resort | warms the HTTP cache |
|
|
83
|
+
|
|
84
|
+
Only in-viewport links are scored (via `IntersectionObserver`), the scoring loop
|
|
85
|
+
sleeps when the pointer is still, and every URL loads once. Offscreen links cost
|
|
86
|
+
nothing.
|
|
87
|
+
|
|
88
|
+
## Options
|
|
89
|
+
|
|
90
|
+
```js
|
|
91
|
+
intently({
|
|
92
|
+
origins: [location.hostname], // hostnames allowed; or a (url, el) => boolean
|
|
93
|
+
ignores: [/\/logout/, "?add-to-cart"], // never touch these (strings or RegExps)
|
|
94
|
+
signals: ["trajectory", "proximity", "hover", "touch"], // which to use
|
|
95
|
+
intentThreshold: 0.5, // confidence (0–1) to prefetch
|
|
96
|
+
prerenderThreshold: 0.85, // confidence to prerender; false to disable
|
|
97
|
+
proximityRadius: 80, // px — how far a link "notices" the cursor
|
|
98
|
+
hoverDelay: 65, // ms dwell before hover counts (instant.page's number)
|
|
99
|
+
eagerOnPress: true, // prefetch immediately on pointerdown / touchstart
|
|
100
|
+
viewportPrefetch: false, // also idle-prefetch in-view links (quicklink-style)
|
|
101
|
+
limit: Infinity, // cap total loads
|
|
102
|
+
respectSaveData: true, // skip on Save-Data / 2g
|
|
103
|
+
onPredict: ({ el, url, confidence, signal }) => {}, // wire a visual affordance
|
|
104
|
+
onLoad: (url, strategy) => {}, // "prefetch" | "prerender"
|
|
105
|
+
});
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`intently()` returns a handle:
|
|
109
|
+
|
|
110
|
+
```js
|
|
111
|
+
const i = intently();
|
|
112
|
+
i.prefetch("/pricing"); // force it
|
|
113
|
+
i.prerender("/checkout"); // force it (falls back to prefetch where unsupported)
|
|
114
|
+
i.loaded; // ReadonlySet<string> of URLs loaded this session
|
|
115
|
+
i.destroy(); // remove every listener/observer
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### Exposed prediction helpers
|
|
119
|
+
|
|
120
|
+
The prediction math is exported if you want to build your own affordance (a ring
|
|
121
|
+
that warms as confidence rises, say):
|
|
122
|
+
|
|
123
|
+
```js
|
|
124
|
+
import { distanceToRect, falloff, proximityScore, trajectoryScore } from "intently";
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Safety & taste
|
|
128
|
+
|
|
129
|
+
- **It only guesses same-origin links** by default, and never touches anything in
|
|
130
|
+
`ignores`. Put sign-out, "add to cart", language switches, and any link with
|
|
131
|
+
side effects there — especially before enabling prerender, which *runs* the
|
|
132
|
+
page.
|
|
133
|
+
- **It respects the user.** Save-Data and 2g connections turn it off. A still
|
|
134
|
+
cursor predicts nothing.
|
|
135
|
+
- **It degrades.** No Speculation Rules → `<link rel=prefetch>` → `fetch`. SSR /
|
|
136
|
+
no-DOM → a no-op handle.
|
|
137
|
+
- **Prefetch is a hint, not a navigation.** The real click always works whether
|
|
138
|
+
or not the guess landed.
|
|
139
|
+
|
|
140
|
+
## When it helps (and when it doesn't)
|
|
141
|
+
|
|
142
|
+
Biggest wins are **multi-page apps** and **hard navigations** — content sites,
|
|
143
|
+
docs, e-commerce, blogs — where the next page is a real document fetch. For SPAs
|
|
144
|
+
with a client router (React Router, Next.js), those frameworks already prefetch
|
|
145
|
+
route data; intently still helps for outbound and non-router links, and its
|
|
146
|
+
prediction layer is reusable.
|
|
147
|
+
|
|
148
|
+
## Credits
|
|
149
|
+
|
|
150
|
+
Trajectory prediction by way of [ForesightJS](https://github.com/spaansba/ForesightJS)
|
|
151
|
+
and a long line of [cursor-extrapolation prior art](https://patents.google.com/patent/US8566696);
|
|
152
|
+
zero-config ergonomics from [instant.page](https://instant.page); viewport idle
|
|
153
|
+
prefetch from [quicklink](https://github.com/GoogleChromeLabs/quicklink); the
|
|
154
|
+
prediction math from my own [input-anticipation](https://seangeng.com/writing/interfaces-that-anticipate-input)
|
|
155
|
+
work. Built on the [Speculation Rules API](https://developer.mozilla.org/en-US/docs/Web/API/Speculation_Rules_API).
|
|
156
|
+
|
|
157
|
+
By [Sean Geng](https://seangeng.com). MIT.
|
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
// src/predict.ts
|
|
2
|
+
function distanceToRect(x, y, r) {
|
|
3
|
+
const dx = Math.max(r.left - x, 0, x - r.right);
|
|
4
|
+
const dy = Math.max(r.top - y, 0, y - r.bottom);
|
|
5
|
+
return Math.hypot(dx, dy);
|
|
6
|
+
}
|
|
7
|
+
function falloff(distance, radius, exponent = 2) {
|
|
8
|
+
return Math.max(0, 1 - distance / radius) ** exponent;
|
|
9
|
+
}
|
|
10
|
+
function proximityScore(x, y, r, radius) {
|
|
11
|
+
return falloff(distanceToRect(x, y, r), radius);
|
|
12
|
+
}
|
|
13
|
+
function trajectoryScore(x, y, v, r, cone = 0.6) {
|
|
14
|
+
const speed = Math.hypot(v.x, v.y);
|
|
15
|
+
if (speed < 0.04) return 0;
|
|
16
|
+
const cx = (r.left + r.right) / 2;
|
|
17
|
+
const cy = (r.top + r.bottom) / 2;
|
|
18
|
+
const dx = cx - x;
|
|
19
|
+
const dy = cy - y;
|
|
20
|
+
const d = Math.hypot(dx, dy) || 1;
|
|
21
|
+
const align = dx / d * (v.x / speed) + dy / d * (v.y / speed);
|
|
22
|
+
if (align <= cone) return 0;
|
|
23
|
+
const aligned = (align - cone) / (1 - cone);
|
|
24
|
+
const committed = Math.min(1, speed / 0.6);
|
|
25
|
+
return aligned * (0.6 + 0.4 * committed);
|
|
26
|
+
}
|
|
27
|
+
function updateVelocity(v, prev, x, y, t, smoothing = 0.7) {
|
|
28
|
+
if (prev) {
|
|
29
|
+
const dt = Math.max(1, t - prev.t);
|
|
30
|
+
v.x = v.x * smoothing + (x - prev.x) / dt * (1 - smoothing);
|
|
31
|
+
v.y = v.y * smoothing + (y - prev.y) / dt * (1 - smoothing);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// src/eligibility.ts
|
|
36
|
+
function shouldRun(respectSaveData) {
|
|
37
|
+
if (typeof navigator === "undefined") return false;
|
|
38
|
+
if (!respectSaveData) return true;
|
|
39
|
+
const c = navigator.connection;
|
|
40
|
+
if (!c) return true;
|
|
41
|
+
if (c.saveData) return false;
|
|
42
|
+
if (typeof c.effectiveType === "string" && /(^|-)2g$/.test(c.effectiveType)) return false;
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
function makeEligible(opts) {
|
|
46
|
+
const here = typeof location !== "undefined" ? location : null;
|
|
47
|
+
const originOk = (url, el) => {
|
|
48
|
+
if (typeof opts.origins === "function") return opts.origins(url, el);
|
|
49
|
+
const allow = opts.origins ?? (here ? [here.hostname] : []);
|
|
50
|
+
return allow.includes(url.hostname);
|
|
51
|
+
};
|
|
52
|
+
const ignored = (url, el) => {
|
|
53
|
+
const ig = opts.ignores;
|
|
54
|
+
if (!ig) return false;
|
|
55
|
+
if (typeof ig === "function") return ig(url, el);
|
|
56
|
+
return ig.some(
|
|
57
|
+
(rule) => typeof rule === "string" ? url.href.includes(rule) : rule.test(url.href)
|
|
58
|
+
);
|
|
59
|
+
};
|
|
60
|
+
return (el) => {
|
|
61
|
+
const href = el.href;
|
|
62
|
+
if (!href) return null;
|
|
63
|
+
let url;
|
|
64
|
+
try {
|
|
65
|
+
url = new URL(href, here?.href);
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
|
70
|
+
if (el.hasAttribute("download")) return null;
|
|
71
|
+
if (here && url.href.replace(/#.*$/, "") === here.href.replace(/#.*$/, "")) return null;
|
|
72
|
+
if (!originOk(url, el)) return null;
|
|
73
|
+
if (ignored(url, el)) return null;
|
|
74
|
+
return url.href;
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// src/loader.ts
|
|
79
|
+
var speculationRulesSupported = typeof HTMLScriptElement !== "undefined" && typeof HTMLScriptElement.supports === "function" && HTMLScriptElement.supports("speculationrules");
|
|
80
|
+
var linkPrefetchSupported = (() => {
|
|
81
|
+
if (typeof document === "undefined") return false;
|
|
82
|
+
try {
|
|
83
|
+
const l = document.createElement("link");
|
|
84
|
+
return l.relList && l.relList.supports && l.relList.supports("prefetch");
|
|
85
|
+
} catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
})();
|
|
89
|
+
var MAX_NODES = 50;
|
|
90
|
+
var MAX_PRERENDERS = 3;
|
|
91
|
+
function createLoader(limit) {
|
|
92
|
+
const loaded = /* @__PURE__ */ new Map();
|
|
93
|
+
const nodes = [];
|
|
94
|
+
const prerenderNodes = [];
|
|
95
|
+
let count = 0;
|
|
96
|
+
function track(node) {
|
|
97
|
+
nodes.push(node);
|
|
98
|
+
while (nodes.length > MAX_NODES) nodes.shift()?.remove();
|
|
99
|
+
}
|
|
100
|
+
const tier = speculationRulesSupported ? "speculationrules" : linkPrefetchSupported ? "link" : typeof fetch === "function" ? "fetch" : "none";
|
|
101
|
+
function addSpeculationRule(url, strategy) {
|
|
102
|
+
const script = document.createElement("script");
|
|
103
|
+
script.type = "speculationrules";
|
|
104
|
+
script.textContent = JSON.stringify({
|
|
105
|
+
[strategy]: [{ source: "list", urls: [url], eagerness: "immediate" }]
|
|
106
|
+
});
|
|
107
|
+
document.head.appendChild(script);
|
|
108
|
+
if (strategy === "prerender") {
|
|
109
|
+
prerenderNodes.push({ url, node: script });
|
|
110
|
+
while (prerenderNodes.length > MAX_PRERENDERS) {
|
|
111
|
+
const old = prerenderNodes.shift();
|
|
112
|
+
if (old) {
|
|
113
|
+
old.node.remove();
|
|
114
|
+
loaded.delete(old.url);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
} else {
|
|
118
|
+
track(script);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
function addPrefetchLink(url) {
|
|
122
|
+
const link = document.createElement("link");
|
|
123
|
+
link.rel = "prefetch";
|
|
124
|
+
link.href = url;
|
|
125
|
+
document.head.appendChild(link);
|
|
126
|
+
track(link);
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
tier,
|
|
130
|
+
load(url, strategy) {
|
|
131
|
+
if (tier === "none") return null;
|
|
132
|
+
const prior = loaded.get(url);
|
|
133
|
+
if (prior === "prerender" || prior === "prefetch" && strategy === "prefetch") {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
if (prior === void 0 && count >= limit) return null;
|
|
137
|
+
if (prior === void 0) count++;
|
|
138
|
+
const effective = strategy === "prerender" && tier !== "speculationrules" ? "prefetch" : strategy;
|
|
139
|
+
if (tier === "speculationrules") {
|
|
140
|
+
addSpeculationRule(url, effective);
|
|
141
|
+
} else if (tier === "link") {
|
|
142
|
+
if (prior === void 0) addPrefetchLink(url);
|
|
143
|
+
} else {
|
|
144
|
+
if (prior === void 0) {
|
|
145
|
+
fetch(url, { credentials: "same-origin" }).catch(() => {
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
loaded.set(url, effective);
|
|
150
|
+
return effective;
|
|
151
|
+
},
|
|
152
|
+
destroy() {
|
|
153
|
+
for (const n of nodes) n.remove();
|
|
154
|
+
for (const p of prerenderNodes) p.node.remove();
|
|
155
|
+
nodes.length = 0;
|
|
156
|
+
prerenderNodes.length = 0;
|
|
157
|
+
loaded.clear();
|
|
158
|
+
count = 0;
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// src/core.ts
|
|
164
|
+
var DEFAULTS = {
|
|
165
|
+
intentThreshold: 0.5,
|
|
166
|
+
prerenderThreshold: 0.85,
|
|
167
|
+
proximityRadius: 80,
|
|
168
|
+
hoverDelay: 65,
|
|
169
|
+
eagerOnPress: true,
|
|
170
|
+
viewportPrefetch: false,
|
|
171
|
+
limit: Infinity,
|
|
172
|
+
respectSaveData: true
|
|
173
|
+
};
|
|
174
|
+
var ARM_FRAMES = 8;
|
|
175
|
+
function now() {
|
|
176
|
+
return typeof performance !== "undefined" ? performance.now() : Date.now();
|
|
177
|
+
}
|
|
178
|
+
function intently(options = {}) {
|
|
179
|
+
const opts = { ...DEFAULTS, ...options };
|
|
180
|
+
const root = opts.root ?? (typeof document !== "undefined" ? document : null);
|
|
181
|
+
const loader = createLoader(opts.limit);
|
|
182
|
+
const signals = new Set(opts.signals ?? ["trajectory", "proximity", "hover", "touch"]);
|
|
183
|
+
const useTrajectory = signals.has("trajectory");
|
|
184
|
+
const useProximity = signals.has("proximity");
|
|
185
|
+
const useHover = signals.has("hover");
|
|
186
|
+
const usePress = opts.eagerOnPress;
|
|
187
|
+
const useViewport = signals.has("viewport") || opts.viewportPrefetch;
|
|
188
|
+
if (typeof window === "undefined" || !root || loader.tier === "none" || !shouldRun(opts.respectSaveData)) {
|
|
189
|
+
return { prefetch() {
|
|
190
|
+
}, prerender() {
|
|
191
|
+
}, destroy() {
|
|
192
|
+
}, loaded: /* @__PURE__ */ new Set() };
|
|
193
|
+
}
|
|
194
|
+
const eligible = makeEligible(opts);
|
|
195
|
+
const scopeEl = root instanceof Element ? root : null;
|
|
196
|
+
const candidates = /* @__PURE__ */ new Map();
|
|
197
|
+
const visible = /* @__PURE__ */ new Set();
|
|
198
|
+
const rectCache = /* @__PURE__ */ new Map();
|
|
199
|
+
const loadedView = /* @__PURE__ */ new Set();
|
|
200
|
+
let rectsDirty = false;
|
|
201
|
+
let destroyed = false;
|
|
202
|
+
const pointer = { x: 0, y: 0 };
|
|
203
|
+
const vel = { x: 0, y: 0 };
|
|
204
|
+
let prevSample = null;
|
|
205
|
+
let lastMove = 0;
|
|
206
|
+
let lastMeasure = 0;
|
|
207
|
+
let raf = 0;
|
|
208
|
+
function prerenderSafe(el, urlStr) {
|
|
209
|
+
try {
|
|
210
|
+
if (new URL(urlStr).search) return false;
|
|
211
|
+
} catch {
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
const rel = (el.getAttribute("rel") || "").toLowerCase();
|
|
215
|
+
if (/\b(nofollow|external)\b/.test(rel)) return false;
|
|
216
|
+
const target = el.getAttribute("target");
|
|
217
|
+
if (target && target !== "_self") return false;
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
function load(el, url, confidence, wantPrerender) {
|
|
221
|
+
if (destroyed) return;
|
|
222
|
+
let strategy = "prefetch";
|
|
223
|
+
if (wantPrerender && opts.prerenderThreshold !== false && confidence >= opts.prerenderThreshold && prerenderSafe(el, url)) {
|
|
224
|
+
strategy = "prerender";
|
|
225
|
+
}
|
|
226
|
+
const used = loader.load(url, strategy);
|
|
227
|
+
if (used) {
|
|
228
|
+
loadedView.add(url);
|
|
229
|
+
opts.onLoad?.(url, used);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
function emit(el, c, confidence, signal) {
|
|
233
|
+
if (!opts.onPredict) return;
|
|
234
|
+
if (Math.abs(c.emitted - confidence) < 0.02) return;
|
|
235
|
+
c.emitted = confidence;
|
|
236
|
+
opts.onPredict({ el, url: c.url, confidence, signal });
|
|
237
|
+
}
|
|
238
|
+
function consider(el) {
|
|
239
|
+
const url = scopeEl && !scopeEl.contains(el) ? null : eligible(el);
|
|
240
|
+
const existing = candidates.get(el);
|
|
241
|
+
if (!url) {
|
|
242
|
+
if (existing) drop(el);
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
if (existing) {
|
|
246
|
+
existing.url = url;
|
|
247
|
+
return existing;
|
|
248
|
+
}
|
|
249
|
+
const c = { url, armedFrames: 0, emitted: -1 };
|
|
250
|
+
candidates.set(el, c);
|
|
251
|
+
io.observe(el);
|
|
252
|
+
return c;
|
|
253
|
+
}
|
|
254
|
+
function drop(el) {
|
|
255
|
+
const c = candidates.get(el);
|
|
256
|
+
if (c) {
|
|
257
|
+
if (c.emitted > 0) emit(el, c, 0, "proximity");
|
|
258
|
+
candidates.delete(el);
|
|
259
|
+
io.unobserve(el);
|
|
260
|
+
visible.delete(el);
|
|
261
|
+
rectCache.delete(el);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
function scan() {
|
|
265
|
+
root.querySelectorAll("a[href]").forEach(consider);
|
|
266
|
+
}
|
|
267
|
+
const io = new IntersectionObserver(
|
|
268
|
+
(entries) => {
|
|
269
|
+
for (const e of entries) {
|
|
270
|
+
const el = e.target;
|
|
271
|
+
if (e.isIntersecting) {
|
|
272
|
+
visible.add(el);
|
|
273
|
+
rectCache.set(el, e.boundingClientRect);
|
|
274
|
+
if (useViewport) {
|
|
275
|
+
idle(() => {
|
|
276
|
+
const cc = candidates.get(el);
|
|
277
|
+
if (cc && !loadedView.has(cc.url)) load(el, cc.url, 0, false);
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
} else {
|
|
281
|
+
visible.delete(el);
|
|
282
|
+
rectCache.delete(el);
|
|
283
|
+
const c = candidates.get(el);
|
|
284
|
+
if (c && c.emitted > 0) emit(el, c, 0, "proximity");
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
},
|
|
288
|
+
{ rootMargin: "0px" }
|
|
289
|
+
);
|
|
290
|
+
const mo = new MutationObserver((muts) => {
|
|
291
|
+
for (const m of muts) {
|
|
292
|
+
if (m.type === "attributes") {
|
|
293
|
+
if (m.target instanceof HTMLAnchorElement) consider(m.target);
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
m.addedNodes.forEach((n) => {
|
|
297
|
+
if (!(n instanceof Element)) return;
|
|
298
|
+
if (n.matches?.("a[href]")) consider(n);
|
|
299
|
+
n.querySelectorAll?.("a[href]").forEach(consider);
|
|
300
|
+
});
|
|
301
|
+
m.removedNodes.forEach((n) => {
|
|
302
|
+
if (!(n instanceof Element)) return;
|
|
303
|
+
if (n.matches?.("a[href]")) drop(n);
|
|
304
|
+
n.querySelectorAll?.("a[href]").forEach((el) => drop(el));
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
function refreshRects() {
|
|
309
|
+
for (const el of visible) rectCache.set(el, el.getBoundingClientRect());
|
|
310
|
+
rectsDirty = false;
|
|
311
|
+
}
|
|
312
|
+
function kick() {
|
|
313
|
+
if (!raf && !destroyed && (useProximity || useTrajectory)) raf = requestAnimationFrame(tick);
|
|
314
|
+
}
|
|
315
|
+
function tick(t) {
|
|
316
|
+
if (destroyed) {
|
|
317
|
+
raf = 0;
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
if (document.hidden || t - lastMove > 500) {
|
|
321
|
+
raf = 0;
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
if (rectsDirty || t - lastMeasure > 250) {
|
|
325
|
+
refreshRects();
|
|
326
|
+
lastMeasure = t;
|
|
327
|
+
}
|
|
328
|
+
if (t - lastMove > 80) {
|
|
329
|
+
vel.x = 0;
|
|
330
|
+
vel.y = 0;
|
|
331
|
+
}
|
|
332
|
+
for (const el of visible) {
|
|
333
|
+
const c = candidates.get(el);
|
|
334
|
+
if (!c || loadedView.has(c.url)) continue;
|
|
335
|
+
const r = rectCache.get(el);
|
|
336
|
+
if (!r) continue;
|
|
337
|
+
const prox = useProximity ? proximityScore(pointer.x, pointer.y, r, opts.proximityRadius) : 0;
|
|
338
|
+
const traj = useTrajectory ? trajectoryScore(pointer.x, pointer.y, vel, r) : 0;
|
|
339
|
+
const confidence = Math.max(prox, traj);
|
|
340
|
+
const signal = traj >= prox ? "trajectory" : "proximity";
|
|
341
|
+
if (confidence < 0.05) {
|
|
342
|
+
c.armedFrames = 0;
|
|
343
|
+
emit(el, c, 0, signal);
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
let wantPrerender = false;
|
|
347
|
+
if (opts.prerenderThreshold !== false && confidence >= opts.prerenderThreshold) {
|
|
348
|
+
c.armedFrames++;
|
|
349
|
+
if (c.armedFrames >= ARM_FRAMES) wantPrerender = true;
|
|
350
|
+
} else {
|
|
351
|
+
c.armedFrames = 0;
|
|
352
|
+
}
|
|
353
|
+
if (confidence >= opts.intentThreshold || wantPrerender) {
|
|
354
|
+
load(el, c.url, confidence, wantPrerender);
|
|
355
|
+
}
|
|
356
|
+
emit(el, c, confidence, signal);
|
|
357
|
+
}
|
|
358
|
+
raf = requestAnimationFrame(tick);
|
|
359
|
+
}
|
|
360
|
+
function onMove(e) {
|
|
361
|
+
updateVelocity(vel, prevSample, e.clientX, e.clientY, e.timeStamp);
|
|
362
|
+
prevSample = { x: e.clientX, y: e.clientY, t: e.timeStamp };
|
|
363
|
+
pointer.x = e.clientX;
|
|
364
|
+
pointer.y = e.clientY;
|
|
365
|
+
lastMove = now();
|
|
366
|
+
kick();
|
|
367
|
+
}
|
|
368
|
+
let hovered = null;
|
|
369
|
+
let hoverTimer = 0;
|
|
370
|
+
function onOver(e) {
|
|
371
|
+
const a = e.target?.closest?.("a[href]");
|
|
372
|
+
if (!a || a === hovered) return;
|
|
373
|
+
hovered = a;
|
|
374
|
+
clearTimeout(hoverTimer);
|
|
375
|
+
const c = consider(a);
|
|
376
|
+
if (!c || loadedView.has(c.url)) return;
|
|
377
|
+
hoverTimer = window.setTimeout(() => {
|
|
378
|
+
if (hovered === a) load(a, c.url, 1, true);
|
|
379
|
+
}, opts.hoverDelay);
|
|
380
|
+
}
|
|
381
|
+
function onOut(e) {
|
|
382
|
+
if (hovered && e.relatedTarget instanceof Node && hovered.contains(e.relatedTarget)) return;
|
|
383
|
+
hovered = null;
|
|
384
|
+
clearTimeout(hoverTimer);
|
|
385
|
+
}
|
|
386
|
+
function onPress(e) {
|
|
387
|
+
const a = e.target?.closest?.("a[href]");
|
|
388
|
+
if (!a) return;
|
|
389
|
+
const c = consider(a);
|
|
390
|
+
if (c && !loadedView.has(c.url)) load(a, c.url, 1, false);
|
|
391
|
+
}
|
|
392
|
+
function onScroll() {
|
|
393
|
+
rectsDirty = true;
|
|
394
|
+
}
|
|
395
|
+
function onVisibility() {
|
|
396
|
+
if (!document.hidden) {
|
|
397
|
+
lastMove = now();
|
|
398
|
+
kick();
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
function idle(fn) {
|
|
402
|
+
const ric = window.requestIdleCallback;
|
|
403
|
+
const guarded = () => {
|
|
404
|
+
if (!destroyed) fn();
|
|
405
|
+
};
|
|
406
|
+
if (ric) ric(guarded);
|
|
407
|
+
else setTimeout(guarded, 1);
|
|
408
|
+
}
|
|
409
|
+
scan();
|
|
410
|
+
mo.observe(scopeEl ?? document.documentElement, {
|
|
411
|
+
childList: true,
|
|
412
|
+
subtree: true,
|
|
413
|
+
attributes: true,
|
|
414
|
+
attributeFilter: ["href"]
|
|
415
|
+
});
|
|
416
|
+
window.addEventListener("pointermove", onMove, { passive: true });
|
|
417
|
+
if (useHover) {
|
|
418
|
+
window.addEventListener("pointerover", onOver, { passive: true });
|
|
419
|
+
window.addEventListener("pointerout", onOut, { passive: true });
|
|
420
|
+
}
|
|
421
|
+
if (usePress) {
|
|
422
|
+
window.addEventListener("pointerdown", onPress, { passive: true });
|
|
423
|
+
window.addEventListener("touchstart", onPress, { passive: true });
|
|
424
|
+
}
|
|
425
|
+
window.addEventListener("scroll", onScroll, { passive: true, capture: true });
|
|
426
|
+
window.addEventListener("resize", onScroll, { passive: true });
|
|
427
|
+
document.addEventListener("visibilitychange", onVisibility);
|
|
428
|
+
return {
|
|
429
|
+
prefetch: (url) => {
|
|
430
|
+
const used = loader.load(url, "prefetch");
|
|
431
|
+
if (used) loadedView.add(url);
|
|
432
|
+
},
|
|
433
|
+
prerender: (url) => {
|
|
434
|
+
const used = loader.load(url, "prerender");
|
|
435
|
+
if (used) loadedView.add(url);
|
|
436
|
+
},
|
|
437
|
+
destroy() {
|
|
438
|
+
destroyed = true;
|
|
439
|
+
if (raf) cancelAnimationFrame(raf);
|
|
440
|
+
clearTimeout(hoverTimer);
|
|
441
|
+
io.disconnect();
|
|
442
|
+
mo.disconnect();
|
|
443
|
+
window.removeEventListener("pointermove", onMove);
|
|
444
|
+
window.removeEventListener("pointerover", onOver);
|
|
445
|
+
window.removeEventListener("pointerout", onOut);
|
|
446
|
+
window.removeEventListener("pointerdown", onPress);
|
|
447
|
+
window.removeEventListener("touchstart", onPress);
|
|
448
|
+
window.removeEventListener("scroll", onScroll, { capture: true });
|
|
449
|
+
window.removeEventListener("resize", onScroll);
|
|
450
|
+
document.removeEventListener("visibilitychange", onVisibility);
|
|
451
|
+
loader.destroy();
|
|
452
|
+
candidates.clear();
|
|
453
|
+
visible.clear();
|
|
454
|
+
rectCache.clear();
|
|
455
|
+
},
|
|
456
|
+
loaded: loadedView
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
export { distanceToRect, falloff, intently, proximityScore, trajectoryScore, updateVelocity };
|
|
461
|
+
//# sourceMappingURL=chunk-WUH3J7BB.js.map
|
|
462
|
+
//# sourceMappingURL=chunk-WUH3J7BB.js.map
|