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 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