UNPKG

v-proximity-prefetch

Version:

Vue plugin that prefetches routes when the mouse approaches links for faster navigation

328 lines (272 loc) 18.8 kB
"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