UNPKG

v-proximity-prefetch

Version:

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

606 lines (550 loc) 22.2 kB
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