Skip to content

Commit b5f63bc

Browse files
committed
feat: use screen capture for slides snapshot
1 parent dbb78fa commit b5f63bc

File tree

13 files changed

+120
-104
lines changed

13 files changed

+120
-104
lines changed

packages/client/constants.ts

Lines changed: 0 additions & 1 deletion
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

Lines changed: 24 additions & 4 deletions
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

Lines changed: 15 additions & 16 deletions
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 bg-cyan:10 rounded op50 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

Lines changed: 3 additions & 3 deletions
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

Lines changed: 1 addition & 0 deletions
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

Lines changed: 76 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,26 @@
1+
import type { SlidevContextNavFull } from '../composables/useNav'
2+
import type { ScreenshotSession } from './screenshot'
3+
import { sleep } from '@antfu/utils'
4+
import { useLocalStorage } from '@vueuse/core'
5+
import { slideHeight, slideWidth } from '../env'
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+
const delay = useLocalStorage('slidev-export-capture-delay', 400, { listenToStorageChanges: false })
17+
418
export class SlideSnapshotManager {
5-
private _capturePromises = new Map<number, Promise<void>>()
19+
private _screenshotSession: ScreenshotSession | null = null
620

7-
getSnapshot(slideNo: number) {
8-
const data = snapshotState.state[slideNo]
21+
getSnapshot(slideNo: number, isDark: boolean) {
22+
const id = slideNo + (isDark ? '-dark' : '-light')
23+
const data = snapshotState.state[id]
924
if (!data) {
1025
return
1126
}
@@ -18,67 +33,76 @@ export class SlideSnapshotManager {
1833
}
1934
}
2035

21-
async captureSnapshot(slideNo: number, el: HTMLElement, delay = 1000) {
36+
private async saveSnapshot(slideNo: number, dataUrl: string, isDark: boolean) {
2237
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
38+
return false
4139
const slide = getSlide(slideNo)
4240
if (!slide)
43-
return
41+
return false
4442

43+
const id = slideNo + (isDark ? '-dark' : '-light')
4544
const revision = slide.meta.slide.revision
45+
snapshotState.patch(id, {
46+
revision,
47+
image: dataUrl,
48+
})
49+
}
4650

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

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

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

packages/client/package.json

Lines changed: 0 additions & 1 deletion
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/state/snapshot.ts

Lines changed: 1 addition & 1 deletion
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/parser/src/config.ts

Lines changed: 0 additions & 1 deletion
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,

packages/types/src/frontmatter.ts

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -221,13 +221,6 @@ export interface HeadmatterConfig extends TransitionOptions {
221221
* @default ''
222222
*/
223223
exportFilename?: string | null
224-
/**
225-
* Use image snapshot for quick overview
226-
*
227-
* @experimental
228-
* @default false
229-
*/
230-
overviewSnapshots?: boolean
231224
/**
232225
* Enable Monaco
233226
*

packages/vscode/schema/headmatter.json

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -431,12 +431,6 @@
431431
"markdownDescription": "Force the filename used when exporting the presentation.\nThe extension, e.g. .pdf, gets automatically added.",
432432
"default": ""
433433
},
434-
"overviewSnapshots": {
435-
"type": "boolean",
436-
"description": "Use image snapshot for quick overview",
437-
"markdownDescription": "Use image snapshot for quick overview",
438-
"default": false
439-
},
440434
"monaco": {
441435
"anyOf": [
442436
{

0 commit comments

Comments
 (0)