Skip to content

Commit

Permalink
feat: use screen capture for slides snapshot (#1988)
Browse files Browse the repository at this point in the history
Co-authored-by: _Kerman <[email protected]>
  • Loading branch information
antfu and KermanX authored Dec 27, 2024
1 parent dbb78fa commit de44e2d
Show file tree
Hide file tree
Showing 16 changed files with 125 additions and 113 deletions.
1 change: 0 additions & 1 deletion packages/client/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,5 +83,4 @@ export const HEADMATTER_FIELDS = [
'mdc',
'contextMenu',
'wakeLock',
'overviewSnapshots',
]
28 changes: 24 additions & 4 deletions packages/client/internals/QuickOverview.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,18 @@ import { computed, ref, watchEffect } from 'vue'
import { createFixedClicks } from '../composables/useClicks'
import { useNav } from '../composables/useNav'
import { CLICKS_MAX } from '../constants'
import { configs, pathPrefix } from '../env'
import { pathPrefix } from '../env'
import { currentOverviewPage, overviewRowCount } from '../logic/overview'
import { isScreenshotSupported } from '../logic/screenshot'
import { snapshotManager } from '../logic/snapshot'
import { breakpoints, showOverview, windowSize } from '../state'
import DrawingPreview from './DrawingPreview.vue'
import IconButton from './IconButton.vue'
import SlideContainer from './SlideContainer.vue'
import SlideWrapper from './SlideWrapper.vue'
const { currentSlideNo, go: goSlide, slides } = useNav()
const nav = useNav()
const { currentSlideNo, go: goSlide, slides } = nav
function close() {
showOverview.value = false
Expand Down Expand Up @@ -48,6 +51,12 @@ const rowCount = computed(() => {
const keyboardBuffer = ref<string>('')
async function captureSlidesOverview() {
showOverview.value = false
await snapshotManager.startCapturing(nav)
showOverview.value = true
}
useEventListener('keypress', (e) => {
if (!showOverview.value) {
keyboardBuffer.value = ''
Expand Down Expand Up @@ -129,7 +138,7 @@ watchEffect(() => {
<SlideContainer
:key="route.no"
:no="route.no"
:use-snapshot="configs.overviewSnapshots"
:use-snapshot="true"
:width="cardWidth"
class="pointer-events-none"
>
Expand Down Expand Up @@ -157,7 +166,10 @@ watchEffect(() => {
</div>
</div>
</Transition>
<div v-if="showOverview" class="fixed top-4 right-4 z-modal text-gray-400 flex flex-col items-center gap-2">
<div
v-show="showOverview"
class="fixed top-4 right-4 z-modal text-gray-400 flex flex-col items-center gap-2"
>
<IconButton title="Close" class="text-2xl" @click="close">
<div class="i-carbon:close" />
</IconButton>
Expand All @@ -172,5 +184,13 @@ watchEffect(() => {
>
<div class="i-carbon:list-boxes" />
</IconButton>
<IconButton
v-if="__DEV__ && isScreenshotSupported"
title="Capture slides as images"
class="text-2xl"
@click="captureSlidesOverview"
>
<div class="i-carbon:drop-photo" />
</IconButton>
</div>
</template>
31 changes: 15 additions & 16 deletions packages/client/internals/SlideContainer.vue
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
<script setup lang="ts">
import { provideLocal, useElementSize, useStyleTag } from '@vueuse/core'
import { computed, onMounted, ref } from 'vue'
import { computed, ref } from 'vue'
import { useNav } from '../composables/useNav'
import { injectionSlideElement, injectionSlideScale } from '../constants'
import { slideAspect, slideHeight, slideWidth } from '../env'
import { isDark } from '../logic/dark'
import { snapshotManager } from '../logic/snapshot'
import { slideScale } from '../state'
Expand Down Expand Up @@ -65,15 +66,9 @@ provideLocal(injectionSlideScale, scale)
provideLocal(injectionSlideElement, slideElement)
const snapshot = computed(() => {
if (!props.useSnapshot || props.no == null)
if (props.no == null || !props.useSnapshot)
return undefined
return snapshotManager.getSnapshot(props.no)
})
onMounted(() => {
if (container.value && props.useSnapshot && props.no != null) {
snapshotManager.captureSnapshot(props.no, container.value)
}
return snapshotManager.getSnapshot(props.no, isDark.value)
})
</script>

Expand All @@ -84,13 +79,17 @@ onMounted(() => {
</div>
<slot name="controls" />
</div>
<!-- Image preview -->
<img
v-else
:src="snapshot"
class="w-full object-cover"
:style="containerStyle"
>
<!-- Image Snapshot -->
<div v-else class="slidev-slide-container w-full h-full relative">
<img
:src="snapshot"
class="w-full h-full object-cover"
:style="containerStyle"
>
<div absolute bottom-1 right-1 p0.5 text-cyan:75 bg-cyan:10 rounded title="Snapshot">
<div class="i-carbon-camera" />
</div>
</div>
</template>

<style scoped lang="postcss">
Expand Down
6 changes: 3 additions & 3 deletions packages/client/internals/SlidesShow.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { createFixedClicks } from '../composables/useClicks'
import { useNav } from '../composables/useNav'
import { useViewTransition } from '../composables/useViewTransition'
import { CLICKS_MAX } from '../constants'
import { skipTransition } from '../logic/hmr'
import { disableTransition, skipTransition } from '../logic/hmr'
import { activeDragElement } from '../state'
import DragControl from './DragControl.vue'
import SlideWrapper from './SlideWrapper.vue'
Expand Down Expand Up @@ -76,8 +76,8 @@ function onAfterLeave() {

<!-- Slides -->
<component
:is="hasViewTransition && !isPrintMode ? 'div' : TransitionGroup"
v-bind="skipTransition || isPrintMode ? {} : currentTransition"
:is="(hasViewTransition && !isPrintMode && !skipTransition && !disableTransition) ? 'div' : TransitionGroup"
v-bind="(skipTransition || disableTransition || isPrintMode) ? {} : currentTransition"
id="slideshow"
tag="div"
:class="{
Expand Down
1 change: 1 addition & 0 deletions packages/client/logic/hmr.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ref } from 'vue'

export const skipTransition = ref(false)
export const disableTransition = ref(false)
126 changes: 74 additions & 52 deletions packages/client/logic/snapshot.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,25 @@
import type { SlidevContextNavFull } from '../composables/useNav'
import type { ScreenshotSession } from './screenshot'
import { sleep } from '@antfu/utils'
import { slideHeight, slideWidth } from '../env'
import { captureDelay } from '../state'
import { snapshotState } from '../state/snapshot'
import { isDark } from './dark'
import { disableTransition } from './hmr'
import { startScreenshotSession } from './screenshot'
import { getSlide } from './slides'

const chromeVersion = window.navigator.userAgent.match(/Chrome\/(\d+)/)?.[1]
export const isScreenshotSupported = chromeVersion ? Number(chromeVersion) >= 94 : false

const initialWait = 100

export class SlideSnapshotManager {
private _capturePromises = new Map<number, Promise<void>>()
private _screenshotSession: ScreenshotSession | null = null

getSnapshot(slideNo: number) {
const data = snapshotState.state[slideNo]
getSnapshot(slideNo: number, isDark: boolean) {
const id = slideNo + (isDark ? '-dark' : '-light')
const data = snapshotState.state[id]
if (!data) {
return
}
Expand All @@ -18,67 +32,75 @@ export class SlideSnapshotManager {
}
}

async captureSnapshot(slideNo: number, el: HTMLElement, delay = 1000) {
private async saveSnapshot(slideNo: number, dataUrl: string, isDark: boolean) {
if (!__DEV__)
return
if (this.getSnapshot(slideNo)) {
return
}
if (this._capturePromises.has(slideNo)) {
await this._capturePromises.get(slideNo)
}
const promise = this._captureSnapshot(slideNo, el, delay)
.finally(() => {
this._capturePromises.delete(slideNo)
})
this._capturePromises.set(slideNo, promise)
await promise
}

private async _captureSnapshot(slideNo: number, el: HTMLElement, delay: number) {
if (!__DEV__)
return
return false
const slide = getSlide(slideNo)
if (!slide)
return
return false

const id = slideNo + (isDark ? '-dark' : '-light')
const revision = slide.meta.slide.revision
snapshotState.patch(id, {
revision,
image: dataUrl,
})
}

// Retry until the slide is loaded
let retries = 100
while (retries-- > 0) {
if (!el.querySelector('.slidev-slide-loading'))
break
await new Promise(r => setTimeout(r, 100))
}
async startCapturing(nav: SlidevContextNavFull) {
if (!__DEV__)
return false

// Artificial delay for the content to be loaded
await new Promise(r => setTimeout(r, delay))
// TODO: show a dialog to confirm

if (this._screenshotSession) {
this._screenshotSession.dispose()
this._screenshotSession = null
}

// Capture the snapshot
const toImage = await import('html-to-image')
try {
const dataUrl = await toImage.toPng(el, {
width: el.offsetWidth,
height: el.offsetHeight,
skipFonts: true,
cacheBust: true,
pixelRatio: 1.5,
})
if (revision !== slide.meta.slide.revision) {
// eslint-disable-next-line no-console
console.info('[Slidev] Slide', slideNo, 'changed, discarding the snapshot')
return
this._screenshotSession = await startScreenshotSession(
slideWidth.value,
slideHeight.value,
)

disableTransition.value = true
nav.go(1, 0, true)

await sleep(initialWait + captureDelay.value)
while (true) {
if (!this._screenshotSession) {
break
}
this.saveSnapshot(
nav.currentSlideNo.value,
this._screenshotSession.screenshot(document.getElementById('slide-content')!),
isDark.value,
)
if (nav.hasNext.value) {
await sleep(captureDelay.value)
nav.nextSlide(true)
await sleep(captureDelay.value)
}
else {
break
}
}
snapshotState.patch(slideNo, {
revision,
image: dataUrl,
})
// eslint-disable-next-line no-console
console.info('[Slidev] Snapshot captured for slide', slideNo)

// TODO: show a message when done

return true
}
catch (e) {
console.error('[Slidev] Failed to capture snapshot for slide', slideNo, e)
console.error(e)
return false
}
finally {
disableTransition.value = false
if (this._screenshotSession) {
this._screenshotSession.dispose()
this._screenshotSession = null
}
}
}
}
Expand Down
1 change: 0 additions & 1 deletion packages/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,6 @@
"file-saver": "catalog:",
"floating-vue": "catalog:",
"fuse.js": "catalog:",
"html-to-image": "catalog:",
"katex": "catalog:",
"lz-string": "catalog:",
"mermaid": "catalog:",
Expand Down
14 changes: 6 additions & 8 deletions packages/client/pages/export.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@ import type { ScreenshotSession } from '../logic/screenshot'
import { sleep } from '@antfu/utils'
import { parseRangeString } from '@slidev/parser/utils'
import { useHead } from '@unhead/vue'
import { provideLocal, useElementSize, useLocalStorage, useStyleTag, watchDebounced } from '@vueuse/core'
import { provideLocal, useElementSize, useStyleTag, watchDebounced } from '@vueuse/core'
import { computed, ref, useTemplateRef, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useDarkMode } from '../composables/useDarkMode'
Expand All @@ -17,7 +16,7 @@ import FormCheckbox from '../internals/FormCheckbox.vue'
import FormItem from '../internals/FormItem.vue'
import PrintSlide from '../internals/PrintSlide.vue'
import { isScreenshotSupported, startScreenshotSession } from '../logic/screenshot'
import { skipExportPdfTip } from '../state'
import { captureDelay, skipExportPdfTip } from '../state'
import Play from './play.vue'
const { slides, isPrintWithClicks, hasNext, go, next, currentSlideNo, clicks, printRange } = useNav()
Expand All @@ -29,7 +28,6 @@ const scale = computed(() => containerWidth.value / slideWidth.value)
const contentMarginBottom = computed(() => `${contentHeight.value * (scale.value - 1)}px`)
const rangesRaw = ref('')
const initialWait = ref(1000)
const delay = useLocalStorage('slidev-export-capture-delay', 400, { listenToStorageChanges: false })
type ScreenshotResult = { slideIndex: number, clickIndex: number, dataUrl: string }[]
const screenshotSession = ref<ScreenshotSession | null>(null)
const capturedImages = ref<ScreenshotResult | null>(null)
Expand Down Expand Up @@ -70,7 +68,7 @@ async function capturePngs() {
go(1, 0, true)
await sleep(initialWait.value + delay.value)
await sleep(initialWait.value + captureDelay.value)
while (true) {
if (!screenshotSession.value) {
break
Expand All @@ -81,9 +79,9 @@ async function capturePngs() {
dataUrl: screenshotSession.value.screenshot(document.getElementById('slide-content')!),
})
if (hasNext.value) {
await sleep(delay.value)
await sleep(captureDelay.value)
next()
await sleep(delay.value)
await sleep(captureDelay.value)
}
else {
break
Expand Down Expand Up @@ -273,7 +271,7 @@ if (import.meta.hot) {
Pre-capture Slides as Images
</button>
<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)">
<input v-model="delay" type="number" step="50" min="50">
<input v-model="captureDelay" type="number" step="50" min="50">
</FormItem>
</div>
</div>
Expand Down
2 changes: 1 addition & 1 deletion packages/client/state/snapshot.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import serverSnapshotState from 'server-reactive:snapshots?diff'
import { createSyncState } from './syncState'

export type SnapshotState = Record<number, {
export type SnapshotState = Record<string, {
revision: string
image: string
}>
Expand Down
1 change: 1 addition & 0 deletions packages/client/state/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export const currentMic = useLocalStorage<string>('slidev-mic', 'default', { lis
export const slideScale = useLocalStorage<number>('slidev-scale', 0)
export const wakeLockEnabled = useLocalStorage('slidev-wake-lock', true)
export const skipExportPdfTip = useLocalStorage('slidev-skip-export-pdf-tip', false)
export const captureDelay = useLocalStorage('slidev-export-capture-delay', 400, { listenToStorageChanges: false })

export const showPresenterCursor = useLocalStorage('slidev-presenter-cursor', true, { listenToStorageChanges: false })
export const showEditor = useLocalStorage('slidev-show-editor', false, { listenToStorageChanges: false })
Expand Down
1 change: 0 additions & 1 deletion packages/parser/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@ export function getDefaultConfig(): SlidevConfig {
transition: null,
editor: true,
contextMenu: null,
overviewSnapshots: false,
wakeLock: true,
remote: false,
mdc: false,
Expand Down
Loading

0 comments on commit de44e2d

Please sign in to comment.