Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: use screen capture for slides snapshot #1988

Merged
merged 5 commits into from
Dec 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading