| 1 | import type { SlideRoute } from '@slidev/types' |
| 2 | import type { ComputedRef, Ref } from 'vue' |
| 3 | import { computed, ref, watchEffect } from 'vue' |
| 4 | import configs from '#slidev/configs' |
| 5 | |
| 6 | const loaded = new Set<string>() |
| 7 | const loading = new Set<string>() |
| 8 | const RE_TRAILING_SLASH = /\/$/ |
| 9 | |
| 10 | function resolveUrl(url: string): string { |
| 11 | if (url.startsWith('http') || url.startsWith('//')) |
| 12 | return url |
| 13 | const base = (import.meta.env.BASE_URL || '/').replace(RE_TRAILING_SLASH, '') |
| 14 | return `${base}${url.startsWith('/') ? url : `/${url}`}` |
| 15 | } |
| 16 | |
| 17 | const RETRY_LIMIT = 2 |
| 18 | const retries = new Map<string, number>() |
| 19 | |
| 20 | const preloadedCount = ref(0) |
| 21 | const totalImagesCount = ref(0) |
| 22 | |
| 23 | /** |
| 24 | * Progress of slide-image preloading, from 0 to 1 (1 when every preloadable |
| 25 | * image in the deck has loaded, or when the deck has no images). |
| 26 | */ |
| 27 | export const preloadProgress = computed(() => |
| 28 | totalImagesCount.value === 0 ? 1 : preloadedCount.value / totalImagesCount.value, |
| 29 | ) |
| 30 | |
| 31 | /** Whether all preloadable slide images have finished loading. */ |
| 32 | export const preloadComplete = computed(() => preloadProgress.value >= 1) |
| 33 | |
| 34 | function preloadImage(url: string): void { |
| 35 | const resolved = resolveUrl(url) |
| 36 | if (loaded.has(resolved) || loading.has(resolved)) |
| 37 | return |
| 38 | loading.add(resolved) |
| 39 | const img = new Image() |
| 40 | img.onload = () => { |
| 41 | loading.delete(resolved) |
| 42 | loaded.add(resolved) |
| 43 | retries.delete(resolved) |
| 44 | preloadedCount.value = loaded.size |
| 45 | } |
| 46 | img.onerror = () => { |
| 47 | loading.delete(resolved) |
| 48 | // Retry transient preload failures (e.g. flaky network) instead of silently |
| 49 | // giving up; bounded so genuinely-missing assets aren't hammered. |
| 50 | const attempts = retries.get(resolved) ?? 0 |
| 51 | if (attempts < RETRY_LIMIT) { |
| 52 | retries.set(resolved, attempts + 1) |
| 53 | setTimeout(preloadImage, 1000 * (attempts + 1), url) |
| 54 | } |
| 55 | } |
| 56 | img.src = resolved |
| 57 | } |
| 58 | |
| 59 | function preloadSlideImages(route: SlideRoute): void { |
| 60 | const images = route.meta?.slide?.images |
| 61 | if (images?.length) { |
| 62 | for (const url of images) |
| 63 | preloadImage(url) |
| 64 | } |
| 65 | } |
| 66 | |
| 67 | export function usePreloadImages( |
| 68 | currentRoute: ComputedRef<SlideRoute>, |
| 69 | prevRoute: ComputedRef<SlideRoute>, |
| 70 | nextRoute: ComputedRef<SlideRoute>, |
| 71 | slides: Ref<SlideRoute[]>, |
| 72 | ): void { |
| 73 | const config = configs.preloadImages |
| 74 | if (config === false) |
| 75 | return |
| 76 | |
| 77 | const ahead = (typeof config === 'object' && config?.ahead) || 3 |
| 78 | |
| 79 | // Track total preloadable images across the deck for progress reporting |
| 80 | watchEffect(() => { |
| 81 | const all = slides.value |
| 82 | if (!all?.length) |
| 83 | return |
| 84 | const urls = new Set<string>() |
| 85 | for (const route of all) { |
| 86 | for (const url of route.meta?.slide?.images ?? []) |
| 87 | urls.add(resolveUrl(url)) |
| 88 | } |
| 89 | totalImagesCount.value = urls.size |
| 90 | }) |
| 91 | |
| 92 | // Preload current + prev + next + look-ahead window |
| 93 | watchEffect(() => { |
| 94 | const current = currentRoute.value |
| 95 | const all = slides.value |
| 96 | if (!current || !all?.length) |
| 97 | return |
| 98 | |
| 99 | preloadSlideImages(current) |
| 100 | preloadSlideImages(prevRoute.value) |
| 101 | preloadSlideImages(nextRoute.value) |
| 102 | |
| 103 | // Preload ahead window |
| 104 | const currentIdx = current.no - 1 |
| 105 | for (let i = 1; i <= ahead; i++) { |
| 106 | const idx = currentIdx + i |
| 107 | if (idx < all.length) |
| 108 | preloadSlideImages(all[idx]) |
| 109 | } |
| 110 | }) |
| 111 | |
| 112 | // Preload all remaining slides after 3s |
| 113 | watchEffect((onCleanup) => { |
| 114 | const all = slides.value |
| 115 | const timeout = setTimeout(() => { |
| 116 | if (all?.length) { |
| 117 | for (const route of all) |
| 118 | preloadSlideImages(route) |
| 119 | } |
| 120 | }, 3000) |
| 121 | onCleanup(() => clearTimeout(timeout)) |
| 122 | }) |
| 123 | } |
| 124 |