Skip to content

Commit

Permalink
feat: browser exporter (#1972)
Browse files Browse the repository at this point in the history
  • Loading branch information
KermanX authored Dec 17, 2024
1 parent 27e9e74 commit 0079b79
Show file tree
Hide file tree
Showing 40 changed files with 877 additions and 171 deletions.
2 changes: 2 additions & 0 deletions docs/custom/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ keywords: keyword1,keyword2

# enable presenter mode, can be boolean, 'dev' or 'build'
presenter: true
# enable browser exporter, can be boolean, 'dev' or 'build'
browserExporter: dev
# enabled pdf downloading in SPA build, can also be a custom url
download: false
# filename of the export file
Expand Down
14 changes: 13 additions & 1 deletion docs/guide/exporting.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,19 @@ Usually the slides are displayed in a web browser, but you can also export them

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.

## Preparation
## The Exporting UI <Badge> Recommended </Badge> {#ui}

> Available since v0.50.0-beta.11
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.

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.

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.

> The following content of this page is for the CLI only.
## The CLI {#cli}

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:

Expand Down
8 changes: 7 additions & 1 deletion docs/guide/ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ In Play mode, move your mouse to the bottom left corner of the page, you can see
| - | <carbon-edit class="inline-icon-btn"/> | Toggle <LinkInline link="features/side-editor" /> |
| - | <carbon-download class="inline-icon-btn"/> | Download PDF. See <LinkInline link="features/build-with-pdf" /> |
| - | <carbon-information class="inline-icon-btn"/> | Show information about the slides |
| - | <carbon-settings-adjust class="inline-icon-btn"/> | Show settings menu |
| - | <carbon-settings-adjust class="inline-icon-btn"/> | More options |
| <kbd>g</kbd> | - | Show goto... |

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

<LinkCard link="features/recording"/>

## Exporting UI {#exporting}

See:

<LinkCard link="guide/exporting#ui"/>

## Global Layers {#global-layers}

You can add any custom UI below or above your slides for the whole presentation or per-slide:
Expand Down
11 changes: 6 additions & 5 deletions packages/client/composables/useClicks.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { ClicksContext, NormalizedRangeClickValue, NormalizedSingleClickValue, RawAtValue, RawSingleAtValue, SlideRoute } from '@slidev/types'
import type { Ref } from 'vue'
import type { MaybeRefOrGetter, Ref } from 'vue'
import { clamp, sum } from '@antfu/utils'
import { computed, onMounted, onUnmounted, ref, shallowReactive } from 'vue'
import { computed, isReadonly, onMounted, onUnmounted, ref, shallowReactive, toValue } from 'vue'

export function normalizeSingleAtValue(at: RawSingleAtValue): NormalizedSingleClickValue {
if (at === false || at === 'false')
Expand Down Expand Up @@ -59,7 +59,8 @@ export function createClicksContextBase(
// Convert maxMap to reactive
maxMap = shallowReactive(maxMap)
// Make sure the query is not greater than the total
context.current = current.value
if (!isReadonly(current))
context.current = current.value
})
onUnmounted(() => {
isMounted.value = false
Expand Down Expand Up @@ -160,11 +161,11 @@ export function createClicksContextBase(

export function createFixedClicks(
route?: SlideRoute | undefined,
currentInit = 0,
currentInit: MaybeRefOrGetter<number> = 0,
): ClicksContext {
const clicksStart = route?.meta.slide?.frontmatter.clicksStart ?? 0
return createClicksContextBase(
ref(Math.max(currentInit, clicksStart)),
computed(() => Math.max(toValue(currentInit), clicksStart)),
clicksStart,
route?.meta?.clicks,
)
Expand Down
8 changes: 7 additions & 1 deletion packages/client/composables/useDragElements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { injectionCurrentPage, injectionFrontmatter, injectionRenderContext, inj
import { makeId } from '../logic/utils'
import { activeDragElement } from '../state'
import { directiveInject } from '../utils'
import { useNav } from './useNav'
import { useSlideBounds } from './useSlideBounds'
import { useDynamicSlideInfo } from './useSlideInfo'

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

let dataSource: DragElementDataSource = directive ? 'directive' : 'prop'
let dragId: string = makeId()
Expand Down Expand Up @@ -266,10 +268,14 @@ export function useDragElement(directive: DirectiveBinding | null, posRaw?: stri
state.stopDragging()
},
startDragging(): void {
if (!enabled)
return
updateBounds()
activeDragElement.value = state
},
stopDragging(): void {
if (!enabled)
return
if (activeDragElement.value === state)
activeDragElement.value = null
},
Expand Down
32 changes: 17 additions & 15 deletions packages/client/composables/useNav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ import type { ComputedRef, Ref, TransitionGroupProps, WritableComputedRef } from
import type { RouteLocationNormalized, Router } from 'vue-router'
import { slides } from '#slidev/slides'
import { clamp } from '@antfu/utils'
import { parseRangeString } from '@slidev/parser/utils'
import { createSharedComposable } from '@vueuse/core'
import { logicOr } from '@vueuse/math'
import { computed, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { CLICKS_MAX } from '../constants'
import { configs } from '../env'
import { skipTransition } from '../logic/hmr'
Expand Down Expand Up @@ -71,7 +71,7 @@ export interface SlidevContextNavState {
router: Router
currentRoute: ComputedRef<RouteLocationNormalized>
isPrintMode: ComputedRef<boolean>
isPrintWithClicks: ComputedRef<boolean>
isPrintWithClicks: Ref<boolean>
isEmbedded: ComputedRef<boolean>
isPlaying: ComputedRef<boolean>
isPresenter: ComputedRef<boolean>
Expand All @@ -83,6 +83,7 @@ export interface SlidevContextNavState {
clicksContext: ComputedRef<ClicksContext>
queryClicksRaw: Ref<string>
queryClicks: WritableComputedRef<number>
printRange: Ref<number[]>
getPrimaryClicks: (route: SlideRoute) => ClicksContext
}

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

const currentTransition = computed(() => getCurrentTransition(navDirection.value, currentSlideRoute.value, prevRoute.value))
const currentTransition = computed(() => isPrint.value ? undefined : getCurrentTransition(navDirection.value, currentSlideRoute.value, prevRoute.value))

watch(currentSlideRoute, (next, prev) => {
navDirection.value = next.no - prev.no
Expand Down Expand Up @@ -191,7 +192,7 @@ export function useNavBase(
clicks = clamp(clicks, clicksStart, meta?.__clicksContext?.total ?? CLICKS_MAX)
if (force || pageChanged || clicksChanged) {
await router?.push({
path: getSlidePath(no, isPresenter.value),
path: getSlidePath(no, isPresenter.value, router.currentRoute.value.name === 'export'),
query: {
...router.currentRoute.value.query,
clicks: clicks === 0 ? undefined : clicks.toString(),
Expand Down Expand Up @@ -272,24 +273,24 @@ export function useFixedNav(

const useNavState = createSharedComposable((): SlidevContextNavState => {
const router = useRouter()
const currentRoute = useRoute()

const currentRoute = computed(() => router.currentRoute.value)
const query = computed(() => {
// eslint-disable-next-line ts/no-unused-expressions
router.currentRoute.value.query
return new URLSearchParams(location.search)
})
const isPrintMode = computed(() => query.value.has('print'))
const isPrintWithClicks = computed(() => query.value.get('print') === 'clicks')
const isPrintMode = computed(() => query.value.has('print') || currentRoute.name === 'export')
const isPrintWithClicks = ref(query.value.get('print') === 'clicks')
const isEmbedded = computed(() => query.value.has('embedded'))
const isPlaying = computed(() => currentRoute.value.name === 'play')
const isPresenter = computed(() => currentRoute.value.name === 'presenter')
const isNotesViewer = computed(() => currentRoute.value.name === 'notes')
const isPlaying = computed(() => currentRoute.name === 'play')
const isPresenter = computed(() => currentRoute.name === 'presenter')
const isNotesViewer = computed(() => currentRoute.name === 'notes')
const isPresenterAvailable = computed(() => !isPresenter.value && (!configs.remote || query.value.get('password') === configs.remote))
const hasPrimarySlide = logicOr(isPlaying, isPresenter)

const currentSlideNo = computed(() => hasPrimarySlide.value ? getSlide(currentRoute.value.params.no as string)?.no ?? 1 : 1)
const hasPrimarySlide = computed(() => !!currentRoute.params.no)
const currentSlideNo = computed(() => hasPrimarySlide.value ? getSlide(currentRoute.params.no as string)?.no ?? 1 : 1)
const currentSlideRoute = computed(() => slides.value[currentSlideNo.value - 1])
const printRange = ref(parseRangeString(slides.value.length, currentRoute.query.range as string | undefined))

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

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

return {
router,
currentRoute,
currentRoute: computed(() => currentRoute),
isPrintMode,
isPrintWithClicks,
isEmbedded,
Expand All @@ -356,6 +357,7 @@ const useNavState = createSharedComposable((): SlidevContextNavState => {
clicksContext,
queryClicksRaw,
queryClicks,
printRange,
getPrimaryClicks,
}
})
Expand Down
28 changes: 28 additions & 0 deletions packages/client/composables/usePrintStyles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { useStyleTag } from '@vueuse/core'
import { computed } from 'vue'
import { slideHeight, slideWidth } from '../env'
import { useNav } from './useNav'

export function usePrintStyles() {
const { isPrintMode } = useNav()

useStyleTag(computed(() => isPrintMode.value
? `
@page {
size: ${slideWidth.value}px ${slideHeight.value}px;
margin: 0px;
}
* {
transition: none !important;
transition-duration: 0s !important;
}`
: ''))
}

// Monaco uses `<style media="screen" class="monaco-colors">` to apply colors, which will be ignored in print mode.
export function patchMonacoColors() {
document.querySelectorAll<HTMLStyleElement>('style.monaco-colors').forEach((el) => {
el.media = ''
})
}
1 change: 1 addition & 0 deletions packages/client/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export const HEADMATTER_FIELDS = [
'author',
'keywords',
'presenter',
'browserExporter',
'download',
'exportFilename',
'export',
Expand Down
90 changes: 90 additions & 0 deletions packages/client/internals/ExportPdfTip.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<script setup lang="ts">
import { useVModel } from '@vueuse/core'
import { skipExportPdfTip } from '../state'
import Modal from './Modal.vue'
const props = defineProps({
modelValue: {
default: false,
},
})
const emit = defineEmits(['update:modelValue', 'print'])
const value = useVModel(props, 'modelValue', emit)
function print() {
value.value = false
emit('print')
}
</script>

<template>
<Modal v-model="value" class="px-6 py-4 flex flex-col gap-2">
<div class="flex gap-2 text-xl">
<div class="i-carbon:information my-auto" /> Tips
</div>
<div>
Slidev will open your browser's built-in print dialog to export the slides as PDF. <br>
In the print dialog, please:
<ul class="list-disc my-4 pl-4">
<li>
Choose "Save as PDF" as the Destination.
<span class="op-70 text-xs"> (Not "Microsoft Print to PDF") </span>
</li>
<li> Choose "Default" as the Margin. </li>
<li> Toggle on "Print backgrounds". </li>
</ul>
<div class="mb-2 op-70 text-sm">
If you're encountering problems, please try
<a href="https://sli.dev/builtin/cli#export"> the CLI </a>
or
<a href="https://github.com/slidevjs/slidev/issues/new"> open an issue</a>.
</div>
<div class="form-check op-70">
<input
v-model="skipExportPdfTip"
name="record-camera"
type="checkbox"
>
<label for="record-camera" @click="skipExportPdfTip = !skipExportPdfTip">Don't show this dialog next time.</label>
</div>
</div>
<div class="flex my-1">
<button class="cancel" @click="value = false">
Cancel
</button>
<div class="flex-auto" />
<button @click="print">
Start
</button>
</div>
</Modal>
</template>

<style scoped>
button {
@apply bg-blue-400 text-white px-4 py-1 rounded border-b-2 border-blue-600;
@apply hover:(bg-blue-500 border-blue-700);
}
button.cancel {
@apply bg-gray-400 bg-opacity-50 text-white px-4 py-1 rounded border-b-2 border-main;
@apply hover:(bg-opacity-75 border-opacity-75);
}
a {
@apply border-current border-b border-dashed hover:text-primary hover:border-solid;
}
.form-check {
@apply leading-5;
* {
@apply my-auto align-middle;
}
label {
@apply ml-1 text-sm select-none;
}
}
</style>
16 changes: 16 additions & 0 deletions packages/client/internals/FormCheckbox.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<script setup lang="ts">
defineProps<{
disabled?: boolean
}>()
const value = defineModel<boolean>('modelValue', {
type: Boolean,
})
</script>

<template>
<div border="~ main rounded" flex="~ gap-2 items-center" relative h-5 w-5 p0.5 hover:bg-active p1>
<div i-ri-check-line :class="value ? '' : 'op0'" />
<input v-model="value" type="checkbox" absolute inset-0 z-10 opacity-0.1 :disabled="disabled">
</div>
</template>
Loading

0 comments on commit 0079b79

Please sign in to comment.