Skip to content

Commit

Permalink
feat: support snapshoting overview (slidevjs#1843)
Browse files Browse the repository at this point in the history
Co-authored-by: _Kerman <[email protected]>
  • Loading branch information
antfu and kermanx authored Sep 27, 2024
1 parent bb490ff commit e37a346
Show file tree
Hide file tree
Showing 19 changed files with 305 additions and 9 deletions.
2 changes: 2 additions & 0 deletions demo/starter/slides.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ drawings:
transition: slide-left
# enable MDC Syntax: https://sli.dev/features/mdc
mdc: true
# take snapshot for each slide in the overview
overviewSnapshots: true
---

# Welcome to Slidev
Expand Down
2 changes: 2 additions & 0 deletions docs/custom/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ record: dev
contextMenu: true
# enable wake lock, can be boolean, 'dev' or 'build'
wakeLock: true
# take snapshot for each slide in the overview
overviewSnapshots: false

# force color schema for the slides, can be 'auto', 'light', or 'dark'
colorSchema: auto
Expand Down
1 change: 1 addition & 0 deletions packages/client/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,4 +81,5 @@ export const HEADMATTER_FIELDS = [
'mdc',
'contextMenu',
'wakeLock',
'overviewSnapshots',
]
4 changes: 3 additions & 1 deletion packages/client/internals/QuickOverview.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { currentOverviewPage, overviewRowCount } from '../logic/overview'
import { createFixedClicks } from '../composables/useClicks'
import { CLICKS_MAX } from '../constants'
import { useNav } from '../composables/useNav'
import { pathPrefix } from '../env'
import { configs, pathPrefix } from '../env'
import SlideContainer from './SlideContainer.vue'
import SlideWrapper from './SlideWrapper.vue'
import DrawingPreview from './DrawingPreview.vue'
Expand Down Expand Up @@ -128,6 +128,8 @@ watchEffect(() => {
>
<SlideContainer
:key="route.no"
:no="route.no"
:use-snapshot="configs.overviewSnapshots"
:width="cardWidth"
class="pointer-events-none"
>
Expand Down
33 changes: 31 additions & 2 deletions packages/client/internals/SlideContainer.vue
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
<script setup lang="ts">
import { provideLocal, useElementSize, useStyleTag } from '@vueuse/core'
import { computed, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { injectionSlideElement, injectionSlideScale } from '../constants'
import { slideAspect, slideHeight, slideWidth } from '../env'
import { useNav } from '../composables/useNav'
import { slideScale } from '../state'
import { snapshotManager } from '../logic/snapshot'
const props = defineProps({
width: {
Expand All @@ -17,6 +18,14 @@ const props = defineProps({
type: Boolean,
default: false,
},
no: {
type: Number,
required: false,
},
useSnapshot: {
type: Boolean,
default: false,
},
})
const { isPrintMode } = useNav()
Expand Down Expand Up @@ -54,15 +63,35 @@ if (props.isMain)
provideLocal(injectionSlideScale, scale)
provideLocal(injectionSlideElement, slideElement)
const snapshot = computed(() => {
if (!props.useSnapshot || props.no == null)
return undefined
return snapshotManager.getSnapshot(props.no)
})
onMounted(() => {
if (container.value && props.useSnapshot && props.no != null) {
snapshotManager.captureSnapshot(props.no, container.value)
}
})
</script>

<template>
<div :id="isMain ? 'slide-container' : undefined" ref="container" class="slidev-slide-container" :style="containerStyle">
<div v-if="!snapshot" :id="isMain ? 'slide-container' : undefined" ref="container" class="slidev-slide-container" :style="containerStyle">
<div :id="isMain ? 'slide-content' : undefined" ref="slideElement" class="slidev-slide-content" :style="contentStyle">
<slot />
</div>
<slot name="controls" />
</div>
<!-- Image preview -->
<template v-else>
<img
:src="snapshot"
class="w-full object-cover"
:style="containerStyle"
>
</template>
</template>

<style scoped lang="postcss">
Expand Down
86 changes: 86 additions & 0 deletions packages/client/logic/snapshot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { snapshotState } from '../state/snapshot'
import { getSlide } from './slides'

export class SlideSnapshotManager {
private _capturePromises = new Map<number, Promise<void>>()

getSnapshot(slideNo: number) {
const data = snapshotState.state[slideNo]
if (!data) {
return
}
const slide = getSlide(slideNo)
if (!slide) {
return
}
if (data?.revision === slide?.meta.slide.revision) {
return data.image
}
}

async captureSnapshot(slideNo: number, el: HTMLElement, delay = 1000) {
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
const slide = getSlide(slideNo)
if (!slide)
return

const revision = slide.meta.slide.revision

// 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))
}

// Artificial delay for the content to be loaded
await new Promise(r => setTimeout(r, delay))

// 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
}
snapshotState.patch(slideNo, {
revision,
image: dataUrl,
})
// eslint-disable-next-line no-console
console.info('[Slidev] Snapshot captured for slide', slideNo)
}
catch (e) {
console.error('[Slidev] Failed to capture snapshot for slide', slideNo, e)
}
}
}

export const snapshotManager = new SlideSnapshotManager()
1 change: 1 addition & 0 deletions packages/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"file-saver": "catalog:",
"floating-vue": "catalog:",
"fuse.js": "catalog:",
"html-to-image": "catalog:",
"katex": "catalog:",
"lz-string": "catalog:",
"mermaid": "catalog:",
Expand Down
13 changes: 13 additions & 0 deletions packages/client/state/snapshot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import serverSnapshotState from 'server-reactive:snapshots?diff'
import { createSyncState } from './syncState'

export type SnapshotState = Record<number, {
revision: string
image: string
}>

export const snapshotState = createSyncState<SnapshotState>(
serverSnapshotState,
serverSnapshotState,
true,
)
1 change: 1 addition & 0 deletions packages/parser/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export function getDefaultConfig(): SlidevConfig {
transition: null,
editor: true,
contextMenu: null,
overviewSnapshots: false,
wakeLock: true,
remote: false,
mdc: false,
Expand Down
11 changes: 11 additions & 0 deletions packages/parser/src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export function parseSlide(raw: string, options: SlidevParserOptions = {}): Omit
let note: string | undefined
const frontmatter = matterResult.data || {}
let content = matterResult.content.trim()
const revision = hash(raw.trim())

const comments = Array.from(content.matchAll(/<!--([\s\S]*?)-->/g))
if (comments.length) {
Expand Down Expand Up @@ -107,6 +108,7 @@ export function parseSlide(raw: string, options: SlidevParserOptions = {}): Omit
raw,
title,
level,
revision,
content,
frontmatter,
frontmatterStyle: matterResult.type,
Expand Down Expand Up @@ -296,5 +298,14 @@ function scanMonacoReferencedMods(md: string) {
}
}

function hash(str: string) {
let hash = 0
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) - hash) + str.charCodeAt(i)
hash |= 0
}
return hash.toString(36).slice(0, 12)
}

export * from './utils'
export * from './config'
1 change: 1 addition & 0 deletions packages/parser/src/fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ export async function load(userRoot: string, filepath: string, loadedSource: Rec
slides.push({
frontmatter: { ...slide.frontmatter, ...frontmatterOverride },
content: slide.content,
revision: slide.revision,
frontmatterRaw: slide.frontmatterRaw,
note: slide.note,
title: slide.title,
Expand Down
26 changes: 26 additions & 0 deletions packages/slidev/node/integrations/snapshots.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { dirname, join, resolve } from 'node:path'
import fs from 'fs-extra'
import type { ResolvedSlidevOptions } from '@slidev/types'

function resolveSnapshotsDir(options: ResolvedSlidevOptions): string {
return resolve(dirname(options.entry), '.slidev/snapshots')
}

export async function loadSnapshots(options: ResolvedSlidevOptions) {
const dir = resolveSnapshotsDir(options)
const file = join(dir, 'snapshots.json')
if (!dir || !fs.existsSync(file))
return {}

return JSON.parse(await fs.readFile(file, 'utf8'))
}

export async function writeSnapshots(options: ResolvedSlidevOptions, data: Record<string, any>) {
const dir = resolveSnapshotsDir(options)
if (!dir)
return

await fs.ensureDir(dir)
// TODO: write as each image file
await fs.writeFile(join(dir, 'snapshots.json'), JSON.stringify(data, null, 2), 'utf-8')
}
13 changes: 7 additions & 6 deletions packages/slidev/node/vite/serverRef.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import type { ResolvedSlidevOptions, SlidevPluginOptions } from '@slidev/types'
import ServerRef from 'vite-plugin-vue-server-ref'
import { loadDrawings, writeDrawings } from '../integrations/drawings'
import { loadSnapshots, writeSnapshots } from '../integrations/snapshots'

export async function createServerRefPlugin(
options: ResolvedSlidevOptions,
pluginOptions: SlidevPluginOptions,
) {
const drawingData = await loadDrawings(options)

return ServerRef({
debug: false, // process.env.NODE_ENV === 'development',
state: {
Expand All @@ -16,15 +15,17 @@ export async function createServerRefPlugin(
page: 0,
clicks: 0,
},
drawings: drawingData,
drawings: await loadDrawings(options),
snapshots: await loadSnapshots(options),
...pluginOptions.serverRef?.state,
},
onChanged(key, data, patch, timestamp) {
pluginOptions.serverRef?.onChanged?.(key, data, patch, timestamp)
if (!options.data.config.drawings.persist)
return
if (key === 'drawings')
if (options.data.config.drawings.persist && key === 'drawings')
writeDrawings(options, patch ?? data)

if (key === 'snapshots')
writeSnapshots(options, data)
},
})
}
7 changes: 7 additions & 0 deletions packages/types/src/frontmatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,13 @@ export interface HeadmatterConfig extends TransitionOptions {
* @default ''
*/
exportFilename?: string | null
/**
* Use image snapshot for quick overview
*
* @experimental
* @default false
*/
overviewSnapshots?: boolean
/**
* Enable Monaco
*
Expand Down
1 change: 1 addition & 0 deletions packages/types/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { SlidevConfig } from './config'
export type FrontmatterStyle = 'frontmatter' | 'yaml'

export interface SlideInfoBase {
revision: string
frontmatter: Record<string, any>
content: string
frontmatterRaw?: string
Expand Down
6 changes: 6 additions & 0 deletions packages/vscode/schema/headmatter.json
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,12 @@
"markdownDescription": "Force the filename used when exporting the presentation.\nThe extension, e.g. .pdf, gets automatically added.",
"default": ""
},
"overviewSnapshots": {
"type": "boolean",
"description": "Use image snapshot for quick overview",
"markdownDescription": "Use image snapshot for quick overview",
"default": false
},
"monaco": {
"anyOf": [
{
Expand Down
Loading

0 comments on commit e37a346

Please sign in to comment.