v-proximity-prefetch
Version:
Vue plugin that prefetches routes when the mouse approaches links for faster navigation
328 lines (272 loc) • 18.8 kB
JavaScript
"use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const u=require("vue"),N=require("vue-router"),x=100,R=u.defineComponent({__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=u.computed(()=>n.debug||P),v=N.useRouter(),h=u.ref({x:0,y:0}),m=u.ref(new Set),a=u.ref([]),S=u.ref(!1),w=u.ref(Date.now()),d=u.ref(!1),M=()=>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,I=()=>{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),g=L(h.value.x,h.value.y,c.x,c.y);return{...i,distance:g}}).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},$=()=>{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(d.value&&n.mobileSupport?t=$():t=I(),S.value=t,!t||!a.value.length)return;a.value.forEach(o=>{o.rect=o.el.getBoundingClientRect()});let i;if(d.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 f=D(l.rect),p=L(h.value.x,h.value.y,f.x,f.y);return{...l,distance:p}});o.sort((l,f)=>l.distance-f.distance),i=o.filter(l=>l.distance<n.threshold).map(({el:l,href:f,rect:p})=>({el:l,href:f,rect:p}))}const c=i.map(o=>o.href),g=3;for(const o of c.slice(0,g))if(!m.value.has(o)){r.value&&console.log("[ProximityPrefetch] Prefetching:",o);try{const l=v.resolve(o);v.getRoutes().forEach(f=>{if(f.path===l.path&&f.components){const p=f.components;Object.values(p).forEach(F=>{const C=F;if(typeof C=="function")try{C()}catch(O){r.value&&console.error("[ProximityPrefetch] Error loading component:",O)}})}}),m.value.add(o),r.value&&Array.from(document.querySelectorAll(`a[href="${o}"]`)).forEach(p=>{p.hasAttribute("data-ppf-debug-applied")||(p.setAttribute("data-ppf-debug-applied","true"),p.classList.add("ppf-debug-highlight"),p.title=`Prefetched: ${o}`)})}catch(l){r.value&&console.error("[ProximityPrefetch] Error prefetching route:",l)}}},_=e=>{if(!m.value.has(e)){r.value&&console.log("[ProximityPrefetch] Prefetching:",e);try{const t=v.resolve(e);v.getRoutes().forEach(i=>{if(i.path===t.path&&i.components){const c=i.components;Object.values(c).forEach(g=>{const o=g;if(typeof o=="function")try{o()}catch(l){r.value&&console.error("[ProximityPrefetch] Error loading component:",l)}})}}),m.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)}}},B=()=>{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,g=()=>{const o=e.slice(t,t+i);if(o.length!==0){for(const l of o)_(l);t+=o.length,t<e.length?setTimeout(g,c):r.value&&console.log(`[ProximityPrefetch] Finished prefetching all links: ${t} routes prefetched`)}};g()},A=e=>{h.value={x:e.clientX,y:e.clientY}},b=()=>{d.value&&n.mobileSupport&&Date.now()-w.value>=x&&k()},T=()=>{d.value&&n.mobileSupport&&k()};return u.onMounted(()=>{if(d.value=M(),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:d.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,subtree:!0,attributes:!0,attributeFilter:["href"]}),d.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(()=>{(d.value||h.value.x!==0||h.value.y!==0)&&k()},n.predictionInterval);else if(!d.value){const i=()=>{Date.now()-w.value>=x&&k()};u.watch(h,()=>{i()})}n.prefetchAllLinks&&setTimeout(()=>{B()},n.prefetchAllLinksDelay),u.onUnmounted(()=>{d.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)=>u.renderSlot(e.$slots,"default")}}),V={threshold:200,predictionInterval:0,maxPrefetch:3,debug:!1,automaticPrefetch:!1,mobileSupport:!0,viewportMargin:300,prefetchAllLinks:!1,prefetchAllLinksDelay:1500};function z(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 q(s={}){const P=process.env.PPF_DEBUG==="true",n={...V,...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})},transformIndexHtml(r){const v=r.replace(/<link rel="modulepreload"/g,'<link rel="modulepreload" data-prefetch="true"');if(n.automaticPrefetch){const h="</head>",m=z(n);return v.replace(h,`${m}
${h}`)}return v}}}const H={install(s){s.component("ProximityPrefetch",R)}};exports.ProximityPrefetch=R;exports.ProximityPrefetchPlugin=H;exports.viteProximityPrefetch=q;
//# sourceMappingURL=index.cjs.map