Skip to content

Commit de44e2d

Browse files
antfukermanx
andauthored
feat: use screen capture for slides snapshot (#1988)
Co-authored-by: _Kerman <[email protected]>
1 parent dbb78fa commit de44e2d

16 files changed

+125
-113
lines changed

packages/client/constants.ts

-1
Original file line numberDiff line numberDiff line change
@@ -83,5 +83,4 @@ export const HEADMATTER_FIELDS = [
8383
'mdc',
8484
'contextMenu',
8585
'wakeLock',
86-
'overviewSnapshots',
8786
]

packages/client/internals/QuickOverview.vue

+24-4
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,18 @@ import { computed, ref, watchEffect } from 'vue'
44
import { createFixedClicks } from '../composables/useClicks'
55
import { useNav } from '../composables/useNav'
66
import { CLICKS_MAX } from '../constants'
7-
import { configs, pathPrefix } from '../env'
7+
import { pathPrefix } from '../env'
88
import { currentOverviewPage, overviewRowCount } from '../logic/overview'
9+
import { isScreenshotSupported } from '../logic/screenshot'
10+
import { snapshotManager } from '../logic/snapshot'
911
import { breakpoints, showOverview, windowSize } from '../state'
1012
import DrawingPreview from './DrawingPreview.vue'
1113
import IconButton from './IconButton.vue'
1214
import SlideContainer from './SlideContainer.vue'
1315
import SlideWrapper from './SlideWrapper.vue'
1416
15-
const { currentSlideNo, go: goSlide, slides } = useNav()
17+
const nav = useNav()
18+
const { currentSlideNo, go: goSlide, slides } = nav
1619
1720
function close() {
1821
showOverview.value = false
@@ -48,6 +51,12 @@ const rowCount = computed(() => {
4851
4952
const keyboardBuffer = ref<string>('')
5053
54+
async function captureSlidesOverview() {
55+
showOverview.value = false
56+
await snapshotManager.startCapturing(nav)
57+
showOverview.value = true
58+
}
59+
5160
useEventListener('keypress', (e) => {
5261
if (!showOverview.value) {
5362
keyboardBuffer.value = ''
@@ -129,7 +138,7 @@ watchEffect(() => {
129138
<SlideContainer
130139
:key="route.no"
131140
:no="route.no"
132-
:use-snapshot="configs.overviewSnapshots"
141+
:use-snapshot="true"
133142
:width="cardWidth"
134143
class="pointer-events-none"
135144
>
@@ -157,7 +166,10 @@ watchEffect(() => {
157166
</div>
158167
</div>
159168
</Transition>
160-
<div v-if="showOverview" class="fixed top-4 right-4 z-modal text-gray-400 flex flex-col items-center gap-2">
169+
<div
170+
v-show="showOverview"
171+
class="fixed top-4 right-4 z-modal text-gray-400 flex flex-col items-center gap-2"
172+
>
161173
<IconButton title="Close" class="text-2xl" @click="close">
162174
<div class="i-carbon:close" />
163175
</IconButton>
@@ -172,5 +184,13 @@ watchEffect(() => {
172184
>
173185
<div class="i-carbon:list-boxes" />
174186
</IconButton>
187+
<IconButton
188+
v-if="__DEV__ && isScreenshotSupported"
189+
title="Capture slides as images"
190+
class="text-2xl"
191+
@click="captureSlidesOverview"
192+
>
193+
<div class="i-carbon:drop-photo" />
194+
</IconButton>
175195
</div>
176196
</template>

packages/client/internals/SlideContainer.vue

+15-16
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
<script setup lang="ts">
22
import { provideLocal, useElementSize, useStyleTag } from '@vueuse/core'
3-
import { computed, onMounted, ref } from 'vue'
3+
import { computed, ref } from 'vue'
44
import { useNav } from '../composables/useNav'
55
import { injectionSlideElement, injectionSlideScale } from '../constants'
66
import { slideAspect, slideHeight, slideWidth } from '../env'
7+
import { isDark } from '../logic/dark'
78
import { snapshotManager } from '../logic/snapshot'
89
import { slideScale } from '../state'
910
@@ -65,15 +66,9 @@ provideLocal(injectionSlideScale, scale)
6566
provideLocal(injectionSlideElement, slideElement)
6667
6768
const snapshot = computed(() => {
68-
if (!props.useSnapshot || props.no == null)
69+
if (props.no == null || !props.useSnapshot)
6970
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-
}
71+
return snapshotManager.getSnapshot(props.no, isDark.value)
7772
})
7873
</script>
7974

@@ -84,13 +79,17 @@ onMounted(() => {
8479
</div>
8580
<slot name="controls" />
8681
</div>
87-
<!-- Image preview -->
88-
<img
89-
v-else
90-
:src="snapshot"
91-
class="w-full object-cover"
92-
:style="containerStyle"
93-
>
82+
<!-- Image Snapshot -->
83+
<div v-else class="slidev-slide-container w-full h-full relative">
84+
<img
85+
:src="snapshot"
86+
class="w-full h-full object-cover"
87+
:style="containerStyle"
88+
>
89+
<div absolute bottom-1 right-1 p0.5 text-cyan:75 bg-cyan:10 rounded title="Snapshot">
90+
<div class="i-carbon-camera" />
91+
</div>
92+
</div>
9493
</template>
9594

9695
<style scoped lang="postcss">

packages/client/internals/SlidesShow.vue

+3-3
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { createFixedClicks } from '../composables/useClicks'
77
import { useNav } from '../composables/useNav'
88
import { useViewTransition } from '../composables/useViewTransition'
99
import { CLICKS_MAX } from '../constants'
10-
import { skipTransition } from '../logic/hmr'
10+
import { disableTransition, skipTransition } from '../logic/hmr'
1111
import { activeDragElement } from '../state'
1212
import DragControl from './DragControl.vue'
1313
import SlideWrapper from './SlideWrapper.vue'
@@ -76,8 +76,8 @@ function onAfterLeave() {
7676

7777
<!-- Slides -->
7878
<component
79-
:is="hasViewTransition && !isPrintMode ? 'div' : TransitionGroup"
80-
v-bind="skipTransition || isPrintMode ? {} : currentTransition"
79+
:is="(hasViewTransition && !isPrintMode && !skipTransition && !disableTransition) ? 'div' : TransitionGroup"
80+
v-bind="(skipTransition || disableTransition || isPrintMode) ? {} : currentTransition"
8181
id="slideshow"
8282
tag="div"
8383
:class="{

packages/client/logic/hmr.ts

+1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
import { ref } from 'vue'
22

33
export const skipTransition = ref(false)
4+
export const disableTransition = ref(false)

packages/client/logic/snapshot.ts

+74-52
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,25 @@
1+
import type { SlidevContextNavFull } from '../composables/useNav'
2+
import type { ScreenshotSession } from './screenshot'
3+
import { sleep } from '@antfu/utils'
4+
import { slideHeight, slideWidth } from '../env'
5+
import { captureDelay } from '../state'
16
import { snapshotState } from '../state/snapshot'
7+
import { isDark } from './dark'
8+
import { disableTransition } from './hmr'
9+
import { startScreenshotSession } from './screenshot'
210
import { getSlide } from './slides'
311

12+
const chromeVersion = window.navigator.userAgent.match(/Chrome\/(\d+)/)?.[1]
13+
export const isScreenshotSupported = chromeVersion ? Number(chromeVersion) >= 94 : false
14+
15+
const initialWait = 100
16+
417
export class SlideSnapshotManager {
5-
private _capturePromises = new Map<number, Promise<void>>()
18+
private _screenshotSession: ScreenshotSession | null = null
619

7-
getSnapshot(slideNo: number) {
8-
const data = snapshotState.state[slideNo]
20+
getSnapshot(slideNo: number, isDark: boolean) {
21+
const id = slideNo + (isDark ? '-dark' : '-light')
22+
const data = snapshotState.state[id]
923
if (!data) {
1024
return
1125
}
@@ -18,67 +32,75 @@ export class SlideSnapshotManager {
1832
}
1933
}
2034

21-
async captureSnapshot(slideNo: number, el: HTMLElement, delay = 1000) {
35+
private async saveSnapshot(slideNo: number, dataUrl: string, isDark: boolean) {
2236
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
37+
return false
4138
const slide = getSlide(slideNo)
4239
if (!slide)
43-
return
40+
return false
4441

42+
const id = slideNo + (isDark ? '-dark' : '-light')
4543
const revision = slide.meta.slide.revision
44+
snapshotState.patch(id, {
45+
revision,
46+
image: dataUrl,
47+
})
48+
}
4649

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-
}
50+
async startCapturing(nav: SlidevContextNavFull) {
51+
if (!__DEV__)
52+
return false
5453

55-
// Artificial delay for the content to be loaded
56-
await new Promise(r => setTimeout(r, delay))
54+
// TODO: show a dialog to confirm
55+
56+
if (this._screenshotSession) {
57+
this._screenshotSession.dispose()
58+
this._screenshotSession = null
59+
}
5760

58-
// Capture the snapshot
59-
const toImage = await import('html-to-image')
6061
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
62+
this._screenshotSession = await startScreenshotSession(
63+
slideWidth.value,
64+
slideHeight.value,
65+
)
66+
67+
disableTransition.value = true
68+
nav.go(1, 0, true)
69+
70+
await sleep(initialWait + captureDelay.value)
71+
while (true) {
72+
if (!this._screenshotSession) {
73+
break
74+
}
75+
this.saveSnapshot(
76+
nav.currentSlideNo.value,
77+
this._screenshotSession.screenshot(document.getElementById('slide-content')!),
78+
isDark.value,
79+
)
80+
if (nav.hasNext.value) {
81+
await sleep(captureDelay.value)
82+
nav.nextSlide(true)
83+
await sleep(captureDelay.value)
84+
}
85+
else {
86+
break
87+
}
7288
}
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)
89+
90+
// TODO: show a message when done
91+
92+
return true
7993
}
8094
catch (e) {
81-
console.error('[Slidev] Failed to capture snapshot for slide', slideNo, e)
95+
console.error(e)
96+
return false
97+
}
98+
finally {
99+
disableTransition.value = false
100+
if (this._screenshotSession) {
101+
this._screenshotSession.dispose()
102+
this._screenshotSession = null
103+
}
82104
}
83105
}
84106
}

packages/client/package.json

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

packages/client/pages/export.vue

+6-8
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,7 @@ import type { ScreenshotSession } from '../logic/screenshot'
33
import { sleep } from '@antfu/utils'
44
import { parseRangeString } from '@slidev/parser/utils'
55
import { useHead } from '@unhead/vue'
6-
import { provideLocal, useElementSize, useLocalStorage, useStyleTag, watchDebounced } from '@vueuse/core'
7-
6+
import { provideLocal, useElementSize, useStyleTag, watchDebounced } from '@vueuse/core'
87
import { computed, ref, useTemplateRef, watch } from 'vue'
98
import { useRouter } from 'vue-router'
109
import { useDarkMode } from '../composables/useDarkMode'
@@ -17,7 +16,7 @@ import FormCheckbox from '../internals/FormCheckbox.vue'
1716
import FormItem from '../internals/FormItem.vue'
1817
import PrintSlide from '../internals/PrintSlide.vue'
1918
import { isScreenshotSupported, startScreenshotSession } from '../logic/screenshot'
20-
import { skipExportPdfTip } from '../state'
19+
import { captureDelay, skipExportPdfTip } from '../state'
2120
import Play from './play.vue'
2221
2322
const { slides, isPrintWithClicks, hasNext, go, next, currentSlideNo, clicks, printRange } = useNav()
@@ -29,7 +28,6 @@ const scale = computed(() => containerWidth.value / slideWidth.value)
2928
const contentMarginBottom = computed(() => `${contentHeight.value * (scale.value - 1)}px`)
3029
const rangesRaw = ref('')
3130
const initialWait = ref(1000)
32-
const delay = useLocalStorage('slidev-export-capture-delay', 400, { listenToStorageChanges: false })
3331
type ScreenshotResult = { slideIndex: number, clickIndex: number, dataUrl: string }[]
3432
const screenshotSession = ref<ScreenshotSession | null>(null)
3533
const capturedImages = ref<ScreenshotResult | null>(null)
@@ -70,7 +68,7 @@ async function capturePngs() {
7068
7169
go(1, 0, true)
7270
73-
await sleep(initialWait.value + delay.value)
71+
await sleep(initialWait.value + captureDelay.value)
7472
while (true) {
7573
if (!screenshotSession.value) {
7674
break
@@ -81,9 +79,9 @@ async function capturePngs() {
8179
dataUrl: screenshotSession.value.screenshot(document.getElementById('slide-content')!),
8280
})
8381
if (hasNext.value) {
84-
await sleep(delay.value)
82+
await sleep(captureDelay.value)
8583
next()
86-
await sleep(delay.value)
84+
await sleep(captureDelay.value)
8785
}
8886
else {
8987
break
@@ -273,7 +271,7 @@ if (import.meta.hot) {
273271
Pre-capture Slides as Images
274272
</button>
275273
<FormItem title="Delay" description="Delay between capturing each slide in milliseconds.<br>Increase this value if slides are captured incompletely. <br>(Not related to PDF export)">
276-
<input v-model="delay" type="number" step="50" min="50">
274+
<input v-model="captureDelay" type="number" step="50" min="50">
277275
</FormItem>
278276
</div>
279277
</div>

packages/client/state/snapshot.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import serverSnapshotState from 'server-reactive:snapshots?diff'
22
import { createSyncState } from './syncState'
33

4-
export type SnapshotState = Record<number, {
4+
export type SnapshotState = Record<string, {
55
revision: string
66
image: string
77
}>

packages/client/state/storage.ts

+1
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export const currentMic = useLocalStorage<string>('slidev-mic', 'default', { lis
2727
export const slideScale = useLocalStorage<number>('slidev-scale', 0)
2828
export const wakeLockEnabled = useLocalStorage('slidev-wake-lock', true)
2929
export const skipExportPdfTip = useLocalStorage('slidev-skip-export-pdf-tip', false)
30+
export const captureDelay = useLocalStorage('slidev-export-capture-delay', 400, { listenToStorageChanges: false })
3031

3132
export const showPresenterCursor = useLocalStorage('slidev-presenter-cursor', true, { listenToStorageChanges: false })
3233
export const showEditor = useLocalStorage('slidev-show-editor', false, { listenToStorageChanges: false })

packages/parser/src/config.ts

-1
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,6 @@ export function getDefaultConfig(): SlidevConfig {
4141
transition: null,
4242
editor: true,
4343
contextMenu: null,
44-
overviewSnapshots: false,
4544
wakeLock: true,
4645
remote: false,
4746
mdc: false,

0 commit comments

Comments
 (0)