Skip to content

Commit 0079b79

Browse files
authored
feat: browser exporter (#1972)
1 parent 27e9e74 commit 0079b79

40 files changed

+877
-171
lines changed

docs/custom/index.md

+2
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ keywords: keyword1,keyword2
2727

2828
# enable presenter mode, can be boolean, 'dev' or 'build'
2929
presenter: true
30+
# enable browser exporter, can be boolean, 'dev' or 'build'
31+
browserExporter: dev
3032
# enabled pdf downloading in SPA build, can also be a custom url
3133
download: false
3234
# filename of the export file

docs/guide/exporting.md

+13-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,19 @@ Usually the slides are displayed in a web browser, but you can also export them
88

99
However, interactive features in your slides may not be available in the exported files. You can build and host your slides as a web application to keep the interactivity. See [Building and Hosting](./hosting) for more information.
1010

11-
## Preparation
11+
## The Exporting UI <Badge> Recommended </Badge> {#ui}
12+
13+
> Available since v0.50.0-beta.11
14+
15+
Slidev provides a UI for exporting your slides. You can access it by clicking the "Export" button in "More options" menu in the [navigation bar](./ui#navigation-bar), or go to `http://localhost:<port>/export` directly.
16+
17+
In the UI, you can export the slides as PDF, or capture the slides as images and download them as a PPTX or zip file.
18+
19+
Note that browsers other than **modern Chromium-based browsers** may not work well with the exporting UI. If you encounter any issues, please try use the CLI instead.
20+
21+
> The following content of this page is for the CLI only.
22+
23+
## The CLI {#cli}
1224

1325
Exporting to PDF, PPTX, or PNG relies on [Playwright](https://playwright.dev) for rendering the slides. Therefore [`playwright-chromium`](https://npmjs.com/package/playwright-chromium) is required to be installed in your project:
1426

docs/guide/ui.md

+7-1
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ In Play mode, move your mouse to the bottom left corner of the page, you can see
2828
| - | <carbon-edit class="inline-icon-btn"/> | Toggle <LinkInline link="features/side-editor" /> |
2929
| - | <carbon-download class="inline-icon-btn"/> | Download PDF. See <LinkInline link="features/build-with-pdf" /> |
3030
| - | <carbon-information class="inline-icon-btn"/> | Show information about the slides |
31-
| - | <carbon-settings-adjust class="inline-icon-btn"/> | Show settings menu |
31+
| - | <carbon-settings-adjust class="inline-icon-btn"/> | More options |
3232
| <kbd>g</kbd> | - | Show goto... |
3333

3434
> You can [configure the shortcuts](../custom/config-shortcuts).
@@ -72,6 +72,12 @@ See:
7272

7373
<LinkCard link="features/recording"/>
7474

75+
## Exporting UI {#exporting}
76+
77+
See:
78+
79+
<LinkCard link="guide/exporting#ui"/>
80+
7581
## Global Layers {#global-layers}
7682

7783
You can add any custom UI below or above your slides for the whole presentation or per-slide:

packages/client/composables/useClicks.ts

+6-5
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { ClicksContext, NormalizedRangeClickValue, NormalizedSingleClickValue, RawAtValue, RawSingleAtValue, SlideRoute } from '@slidev/types'
2-
import type { Ref } from 'vue'
2+
import type { MaybeRefOrGetter, Ref } from 'vue'
33
import { clamp, sum } from '@antfu/utils'
4-
import { computed, onMounted, onUnmounted, ref, shallowReactive } from 'vue'
4+
import { computed, isReadonly, onMounted, onUnmounted, ref, shallowReactive, toValue } from 'vue'
55

66
export function normalizeSingleAtValue(at: RawSingleAtValue): NormalizedSingleClickValue {
77
if (at === false || at === 'false')
@@ -59,7 +59,8 @@ export function createClicksContextBase(
5959
// Convert maxMap to reactive
6060
maxMap = shallowReactive(maxMap)
6161
// Make sure the query is not greater than the total
62-
context.current = current.value
62+
if (!isReadonly(current))
63+
context.current = current.value
6364
})
6465
onUnmounted(() => {
6566
isMounted.value = false
@@ -160,11 +161,11 @@ export function createClicksContextBase(
160161

161162
export function createFixedClicks(
162163
route?: SlideRoute | undefined,
163-
currentInit = 0,
164+
currentInit: MaybeRefOrGetter<number> = 0,
164165
): ClicksContext {
165166
const clicksStart = route?.meta.slide?.frontmatter.clicksStart ?? 0
166167
return createClicksContextBase(
167-
ref(Math.max(currentInit, clicksStart)),
168+
computed(() => Math.max(toValue(currentInit), clicksStart)),
168169
clicksStart,
169170
route?.meta?.clicks,
170171
)

packages/client/composables/useDragElements.ts

+7-1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { injectionCurrentPage, injectionFrontmatter, injectionRenderContext, inj
77
import { makeId } from '../logic/utils'
88
import { activeDragElement } from '../state'
99
import { directiveInject } from '../utils'
10+
import { useNav } from './useNav'
1011
import { useSlideBounds } from './useSlideBounds'
1112
import { useDynamicSlideInfo } from './useSlideInfo'
1213

@@ -127,7 +128,8 @@ export function useDragElement(directive: DirectiveBinding | null, posRaw?: stri
127128
const scale = inject(injectionSlideScale) ?? ref(1)
128129
const zoom = inject(injectionSlideZoom) ?? ref(1)
129130
const { left: slideLeft, top: slideTop, stop: stopWatchBounds } = useSlideBounds(inject(injectionSlideElement) ?? ref())
130-
const enabled = ['slide', 'presenter'].includes(renderContext.value)
131+
const { isPrintMode } = useNav()
132+
const enabled = ['slide', 'presenter'].includes(renderContext.value) && !isPrintMode.value
131133

132134
let dataSource: DragElementDataSource = directive ? 'directive' : 'prop'
133135
let dragId: string = makeId()
@@ -266,10 +268,14 @@ export function useDragElement(directive: DirectiveBinding | null, posRaw?: stri
266268
state.stopDragging()
267269
},
268270
startDragging(): void {
271+
if (!enabled)
272+
return
269273
updateBounds()
270274
activeDragElement.value = state
271275
},
272276
stopDragging(): void {
277+
if (!enabled)
278+
return
273279
if (activeDragElement.value === state)
274280
activeDragElement.value = null
275281
},

packages/client/composables/useNav.ts

+17-15
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@ import type { ComputedRef, Ref, TransitionGroupProps, WritableComputedRef } from
33
import type { RouteLocationNormalized, Router } from 'vue-router'
44
import { slides } from '#slidev/slides'
55
import { clamp } from '@antfu/utils'
6+
import { parseRangeString } from '@slidev/parser/utils'
67
import { createSharedComposable } from '@vueuse/core'
7-
import { logicOr } from '@vueuse/math'
88
import { computed, ref, watch } from 'vue'
9-
import { useRouter } from 'vue-router'
9+
import { useRoute, useRouter } from 'vue-router'
1010
import { CLICKS_MAX } from '../constants'
1111
import { configs } from '../env'
1212
import { skipTransition } from '../logic/hmr'
@@ -71,7 +71,7 @@ export interface SlidevContextNavState {
7171
router: Router
7272
currentRoute: ComputedRef<RouteLocationNormalized>
7373
isPrintMode: ComputedRef<boolean>
74-
isPrintWithClicks: ComputedRef<boolean>
74+
isPrintWithClicks: Ref<boolean>
7575
isEmbedded: ComputedRef<boolean>
7676
isPlaying: ComputedRef<boolean>
7777
isPresenter: ComputedRef<boolean>
@@ -83,6 +83,7 @@ export interface SlidevContextNavState {
8383
clicksContext: ComputedRef<ClicksContext>
8484
queryClicksRaw: Ref<string>
8585
queryClicks: WritableComputedRef<number>
86+
printRange: Ref<number[]>
8687
getPrimaryClicks: (route: SlideRoute) => ClicksContext
8788
}
8889

@@ -113,7 +114,7 @@ export function useNavBase(
113114
const hasNext = computed(() => currentSlideNo.value < slides.value.length || clicks.value < clicksTotal.value)
114115
const hasPrev = computed(() => currentSlideNo.value > 1 || clicks.value > 0)
115116

116-
const currentTransition = computed(() => getCurrentTransition(navDirection.value, currentSlideRoute.value, prevRoute.value))
117+
const currentTransition = computed(() => isPrint.value ? undefined : getCurrentTransition(navDirection.value, currentSlideRoute.value, prevRoute.value))
117118

118119
watch(currentSlideRoute, (next, prev) => {
119120
navDirection.value = next.no - prev.no
@@ -191,7 +192,7 @@ export function useNavBase(
191192
clicks = clamp(clicks, clicksStart, meta?.__clicksContext?.total ?? CLICKS_MAX)
192193
if (force || pageChanged || clicksChanged) {
193194
await router?.push({
194-
path: getSlidePath(no, isPresenter.value),
195+
path: getSlidePath(no, isPresenter.value, router.currentRoute.value.name === 'export'),
195196
query: {
196197
...router.currentRoute.value.query,
197198
clicks: clicks === 0 ? undefined : clicks.toString(),
@@ -272,24 +273,24 @@ export function useFixedNav(
272273

273274
const useNavState = createSharedComposable((): SlidevContextNavState => {
274275
const router = useRouter()
276+
const currentRoute = useRoute()
275277

276-
const currentRoute = computed(() => router.currentRoute.value)
277278
const query = computed(() => {
278279
// eslint-disable-next-line ts/no-unused-expressions
279280
router.currentRoute.value.query
280281
return new URLSearchParams(location.search)
281282
})
282-
const isPrintMode = computed(() => query.value.has('print'))
283-
const isPrintWithClicks = computed(() => query.value.get('print') === 'clicks')
283+
const isPrintMode = computed(() => query.value.has('print') || currentRoute.name === 'export')
284+
const isPrintWithClicks = ref(query.value.get('print') === 'clicks')
284285
const isEmbedded = computed(() => query.value.has('embedded'))
285-
const isPlaying = computed(() => currentRoute.value.name === 'play')
286-
const isPresenter = computed(() => currentRoute.value.name === 'presenter')
287-
const isNotesViewer = computed(() => currentRoute.value.name === 'notes')
286+
const isPlaying = computed(() => currentRoute.name === 'play')
287+
const isPresenter = computed(() => currentRoute.name === 'presenter')
288+
const isNotesViewer = computed(() => currentRoute.name === 'notes')
288289
const isPresenterAvailable = computed(() => !isPresenter.value && (!configs.remote || query.value.get('password') === configs.remote))
289-
const hasPrimarySlide = logicOr(isPlaying, isPresenter)
290-
291-
const currentSlideNo = computed(() => hasPrimarySlide.value ? getSlide(currentRoute.value.params.no as string)?.no ?? 1 : 1)
290+
const hasPrimarySlide = computed(() => !!currentRoute.params.no)
291+
const currentSlideNo = computed(() => hasPrimarySlide.value ? getSlide(currentRoute.params.no as string)?.no ?? 1 : 1)
292292
const currentSlideRoute = computed(() => slides.value[currentSlideNo.value - 1])
293+
const printRange = ref(parseRangeString(slides.value.length, currentRoute.query.range as string | undefined))
293294

294295
const queryClicksRaw = useRouteQuery<string>('clicks', '0')
295296

@@ -342,7 +343,7 @@ const useNavState = createSharedComposable((): SlidevContextNavState => {
342343

343344
return {
344345
router,
345-
currentRoute,
346+
currentRoute: computed(() => currentRoute),
346347
isPrintMode,
347348
isPrintWithClicks,
348349
isEmbedded,
@@ -356,6 +357,7 @@ const useNavState = createSharedComposable((): SlidevContextNavState => {
356357
clicksContext,
357358
queryClicksRaw,
358359
queryClicks,
360+
printRange,
359361
getPrimaryClicks,
360362
}
361363
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { useStyleTag } from '@vueuse/core'
2+
import { computed } from 'vue'
3+
import { slideHeight, slideWidth } from '../env'
4+
import { useNav } from './useNav'
5+
6+
export function usePrintStyles() {
7+
const { isPrintMode } = useNav()
8+
9+
useStyleTag(computed(() => isPrintMode.value
10+
? `
11+
@page {
12+
size: ${slideWidth.value}px ${slideHeight.value}px;
13+
margin: 0px;
14+
}
15+
16+
* {
17+
transition: none !important;
18+
transition-duration: 0s !important;
19+
}`
20+
: ''))
21+
}
22+
23+
// Monaco uses `<style media="screen" class="monaco-colors">` to apply colors, which will be ignored in print mode.
24+
export function patchMonacoColors() {
25+
document.querySelectorAll<HTMLStyleElement>('style.monaco-colors').forEach((el) => {
26+
el.media = ''
27+
})
28+
}

packages/client/constants.ts

+1
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export const HEADMATTER_FIELDS = [
5656
'author',
5757
'keywords',
5858
'presenter',
59+
'browserExporter',
5960
'download',
6061
'exportFilename',
6162
'export',
+90
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
<script setup lang="ts">
2+
import { useVModel } from '@vueuse/core'
3+
import { skipExportPdfTip } from '../state'
4+
import Modal from './Modal.vue'
5+
6+
const props = defineProps({
7+
modelValue: {
8+
default: false,
9+
},
10+
})
11+
12+
const emit = defineEmits(['update:modelValue', 'print'])
13+
const value = useVModel(props, 'modelValue', emit)
14+
15+
function print() {
16+
value.value = false
17+
emit('print')
18+
}
19+
</script>
20+
21+
<template>
22+
<Modal v-model="value" class="px-6 py-4 flex flex-col gap-2">
23+
<div class="flex gap-2 text-xl">
24+
<div class="i-carbon:information my-auto" /> Tips
25+
</div>
26+
<div>
27+
Slidev will open your browser's built-in print dialog to export the slides as PDF. <br>
28+
In the print dialog, please:
29+
<ul class="list-disc my-4 pl-4">
30+
<li>
31+
Choose "Save as PDF" as the Destination.
32+
<span class="op-70 text-xs"> (Not "Microsoft Print to PDF") </span>
33+
</li>
34+
<li> Choose "Default" as the Margin. </li>
35+
<li> Toggle on "Print backgrounds". </li>
36+
</ul>
37+
<div class="mb-2 op-70 text-sm">
38+
If you're encountering problems, please try
39+
<a href="https://sli.dev/builtin/cli#export"> the CLI </a>
40+
or
41+
<a href="https://github.com/slidevjs/slidev/issues/new"> open an issue</a>.
42+
</div>
43+
<div class="form-check op-70">
44+
<input
45+
v-model="skipExportPdfTip"
46+
name="record-camera"
47+
type="checkbox"
48+
>
49+
<label for="record-camera" @click="skipExportPdfTip = !skipExportPdfTip">Don't show this dialog next time.</label>
50+
</div>
51+
</div>
52+
<div class="flex my-1">
53+
<button class="cancel" @click="value = false">
54+
Cancel
55+
</button>
56+
<div class="flex-auto" />
57+
<button @click="print">
58+
Start
59+
</button>
60+
</div>
61+
</Modal>
62+
</template>
63+
64+
<style scoped>
65+
button {
66+
@apply bg-blue-400 text-white px-4 py-1 rounded border-b-2 border-blue-600;
67+
@apply hover:(bg-blue-500 border-blue-700);
68+
}
69+
70+
button.cancel {
71+
@apply bg-gray-400 bg-opacity-50 text-white px-4 py-1 rounded border-b-2 border-main;
72+
@apply hover:(bg-opacity-75 border-opacity-75);
73+
}
74+
75+
a {
76+
@apply border-current border-b border-dashed hover:text-primary hover:border-solid;
77+
}
78+
79+
.form-check {
80+
@apply leading-5;
81+
82+
* {
83+
@apply my-auto align-middle;
84+
}
85+
86+
label {
87+
@apply ml-1 text-sm select-none;
88+
}
89+
}
90+
</style>
+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<script setup lang="ts">
2+
defineProps<{
3+
disabled?: boolean
4+
}>()
5+
6+
const value = defineModel<boolean>('modelValue', {
7+
type: Boolean,
8+
})
9+
</script>
10+
11+
<template>
12+
<div border="~ main rounded" flex="~ gap-2 items-center" relative h-5 w-5 p0.5 hover:bg-active p1>
13+
<div i-ri-check-line :class="value ? '' : 'op0'" />
14+
<input v-model="value" type="checkbox" absolute inset-0 z-10 opacity-0.1 :disabled="disabled">
15+
</div>
16+
</template>

0 commit comments

Comments
 (0)