Skip to content

Commit f1eca71

Browse files
committed
feat: smart image preloading with build-time extraction and runtime scheduling
Extract image URLs at parse time from all sources (frontmatter, markdown, Vue component props, CSS url()) and store in `images[]` on SlideInfoBase. This field survives build-mode content stripping. Build-time: generates `<link rel="preload" as="image">` tags in HTML head. Runtime: navigation-aware composable preloads current + ahead window immediately, then all remaining slides after 3s. Configurable via headmatter `preloadImages` (default: true). Supersedes slidevjs#2450 with comprehensive coverage.
1 parent 932dc3c commit f1eca71

File tree

9 files changed

+207
-0
lines changed

9 files changed

+207
-0
lines changed
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import type { SlideRoute } from '@slidev/types'
2+
import type { ComputedRef, Ref } from 'vue'
3+
import configs from '#slidev/configs'
4+
import { watchEffect } from 'vue'
5+
6+
const loaded = new Set<string>()
7+
const loading = new Set<string>()
8+
9+
function resolveUrl(url: string): string {
10+
if (url.startsWith('http') || url.startsWith('//'))
11+
return url
12+
const base = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
13+
return `${base}${url.startsWith('/') ? url : `/${url}`}`
14+
}
15+
16+
function preloadImage(url: string): void {
17+
const resolved = resolveUrl(url)
18+
if (loaded.has(resolved) || loading.has(resolved))
19+
return
20+
loading.add(resolved)
21+
const img = new Image()
22+
img.onload = () => {
23+
loading.delete(resolved)
24+
loaded.add(resolved)
25+
}
26+
img.onerror = () => {
27+
loading.delete(resolved)
28+
}
29+
img.src = resolved
30+
}
31+
32+
function preloadSlideImages(route: SlideRoute): void {
33+
const images = route.meta?.slide?.images
34+
if (images?.length) {
35+
for (const url of images)
36+
preloadImage(url)
37+
}
38+
}
39+
40+
export function usePreloadImages(
41+
currentRoute: ComputedRef<SlideRoute>,
42+
prevRoute: ComputedRef<SlideRoute>,
43+
nextRoute: ComputedRef<SlideRoute>,
44+
slides: Ref<SlideRoute[]>,
45+
): void {
46+
const config = configs.preloadImages
47+
if (config === false)
48+
return
49+
50+
const ahead = (typeof config === 'object' && config?.ahead) || 3
51+
52+
// Preload current + prev + next + look-ahead window
53+
watchEffect(() => {
54+
const current = currentRoute.value
55+
const all = slides.value
56+
if (!current || !all?.length)
57+
return
58+
59+
preloadSlideImages(current)
60+
preloadSlideImages(prevRoute.value)
61+
preloadSlideImages(nextRoute.value)
62+
63+
// Preload ahead window
64+
const currentIdx = current.no - 1
65+
for (let i = 1; i <= ahead; i++) {
66+
const idx = currentIdx + i
67+
if (idx < all.length)
68+
preloadSlideImages(all[idx])
69+
}
70+
})
71+
72+
// Preload all remaining slides after 3s
73+
watchEffect((onCleanup) => {
74+
const all = slides.value
75+
const timeout = setTimeout(() => {
76+
if (all?.length) {
77+
for (const route of all)
78+
preloadSlideImages(route)
79+
}
80+
}, 3000)
81+
onCleanup(() => clearTimeout(timeout))
82+
})
83+
}

packages/client/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,4 +87,5 @@ export const HEADMATTER_FIELDS = [
8787
'seoMeta',
8888
'notesAutoRuby',
8989
'magicMoveDuration',
90+
'preloadImages',
9091
]

packages/client/internals/SlidesShow.vue

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { recomputeAllPoppers } from 'floating-vue'
55
import { computed, shallowRef, TransitionGroup, watchEffect } from 'vue'
66
import { createFixedClicks } from '../composables/useClicks'
77
import { useNav } from '../composables/useNav'
8+
import { usePreloadImages } from '../composables/usePreloadImages'
89
import { useViewTransition } from '../composables/useViewTransition'
910
import { CLICKS_MAX } from '../constants'
1011
import { activeDragElement, disableTransition, hmrSkipTransition } from '../state'
@@ -49,6 +50,9 @@ watchEffect((onCleanup) => {
4950
onCleanup(() => clearTimeout(timeout))
5051
})
5152
53+
// preload images for nearby slides
54+
usePreloadImages(currentSlideRoute, prevRoute, nextRoute, slides)
55+
5256
const hasViewTransition = useViewTransition()
5357
5458
const DrawingLayer = shallowRef<any>()

packages/parser/src/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export function getDefaultConfig(): SlidevConfig {
5151
duration: '30min',
5252
timer: 'stopwatch',
5353
magicMoveDuration: 800,
54+
preloadImages: true,
5455
}
5556
}
5657

packages/parser/src/core.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,60 @@ function matter(code: string, options: SlidevParserOptions) {
6666
}
6767
}
6868

69+
const IMAGE_EXTENSIONS = /\.(?:png|jpe?g|gif|svg|webp|avif|ico|bmp|tiff?)$/i
70+
71+
/**
72+
* Extract image URLs from slide content and frontmatter.
73+
* Strips code blocks first to avoid false positives.
74+
*/
75+
export function extractImages(content: string, frontmatter: Record<string, any>): string[] {
76+
const images = new Set<string>()
77+
78+
// Collect from frontmatter keys
79+
for (const key of ['image', 'backgroundImage', 'background']) {
80+
const val = frontmatter[key]
81+
if (typeof val === 'string' && val && !val.startsWith('data:')) {
82+
// For `background`, only include if it looks like an image URL
83+
if (key === 'background') {
84+
if (IMAGE_EXTENSIONS.test(val) || val.startsWith('/') || val.startsWith('http'))
85+
images.add(val)
86+
}
87+
else {
88+
images.add(val)
89+
}
90+
}
91+
}
92+
93+
// Strip code blocks to avoid false positives
94+
const stripped = content.replace(/^```[\s\S]+?^```/gm, '')
95+
96+
// Markdown images: ![alt](url)
97+
for (const [, url] of stripped.matchAll(/!\[[^\]]*\]\(([^)]+)\)/g)) {
98+
if (url && !url.startsWith('data:'))
99+
images.add(url.trim())
100+
}
101+
102+
// Vue component props: src="url", image="url"
103+
for (const [, url] of stripped.matchAll(/\b(?:src|image)=["']([^"']+)["']/g)) {
104+
if (url && !url.startsWith('data:') && !url.includes('{{') && IMAGE_EXTENSIONS.test(url))
105+
images.add(url.trim())
106+
}
107+
108+
// Vue bound props: :src="'/path/to/img.png'"
109+
for (const [, url] of stripped.matchAll(/:(?:src|image)=["']'([^']+)'["']/g)) {
110+
if (url && !url.startsWith('data:') && IMAGE_EXTENSIONS.test(url))
111+
images.add(url.trim())
112+
}
113+
114+
// CSS url() with image extension filter
115+
for (const [, url] of stripped.matchAll(/url\(["']?([^"')]+)["']?\)/g)) {
116+
if (url && !url.startsWith('data:') && IMAGE_EXTENSIONS.test(url))
117+
images.add(url.trim())
118+
}
119+
120+
return Array.from(images)
121+
}
122+
69123
export function detectFeatures(code: string): SlidevDetectedFeatures {
70124
return {
71125
katex: !!code.match(/\$.*?\$/) || !!code.match(/\$\$/),
@@ -104,6 +158,8 @@ export function parseSlide(raw: string, options: SlidevParserOptions = {}): Omit
104158
if (frontmatter.level)
105159
level = frontmatter.level || 1
106160

161+
const images = extractImages(content, frontmatter)
162+
107163
return {
108164
raw,
109165
title,
@@ -116,6 +172,7 @@ export function parseSlide(raw: string, options: SlidevParserOptions = {}): Omit
116172
frontmatterDoc: matterResult.doc,
117173
frontmatterRaw: matterResult.raw,
118174
note,
175+
images,
119176
}
120177
}
121178

packages/slidev/node/setups/indexHtml.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,33 @@ function toAttrValue(unsafe: unknown) {
1616
return JSON.stringify(escapeHtml(String(unsafe)))
1717
}
1818

19+
function collectPreloadImages(data: Omit<ResolvedSlidevOptions, 'utils'>['data'], base?: string): ResolvableLink[] {
20+
const config = data.config
21+
if (config.preloadImages === false)
22+
return []
23+
24+
const seen = new Set<string>()
25+
const links: ResolvableLink[] = []
26+
const basePrefix = base ? base.replace(/\/$/, '') : ''
27+
28+
for (const slide of data.slides) {
29+
const images = slide.images || slide.source?.images
30+
if (!images?.length)
31+
continue
32+
for (const url of images) {
33+
if (seen.has(url))
34+
continue
35+
seen.add(url)
36+
const href = url.startsWith('http') || url.startsWith('//')
37+
? url
38+
: `${basePrefix}${url.startsWith('/') ? url : `/${url}`}`
39+
links.push({ rel: 'preload', as: 'image', href })
40+
}
41+
}
42+
43+
return links
44+
}
45+
1946
export default async function setupIndexHtml({ mode, entry, clientRoot, userRoot, roots, data, base }: Omit<ResolvedSlidevOptions, 'utils'>): Promise<string> {
2047
let main = await readFile(join(clientRoot, 'index.html'), 'utf-8')
2148
let body = ''
@@ -75,6 +102,7 @@ export default async function setupIndexHtml({ mode, entry, clientRoot, userRoot
75102
link: [
76103
data.config.favicon ? { rel: 'icon', href: data.config.favicon } : null,
77104
...webFontsLink,
105+
...collectPreloadImages(data, base),
78106
].filter(x => x),
79107
meta: [
80108
{ 'http-equiv': 'Content-Type', 'content': 'text/html; charset=UTF-8' },

packages/types/src/frontmatter.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,16 @@ export interface HeadmatterConfig extends TransitionOptions {
311311
* @default 800
312312
*/
313313
magicMoveDuration?: number
314+
/**
315+
* Preload images extracted from slides for faster navigation.
316+
*
317+
* - `true` - enable with default look-ahead of 3 slides
318+
* - `false` - disable image preloading
319+
* - `{ ahead: number }` - enable with custom look-ahead window
320+
*
321+
* @default true
322+
*/
323+
preloadImages?: boolean | { ahead?: number }
314324
}
315325

316326
export interface Frontmatter extends TransitionOptions {

packages/types/src/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ export interface SlideInfoBase {
1313
note?: string
1414
title?: string
1515
level?: number
16+
/**
17+
* Image URLs extracted from the slide content (frontmatter, markdown, Vue components, CSS).
18+
* Populated at parse time to survive build-mode content stripping.
19+
*/
20+
images?: string[]
1621
}
1722

1823
export interface SourceSlideInfo extends SlideInfoBase {

packages/vscode/schema/headmatter.json

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -551,6 +551,24 @@
551551
"markdownDescription": "Duration for shiki magic move transitions in milliseconds",
552552
"default": 800
553553
},
554+
"preloadImages": {
555+
"anyOf": [
556+
{
557+
"type": "boolean"
558+
},
559+
{
560+
"type": "object",
561+
"properties": {
562+
"ahead": {
563+
"type": "number"
564+
}
565+
}
566+
}
567+
],
568+
"description": "Preload images extracted from slides for faster navigation.\n\n- `true` - enable with default look-ahead of 3 slides\n- `false` - disable image preloading\n- `{ ahead: number }` - enable with custom look-ahead window",
569+
"markdownDescription": "Preload images extracted from slides for faster navigation.\n\n- `true` - enable with default look-ahead of 3 slides\n- `false` - disable image preloading\n- `{ ahead: number }` - enable with custom look-ahead window",
570+
"default": true
571+
},
554572
"defaults": {
555573
"$ref": "#/definitions/Frontmatter",
556574
"description": "Default frontmatter options applied to all slides",

0 commit comments

Comments
 (0)