Skip to content

Commit e37a346

Browse files
antfukermanx
andauthoredSep 27, 2024··
feat: support snapshoting overview (#1843)
Co-authored-by: _Kerman <kermanx@qq.com>
1 parent bb490ff commit e37a346

File tree

19 files changed

+305
-9
lines changed

19 files changed

+305
-9
lines changed
 

Diff for: ‎demo/starter/slides.md

+2
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ drawings:
2020
transition: slide-left
2121
# enable MDC Syntax: https://sli.dev/features/mdc
2222
mdc: true
23+
# take snapshot for each slide in the overview
24+
overviewSnapshots: true
2325
---
2426

2527
# Welcome to Slidev

Diff for: ‎docs/custom/index.md

+2
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ record: dev
6464
contextMenu: true
6565
# enable wake lock, can be boolean, 'dev' or 'build'
6666
wakeLock: true
67+
# take snapshot for each slide in the overview
68+
overviewSnapshots: false
6769

6870
# force color schema for the slides, can be 'auto', 'light', or 'dark'
6971
colorSchema: auto

Diff for: ‎packages/client/constants.ts

+1
Original file line numberDiff line numberDiff line change
@@ -81,4 +81,5 @@ export const HEADMATTER_FIELDS = [
8181
'mdc',
8282
'contextMenu',
8383
'wakeLock',
84+
'overviewSnapshots',
8485
]

Diff for: ‎packages/client/internals/QuickOverview.vue

+3-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { currentOverviewPage, overviewRowCount } from '../logic/overview'
66
import { createFixedClicks } from '../composables/useClicks'
77
import { CLICKS_MAX } from '../constants'
88
import { useNav } from '../composables/useNav'
9-
import { pathPrefix } from '../env'
9+
import { configs, pathPrefix } from '../env'
1010
import SlideContainer from './SlideContainer.vue'
1111
import SlideWrapper from './SlideWrapper.vue'
1212
import DrawingPreview from './DrawingPreview.vue'
@@ -128,6 +128,8 @@ watchEffect(() => {
128128
>
129129
<SlideContainer
130130
:key="route.no"
131+
:no="route.no"
132+
:use-snapshot="configs.overviewSnapshots"
131133
:width="cardWidth"
132134
class="pointer-events-none"
133135
>

Diff for: ‎packages/client/internals/SlideContainer.vue

+31-2
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
<script setup lang="ts">
22
import { provideLocal, useElementSize, useStyleTag } from '@vueuse/core'
3-
import { computed, ref } from 'vue'
3+
import { computed, onMounted, ref } from 'vue'
44
import { injectionSlideElement, injectionSlideScale } from '../constants'
55
import { slideAspect, slideHeight, slideWidth } from '../env'
66
import { useNav } from '../composables/useNav'
77
import { slideScale } from '../state'
8+
import { snapshotManager } from '../logic/snapshot'
89
910
const props = defineProps({
1011
width: {
@@ -17,6 +18,14 @@ const props = defineProps({
1718
type: Boolean,
1819
default: false,
1920
},
21+
no: {
22+
type: Number,
23+
required: false,
24+
},
25+
useSnapshot: {
26+
type: Boolean,
27+
default: false,
28+
},
2029
})
2130
2231
const { isPrintMode } = useNav()
@@ -54,15 +63,35 @@ if (props.isMain)
5463
5564
provideLocal(injectionSlideScale, scale)
5665
provideLocal(injectionSlideElement, slideElement)
66+
67+
const snapshot = computed(() => {
68+
if (!props.useSnapshot || props.no == null)
69+
return undefined
70+
return snapshotManager.getSnapshot(props.no)
71+
})
72+
73+
onMounted(() => {
74+
if (container.value && props.useSnapshot && props.no != null) {
75+
snapshotManager.captureSnapshot(props.no, container.value)
76+
}
77+
})
5778
</script>
5879

5980
<template>
60-
<div :id="isMain ? 'slide-container' : undefined" ref="container" class="slidev-slide-container" :style="containerStyle">
81+
<div v-if="!snapshot" :id="isMain ? 'slide-container' : undefined" ref="container" class="slidev-slide-container" :style="containerStyle">
6182
<div :id="isMain ? 'slide-content' : undefined" ref="slideElement" class="slidev-slide-content" :style="contentStyle">
6283
<slot />
6384
</div>
6485
<slot name="controls" />
6586
</div>
87+
<!-- Image preview -->
88+
<template v-else>
89+
<img
90+
:src="snapshot"
91+
class="w-full object-cover"
92+
:style="containerStyle"
93+
>
94+
</template>
6695
</template>
6796

6897
<style scoped lang="postcss">

Diff for: ‎packages/client/logic/snapshot.ts

+86
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { snapshotState } from '../state/snapshot'
2+
import { getSlide } from './slides'
3+
4+
export class SlideSnapshotManager {
5+
private _capturePromises = new Map<number, Promise<void>>()
6+
7+
getSnapshot(slideNo: number) {
8+
const data = snapshotState.state[slideNo]
9+
if (!data) {
10+
return
11+
}
12+
const slide = getSlide(slideNo)
13+
if (!slide) {
14+
return
15+
}
16+
if (data?.revision === slide?.meta.slide.revision) {
17+
return data.image
18+
}
19+
}
20+
21+
async captureSnapshot(slideNo: number, el: HTMLElement, delay = 1000) {
22+
if (!__DEV__)
23+
return
24+
if (this.getSnapshot(slideNo)) {
25+
return
26+
}
27+
if (this._capturePromises.has(slideNo)) {
28+
await this._capturePromises.get(slideNo)
29+
}
30+
const promise = this._captureSnapshot(slideNo, el, delay)
31+
.finally(() => {
32+
this._capturePromises.delete(slideNo)
33+
})
34+
this._capturePromises.set(slideNo, promise)
35+
await promise
36+
}
37+
38+
private async _captureSnapshot(slideNo: number, el: HTMLElement, delay: number) {
39+
if (!__DEV__)
40+
return
41+
const slide = getSlide(slideNo)
42+
if (!slide)
43+
return
44+
45+
const revision = slide.meta.slide.revision
46+
47+
// Retry until the slide is loaded
48+
let retries = 100
49+
while (retries-- > 0) {
50+
if (!el.querySelector('.slidev-slide-loading'))
51+
break
52+
await new Promise(r => setTimeout(r, 100))
53+
}
54+
55+
// Artificial delay for the content to be loaded
56+
await new Promise(r => setTimeout(r, delay))
57+
58+
// Capture the snapshot
59+
const toImage = await import('html-to-image')
60+
try {
61+
const dataUrl = await toImage.toPng(el, {
62+
width: el.offsetWidth,
63+
height: el.offsetHeight,
64+
skipFonts: true,
65+
cacheBust: true,
66+
pixelRatio: 1.5,
67+
})
68+
if (revision !== slide.meta.slide.revision) {
69+
// eslint-disable-next-line no-console
70+
console.info('[Slidev] Slide', slideNo, 'changed, discarding the snapshot')
71+
return
72+
}
73+
snapshotState.patch(slideNo, {
74+
revision,
75+
image: dataUrl,
76+
})
77+
// eslint-disable-next-line no-console
78+
console.info('[Slidev] Snapshot captured for slide', slideNo)
79+
}
80+
catch (e) {
81+
console.error('[Slidev] Failed to capture snapshot for slide', slideNo, e)
82+
}
83+
}
84+
}
85+
86+
export const snapshotManager = new SlideSnapshotManager()

Diff for: ‎packages/client/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
"file-saver": "catalog:",
4848
"floating-vue": "catalog:",
4949
"fuse.js": "catalog:",
50+
"html-to-image": "catalog:",
5051
"katex": "catalog:",
5152
"lz-string": "catalog:",
5253
"mermaid": "catalog:",

Diff for: ‎packages/client/state/snapshot.ts

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import serverSnapshotState from 'server-reactive:snapshots?diff'
2+
import { createSyncState } from './syncState'
3+
4+
export type SnapshotState = Record<number, {
5+
revision: string
6+
image: string
7+
}>
8+
9+
export const snapshotState = createSyncState<SnapshotState>(
10+
serverSnapshotState,
11+
serverSnapshotState,
12+
true,
13+
)

Diff for: ‎packages/parser/src/config.ts

+1
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export function getDefaultConfig(): SlidevConfig {
3939
transition: null,
4040
editor: true,
4141
contextMenu: null,
42+
overviewSnapshots: false,
4243
wakeLock: true,
4344
remote: false,
4445
mdc: false,

Diff for: ‎packages/parser/src/core.ts

+11
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ export function parseSlide(raw: string, options: SlidevParserOptions = {}): Omit
8080
let note: string | undefined
8181
const frontmatter = matterResult.data || {}
8282
let content = matterResult.content.trim()
83+
const revision = hash(raw.trim())
8384

8485
const comments = Array.from(content.matchAll(/<!--([\s\S]*?)-->/g))
8586
if (comments.length) {
@@ -107,6 +108,7 @@ export function parseSlide(raw: string, options: SlidevParserOptions = {}): Omit
107108
raw,
108109
title,
109110
level,
111+
revision,
110112
content,
111113
frontmatter,
112114
frontmatterStyle: matterResult.type,
@@ -296,5 +298,14 @@ function scanMonacoReferencedMods(md: string) {
296298
}
297299
}
298300

301+
function hash(str: string) {
302+
let hash = 0
303+
for (let i = 0; i < str.length; i++) {
304+
hash = ((hash << 5) - hash) + str.charCodeAt(i)
305+
hash |= 0
306+
}
307+
return hash.toString(36).slice(0, 12)
308+
}
309+
299310
export * from './utils'
300311
export * from './config'

Diff for: ‎packages/parser/src/fs.ts

+1
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ export async function load(userRoot: string, filepath: string, loadedSource: Rec
105105
slides.push({
106106
frontmatter: { ...slide.frontmatter, ...frontmatterOverride },
107107
content: slide.content,
108+
revision: slide.revision,
108109
frontmatterRaw: slide.frontmatterRaw,
109110
note: slide.note,
110111
title: slide.title,

Diff for: ‎packages/slidev/node/integrations/snapshots.ts

+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { dirname, join, resolve } from 'node:path'
2+
import fs from 'fs-extra'
3+
import type { ResolvedSlidevOptions } from '@slidev/types'
4+
5+
function resolveSnapshotsDir(options: ResolvedSlidevOptions): string {
6+
return resolve(dirname(options.entry), '.slidev/snapshots')
7+
}
8+
9+
export async function loadSnapshots(options: ResolvedSlidevOptions) {
10+
const dir = resolveSnapshotsDir(options)
11+
const file = join(dir, 'snapshots.json')
12+
if (!dir || !fs.existsSync(file))
13+
return {}
14+
15+
return JSON.parse(await fs.readFile(file, 'utf8'))
16+
}
17+
18+
export async function writeSnapshots(options: ResolvedSlidevOptions, data: Record<string, any>) {
19+
const dir = resolveSnapshotsDir(options)
20+
if (!dir)
21+
return
22+
23+
await fs.ensureDir(dir)
24+
// TODO: write as each image file
25+
await fs.writeFile(join(dir, 'snapshots.json'), JSON.stringify(data, null, 2), 'utf-8')
26+
}

Diff for: ‎packages/slidev/node/vite/serverRef.ts

+7-6
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
import type { ResolvedSlidevOptions, SlidevPluginOptions } from '@slidev/types'
22
import ServerRef from 'vite-plugin-vue-server-ref'
33
import { loadDrawings, writeDrawings } from '../integrations/drawings'
4+
import { loadSnapshots, writeSnapshots } from '../integrations/snapshots'
45

56
export async function createServerRefPlugin(
67
options: ResolvedSlidevOptions,
78
pluginOptions: SlidevPluginOptions,
89
) {
9-
const drawingData = await loadDrawings(options)
10-
1110
return ServerRef({
1211
debug: false, // process.env.NODE_ENV === 'development',
1312
state: {
@@ -16,15 +15,17 @@ export async function createServerRefPlugin(
1615
page: 0,
1716
clicks: 0,
1817
},
19-
drawings: drawingData,
18+
drawings: await loadDrawings(options),
19+
snapshots: await loadSnapshots(options),
2020
...pluginOptions.serverRef?.state,
2121
},
2222
onChanged(key, data, patch, timestamp) {
2323
pluginOptions.serverRef?.onChanged?.(key, data, patch, timestamp)
24-
if (!options.data.config.drawings.persist)
25-
return
26-
if (key === 'drawings')
24+
if (options.data.config.drawings.persist && key === 'drawings')
2725
writeDrawings(options, patch ?? data)
26+
27+
if (key === 'snapshots')
28+
writeSnapshots(options, data)
2829
},
2930
})
3031
}

Diff for: ‎packages/types/src/frontmatter.ts

+7
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,13 @@ export interface HeadmatterConfig extends TransitionOptions {
211211
* @default ''
212212
*/
213213
exportFilename?: string | null
214+
/**
215+
* Use image snapshot for quick overview
216+
*
217+
* @experimental
218+
* @default false
219+
*/
220+
overviewSnapshots?: boolean
214221
/**
215222
* Enable Monaco
216223
*

Diff for: ‎packages/types/src/types.ts

+1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { SlidevConfig } from './config'
66
export type FrontmatterStyle = 'frontmatter' | 'yaml'
77

88
export interface SlideInfoBase {
9+
revision: string
910
frontmatter: Record<string, any>
1011
content: string
1112
frontmatterRaw?: string

Diff for: ‎packages/vscode/schema/headmatter.json

+6
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,12 @@
411411
"markdownDescription": "Force the filename used when exporting the presentation.\nThe extension, e.g. .pdf, gets automatically added.",
412412
"default": ""
413413
},
414+
"overviewSnapshots": {
415+
"type": "boolean",
416+
"description": "Use image snapshot for quick overview",
417+
"markdownDescription": "Use image snapshot for quick overview",
418+
"default": false
419+
},
414420
"monaco": {
415421
"anyOf": [
416422
{

Diff for: ‎pnpm-lock.yaml

+11
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Diff for: ‎pnpm-workspace.yaml

+1
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ catalog:
124124
unplugin-icons: ^0.19.2
125125
unplugin-vue-components: ^0.27.4
126126
unplugin-vue-markdown: ^0.26.2
127+
html-to-image: ^1.11.11
127128
untun: ^0.1.3
128129
uqr: ^0.1.2
129130
vite: ^5.4.2

Diff for: ‎test/__snapshots__/parser.test.ts.snap

+94
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)
Please sign in to comment.