v-proximity-prefetch
Version:
Vue plugin that prefetches routes when the mouse approaches links for faster navigation
606 lines (550 loc) • 22.2 kB
JavaScript
import { defineComponent as N, computed as O, ref as m, onMounted as V, watch as z, onUnmounted as q, renderSlot as H } from "vue";
import { useRouter as W } from "vue-router";
const x = 100, U = /* @__PURE__ */ N({
__name: "ProximityPrefetch",
props: {
threshold: { default: 200 },
predictionInterval: { default: 0 },
debug: { type: Boolean, default: !1 },
mobileSupport: { type: Boolean, default: !0 },
viewportMargin: { default: 300 },
prefetchAllLinks: { type: Boolean, default: !1 },
prefetchAllLinksDelay: { default: 1500 }
},
setup(s) {
const P = typeof window < "u" && window.PPF_DEBUG === !0, n = s, r = O(() => n.debug || P), g = W(), u = m({ x: 0, y: 0 }), v = m(/* @__PURE__ */ new Set()), a = m([]), R = m(!1), w = m(Date.now()), f = m(!1), S = () => typeof window < "u" && ("ontouchstart" in window || navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0), y = () => {
const e = Array.from(
document.querySelectorAll("a[href]")
);
r.value && console.debug(`[ProximityPrefetch] Found ${e.length} links in the page`), a.value = e.map((t) => {
const i = t.getAttribute("href");
if (i && (i.startsWith("/") || !i.includes("://")) && !i.startsWith("#")) {
const c = t.getBoundingClientRect();
return r.value && console.debug(`[ProximityPrefetch] Link found: ${i}, rect:`, c), {
el: t,
href: i,
rect: c
};
}
return null;
}).filter((t) => t !== null);
}, L = (e, t, i, c) => Math.sqrt((i - e) ** 2 + (c - t) ** 2), D = (e) => ({
x: e.left + e.width / 2,
y: e.top + e.height / 2
}), E = (e) => e.top >= -n.viewportMargin && e.left >= -n.viewportMargin && e.bottom <= window.innerHeight + n.viewportMargin && e.right <= window.innerWidth + n.viewportMargin, M = () => {
if (!a.value.length)
return !1;
a.value.forEach((i) => {
i.rect = i.el.getBoundingClientRect();
});
const t = a.value.map((i) => {
const c = D(i.rect), p = L(
u.value.x,
u.value.y,
c.x,
c.y
);
return { ...i, distance: p };
}).filter(
(i) => i.distance < n.threshold
);
return r.value && t.length > 0 && (console.debug(`[ProximityPrefetch] ${t.length} links within threshold ${n.threshold}px`), t.forEach((i) => {
console.debug(`[ProximityPrefetch] Link: ${i.href}, Distance: ${i.distance.toFixed(2)}px`);
})), t.length > 0;
}, I = () => {
if (!a.value.length)
return !1;
a.value.forEach((t) => {
t.rect = t.el.getBoundingClientRect();
});
const e = a.value.filter((t) => E(t.rect));
return r.value && e.length > 0 && console.debug(`[ProximityPrefetch] ${e.length} links in viewport (plus margin ${n.viewportMargin}px)`), e.length > 0;
}, k = () => {
const e = Date.now();
if (e - w.value < x)
return;
w.value = e;
let t;
if (f.value && n.mobileSupport ? t = I() : t = M(), R.value = t, !t || !a.value.length)
return;
a.value.forEach((o) => {
o.rect = o.el.getBoundingClientRect();
});
let i;
if (f.value && n.mobileSupport)
i = a.value.filter((o) => E(o.rect)), i.sort((o, l) => o.rect.top - l.rect.top);
else {
const o = a.value.map((l) => {
const h = D(l.rect), d = L(
u.value.x,
u.value.y,
h.x,
h.y
);
return { ...l, distance: d };
});
o.sort((l, h) => l.distance - h.distance), i = o.filter((l) => l.distance < n.threshold).map(({ el: l, href: h, rect: d }) => ({ el: l, href: h, rect: d }));
}
const c = i.map((o) => o.href), p = 3;
for (const o of c.slice(0, p))
if (!v.value.has(o)) {
r.value && console.log("[ProximityPrefetch] Prefetching:", o);
try {
const l = g.resolve(o);
g.getRoutes().forEach((h) => {
if (h.path === l.path && h.components) {
const d = h.components;
Object.values(d).forEach((B) => {
const C = B;
if (typeof C == "function")
try {
C();
} catch (F) {
r.value && console.error("[ProximityPrefetch] Error loading component:", F);
}
});
}
}), v.value.add(o), r.value && Array.from(
document.querySelectorAll(`a[href="${o}"]`)
).forEach((d) => {
d.hasAttribute("data-ppf-debug-applied") || (d.setAttribute("data-ppf-debug-applied", "true"), d.classList.add("ppf-debug-highlight"), d.title = `Prefetched: ${o}`);
});
} catch (l) {
r.value && console.error("[ProximityPrefetch] Error prefetching route:", l);
}
}
}, $ = (e) => {
if (!v.value.has(e)) {
r.value && console.log("[ProximityPrefetch] Prefetching:", e);
try {
const t = g.resolve(e);
g.getRoutes().forEach((i) => {
if (i.path === t.path && i.components) {
const c = i.components;
Object.values(c).forEach((p) => {
const o = p;
if (typeof o == "function")
try {
o();
} catch (l) {
r.value && console.error("[ProximityPrefetch] Error loading component:", l);
}
});
}
}), v.value.add(e), r.value && Array.from(
document.querySelectorAll(`a[href="${e}"]`)
).forEach((c) => {
c.hasAttribute("data-ppf-debug-applied") || (c.setAttribute("data-ppf-debug-applied", "true"), c.classList.add("ppf-debug-highlight"), c.title = `Prefetched: ${e}`);
});
} catch (t) {
r.value && console.error("[ProximityPrefetch] Error prefetching route:", t);
}
}
}, _ = () => {
if (y(), !a.value.length) {
r.value && console.log("[ProximityPrefetch] No links found to prefetch");
return;
}
r.value && console.log(`[ProximityPrefetch] Prefetching all links: ${a.value.length} links found`);
const e = [...new Set(a.value.map((o) => o.href))];
r.value && console.log(`[ProximityPrefetch] Unique routes to prefetch: ${e.length}`);
let t = 0;
const i = 3, c = 300, p = () => {
const o = e.slice(t, t + i);
if (o.length !== 0) {
for (const l of o)
$(l);
t += o.length, t < e.length ? setTimeout(p, c) : r.value && console.log(`[ProximityPrefetch] Finished prefetching all links: ${t} routes prefetched`);
}
};
p();
}, A = (e) => {
u.value = { x: e.clientX, y: e.clientY };
}, b = () => {
f.value && n.mobileSupport && Date.now() - w.value >= x && k();
}, T = () => {
f.value && n.mobileSupport && k();
};
return V(() => {
if (f.value = S(), r.value) {
console.log("[ProximityPrefetch] Component mounted with options:", {
threshold: n.threshold,
predictionInterval: n.predictionInterval,
debug: r.value,
mobileSupport: n.mobileSupport,
viewportMargin: n.viewportMargin,
deviceType: f.value ? "Touch device" : "Desktop device"
});
const i = document.createElement("style");
i.textContent = `
.ppf-debug-highlight {
border: 2px solid red !important;
box-sizing: border-box;
}
`, document.head.appendChild(i);
}
setTimeout(() => {
y(), r.value && console.log(`[ProximityPrefetch] Initial links detection: ${a.value.length} links found`), k();
}, 500);
const e = new MutationObserver(() => {
y();
});
e.observe(document.body, {
childList: !0,
// Watch for added/removed nodes
subtree: !0,
// Include descendants
attributes: !0,
// Watch for attribute changes
attributeFilter: ["href"]
// Only care about href changes
}), f.value && n.mobileSupport ? (window.addEventListener("scroll", b, { passive: !0 }), window.addEventListener("touchstart", T, { passive: !0 }), window.addEventListener("resize", b, { passive: !0 }), r.value && console.log("[ProximityPrefetch] Mobile mode initialized with viewport margin:", n.viewportMargin + "px")) : (window.addEventListener("mousemove", A), r.value && console.log("[ProximityPrefetch] Desktop mode initialized"));
let t;
if (n.predictionInterval > 0)
t = window.setInterval(() => {
(f.value || u.value.x !== 0 || u.value.y !== 0) && k();
}, n.predictionInterval);
else if (!f.value) {
const i = () => {
Date.now() - w.value >= x && k();
};
z(u, () => {
i();
});
}
n.prefetchAllLinks && setTimeout(() => {
_();
}, n.prefetchAllLinksDelay), q(() => {
f.value && n.mobileSupport ? (window.removeEventListener("scroll", b), window.removeEventListener("touchstart", T), window.removeEventListener("resize", b)) : window.removeEventListener("mousemove", A), e.disconnect(), t && window.clearInterval(t);
});
}), (e, t) => H(e.$slots, "default");
}
}), G = {
threshold: 200,
predictionInterval: 0,
maxPrefetch: 3,
debug: !1,
automaticPrefetch: !1,
mobileSupport: !0,
viewportMargin: 300,
prefetchAllLinks: !1,
prefetchAllLinksDelay: 1500
};
function j(s) {
return `
<!-- Injected by Vue Proximity Prefetch Plugin -->
<script>
(function() {
// Set global PPF_DEBUG flag for the Vue component to detect
window.PPF_DEBUG = ${s.debug};
// Configuration from Vite plugin
const config = {
threshold: ${s.threshold},
predictionInterval: ${s.predictionInterval},
maxPrefetch: ${s.maxPrefetch},
debug: ${s.debug} || (typeof window !== 'undefined' && window.PPF_DEBUG === true),
mobileSupport: ${s.mobileSupport},
viewportMargin: ${s.viewportMargin},
prefetchAllLinks: ${s.prefetchAllLinks},
prefetchAllLinksDelay: ${s.prefetchAllLinksDelay}
};
// Utils
const log = config.debug ? console.log.bind(console, '[ProximityPrefetch]') : () => {};
log('Automatic prefetching enabled with options:', config);
// State variables
let mousePosition = { x: 0, y: 0 };
let prefetchedRoutes = new Set();
let lastCheck = Date.now();
const THROTTLE_INTERVAL = 100;
// Device detection
let isTouchDevice = false;
// Detect touch devices
function detectTouchDevice() {
return (('ontouchstart' in window) ||
(navigator.maxTouchPoints > 0) ||
(navigator.msMaxTouchPoints > 0));
}
// Calculate Euclidean distance between two points
function calculateDistance(x1, y1, x2, y2) {
return Math.sqrt((x2 - x1) ** 2 + (y1 - y2) ** 2);
}
// Calculate center point of a DOMRect
function calculateCenterPoint(rect) {
return {
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2
};
}
// Get all valid links on the page
function getLinks() {
const anchors = Array.from(document.querySelectorAll('a[href]'));
return anchors
.map((el) => {
const href = el.getAttribute('href');
// Only include internal links (starting with / or without ://) and not anchor links
if (href && (href.startsWith('/') || !href.includes('://')) && !href.startsWith('#')) {
const rect = el.getBoundingClientRect();
return { el, href, rect };
}
return null;
})
.filter(link => link !== null);
}
// Prefetch a single route
function prefetchRoute(route) {
if (prefetchedRoutes.has(route)) return;
try {
// Create a prefetch link element
const link = document.createElement('link');
link.rel = 'prefetch';
link.href = route;
link.as = 'document';
document.head.appendChild(link);
prefetchedRoutes.add(route);
// In debug mode, add a visual indicator around the link
if (config.debug) {
// Find all link elements pointing to this route
const matchingAnchors = Array.from(document.querySelectorAll('a[href="' + route + '"]'));
matchingAnchors.forEach(anchor => {
// Add a red border directly to the link element if not already applied
if (!anchor.hasAttribute('data-ppf-debug-applied')) {
anchor.setAttribute('data-ppf-debug-applied', 'true');
anchor.classList.add('ppf-debug-highlight');
anchor.title = 'Prefetched: ' + route;
}
});
}
return true;
} catch (err) {
console.error('[ProximityPrefetch] Error prefetching route:', route, err);
return false;
}
}
// Prefetch all links on the page
function prefetchAllPageLinks() {
const links = getLinks();
if (!links.length) return;
log('Prefetching all links on page: ' + links.length + ' links found');
// Get unique routes
const uniqueRoutes = [...new Set(links.map(link => link.href))];
// Batch prefetching with small delays to avoid network congestion
let processed = 0;
const batchSize = 3;
const batchDelay = 300;
function processBatch() {
const batch = uniqueRoutes.slice(processed, processed + batchSize);
if (batch.length === 0) return;
for (const route of batch) {
prefetchRoute(route);
}
processed += batch.length;
if (processed < uniqueRoutes.length) {
setTimeout(processBatch, batchDelay);
} else if (config.debug) {
log('Finished prefetching all links: ' + processed + ' routes prefetched');
}
}
processBatch();
}
// Check if a link is in or near the viewport
function isLinkInViewport(rect) {
// Check if fully in viewport
const isVisible = (
rect.top >= -config.viewportMargin &&
rect.left >= -config.viewportMargin &&
rect.bottom <= window.innerHeight + config.viewportMargin &&
rect.right <= window.innerWidth + config.viewportMargin
);
return isVisible;
}
// Check if mouse is near any links (for desktop)
function checkProximity() {
const links = getLinks();
if (!links.length) return false;
// Calculate distance between mouse and each link
const linksWithDistance = links.map((link) => {
const center = calculateCenterPoint(link.rect);
const distance = calculateDistance(
mousePosition.x,
mousePosition.y,
center.x,
center.y
);
return { ...link, distance };
});
// Find links within threshold distance
const closestLinks = linksWithDistance.filter(
(link) => link.distance < config.threshold
);
if (config.debug && closestLinks.length > 0) {
log(closestLinks.length + ' links within threshold ' + config.threshold + 'px');
}
return closestLinks;
}
// Check which links are in or near viewport (for mobile)
function checkViewportLinks() {
const links = getLinks();
if (!links.length) return false;
// Filter links that are in or near the viewport
const visibleLinks = links.filter(link => isLinkInViewport(link.rect));
if (config.debug && visibleLinks.length > 0) {
log(visibleLinks.length + ' links in viewport (plus margin ' + config.viewportMargin + 'px)');
}
return visibleLinks;
}
// Prefetch routes when mouse is near links or links are in viewport
function prefetchNearbyRoutes() {
const now = Date.now();
if (now - lastCheck < THROTTLE_INTERVAL) return;
lastCheck = now;
// Choose detection strategy based on device type
const links = isTouchDevice ? checkViewportLinks() : checkProximity();
if (!links || !links.length) return;
// Sort links: by distance for desktop, by position for mobile
if (isTouchDevice) {
// On mobile, prioritize links near the top of viewport
links.sort((a, b) => a.rect.top - b.rect.top);
} else {
// On desktop, keep sorting by distance
links.sort((a, b) => a.distance - b.distance);
}
// Limit prefetching to maxPrefetch routes
const routesToPrefetch = links.slice(0, config.maxPrefetch).map(link => link.href);
// Keep track of the first link being processed
let isFirstPrefetch = !window.PPF_HAS_PREFETCHED;
// Prefetch routes
for (const route of routesToPrefetch) {
prefetchRoute(route);
}
}
// Initialize
function init() {
// Detect device type
isTouchDevice = detectTouchDevice();
log('Device detection: ' + (isTouchDevice ? 'Touch device' : 'Desktop device'));
// Add debug styles to the page if in debug mode
if (config.debug) {
const style = document.createElement('style');
style.textContent =
'.ppf-debug-highlight {' +
' box-shadow: 0 0 0 2px red !important;' +
' box-sizing: border-box;' +
'}';
document.head.appendChild(style);
}
// If prefetchAllLinks is enabled, prefetch all links after a delay
if (config.prefetchAllLinks) {
log('prefetchAllLinks enabled, will prefetch all links after ' + config.prefetchAllLinksDelay + 'ms');
setTimeout(prefetchAllPageLinks, config.prefetchAllLinksDelay);
}
if (isTouchDevice && config.mobileSupport) {
// Mobile approach: viewport-based prefetching
// 1. Check on page load
prefetchNearbyRoutes();
// 2. Check on scroll with throttling
window.addEventListener('scroll', () => {
const now = Date.now();
if (now - lastCheck > THROTTLE_INTERVAL) {
prefetchNearbyRoutes();
}
}, { passive: true });
// 3. Check on touch events
window.addEventListener('touchstart', () => {
prefetchNearbyRoutes();
}, { passive: true });
// 4. Check periodically if interval is set
if (config.predictionInterval > 0) {
setInterval(prefetchNearbyRoutes, config.predictionInterval);
}
log('Mobile prefetching initialized with viewport margin:', config.viewportMargin + 'px');
} else {
// Desktop approach: mouse proximity
// Mouse move listener
window.addEventListener('mousemove', (e) => {
mousePosition = { x: e.clientX, y: e.clientY };
// Reactive mode
if (config.predictionInterval === 0) {
prefetchNearbyRoutes();
}
});
// Interval mode
if (config.predictionInterval > 0) {
setInterval(() => {
if (mousePosition.x !== 0 || mousePosition.y !== 0) {
prefetchNearbyRoutes();
}
}, config.predictionInterval);
}
log('Desktop proximity prefetching initialized');
}
// Window resize handler to update link positions
window.addEventListener('resize', () => {
// Throttle resize event
const now = Date.now();
if (now - lastCheck > THROTTLE_INTERVAL * 2) {
lastCheck = now;
prefetchNearbyRoutes();
}
}, { passive: true });
}
// Start when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
<\/script>
`;
}
function K(s = {}) {
const P = process.env.PPF_DEBUG === "true", n = {
...G,
...s,
debug: s.debug || P
};
return {
name: "vite-plugin-vue-proximity-prefetch",
configResolved() {
console.log("Vue Proximity Prefetch Plugin enabled"), n.debug && console.log("Options:", {
threshold: n.threshold,
predictionInterval: n.predictionInterval,
maxPrefetch: n.maxPrefetch,
automaticPrefetch: n.automaticPrefetch,
debug: n.debug,
mobileSupport: n.mobileSupport,
viewportMargin: n.viewportMargin
});
},
/**
* Transform HTML to add prefetch attributes to preloaded modules
* and inject the automatic prefetching script if enabled
*/
transformIndexHtml(r) {
const g = r.replace(
/<link rel="modulepreload"/g,
'<link rel="modulepreload" data-prefetch="true"'
);
if (n.automaticPrefetch) {
const u = "</head>", v = j(n);
return g.replace(
u,
`${v}
${u}`
);
}
return g;
}
};
}
const J = {
install(s) {
s.component("ProximityPrefetch", U);
}
};
export {
U as ProximityPrefetch,
J as ProximityPrefetchPlugin,
K as viteProximityPrefetch
};
//# sourceMappingURL=index.js.map