From 0079b79ae04a6ecf17a4a3b250577e1b463cc754 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 17 Dec 2024 14:35:46 +0800 Subject: [PATCH] feat: browser exporter (#1972) --- docs/custom/index.md | 2 + docs/guide/exporting.md | 14 +- docs/guide/ui.md | 8 +- packages/client/composables/useClicks.ts | 11 +- .../client/composables/useDragElements.ts | 8 +- packages/client/composables/useNav.ts | 32 +- packages/client/composables/usePrintStyles.ts | 28 ++ packages/client/constants.ts | 1 + packages/client/internals/ExportPdfTip.vue | 90 +++++ packages/client/internals/FormCheckbox.vue | 16 + packages/client/internals/FormItem.vue | 42 ++ packages/client/internals/IconButton.vue | 9 +- packages/client/internals/NavControls.vue | 10 +- packages/client/internals/PrintContainer.vue | 23 +- packages/client/internals/PrintSlide.vue | 7 +- packages/client/internals/PrintSlideClick.vue | 14 +- packages/client/internals/PrintStyle.vue | 16 - packages/client/internals/Settings.vue | 7 +- packages/client/internals/SlidesShow.vue | 10 +- packages/client/logic/screenshot.ts | 61 +++ packages/client/logic/shortcuts.ts | 71 ++-- packages/client/logic/slides.ts | 3 +- packages/client/modules/v-mark.ts | 6 + packages/client/package.json | 2 + packages/client/pages/export.vue | 369 ++++++++++++++++++ packages/client/pages/play.vue | 5 +- packages/client/pages/print.vue | 2 - packages/client/setup/root.ts | 8 +- packages/client/setup/routes.ts | 35 +- packages/client/state/index.ts | 1 + packages/client/styles/index.css | 7 +- packages/parser/src/config.ts | 2 + packages/parser/src/utils.ts | 8 +- packages/slidev/node/cli.ts | 18 +- packages/slidev/node/options.ts | 33 +- packages/types/src/frontmatter.ts | 10 + packages/vscode/schema/headmatter.json | 23 ++ pnpm-lock.yaml | 33 +- pnpm-workspace.yaml | 1 + shim.d.ts | 2 + 40 files changed, 877 insertions(+), 171 deletions(-) create mode 100644 packages/client/composables/usePrintStyles.ts create mode 100644 packages/client/internals/ExportPdfTip.vue create mode 100644 packages/client/internals/FormCheckbox.vue create mode 100644 packages/client/internals/FormItem.vue delete mode 100644 packages/client/internals/PrintStyle.vue create mode 100644 packages/client/logic/screenshot.ts create mode 100644 packages/client/pages/export.vue diff --git a/docs/custom/index.md b/docs/custom/index.md index 654f2e35f4..2e106509ed 100644 --- a/docs/custom/index.md +++ b/docs/custom/index.md @@ -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 diff --git a/docs/guide/exporting.md b/docs/guide/exporting.md index 56c8de96ab..fd7bebc9fd 100644 --- a/docs/guide/exporting.md +++ b/docs/guide/exporting.md @@ -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 Recommended {#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:/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: diff --git a/docs/guide/ui.md b/docs/guide/ui.md index b48838407b..ac81dcbb09 100644 --- a/docs/guide/ui.md +++ b/docs/guide/ui.md @@ -28,7 +28,7 @@ In Play mode, move your mouse to the bottom left corner of the page, you can see | - | | Toggle | | - | | Download PDF. See | | - | | Show information about the slides | -| - | | Show settings menu | +| - | | More options | | g | - | Show goto... | > You can [configure the shortcuts](../custom/config-shortcuts). @@ -72,6 +72,12 @@ See: +## Exporting UI {#exporting} + +See: + + + ## Global Layers {#global-layers} You can add any custom UI below or above your slides for the whole presentation or per-slide: diff --git a/packages/client/composables/useClicks.ts b/packages/client/composables/useClicks.ts index d643558e1a..3636ec64ad 100644 --- a/packages/client/composables/useClicks.ts +++ b/packages/client/composables/useClicks.ts @@ -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') @@ -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 @@ -160,11 +161,11 @@ export function createClicksContextBase( export function createFixedClicks( route?: SlideRoute | undefined, - currentInit = 0, + currentInit: MaybeRefOrGetter = 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, ) diff --git a/packages/client/composables/useDragElements.ts b/packages/client/composables/useDragElements.ts index d060f2dd90..ae5dae190f 100644 --- a/packages/client/composables/useDragElements.ts +++ b/packages/client/composables/useDragElements.ts @@ -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' @@ -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() @@ -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 }, diff --git a/packages/client/composables/useNav.ts b/packages/client/composables/useNav.ts index 4da9059f8d..de141d554d 100644 --- a/packages/client/composables/useNav.ts +++ b/packages/client/composables/useNav.ts @@ -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' @@ -71,7 +71,7 @@ export interface SlidevContextNavState { router: Router currentRoute: ComputedRef isPrintMode: ComputedRef - isPrintWithClicks: ComputedRef + isPrintWithClicks: Ref isEmbedded: ComputedRef isPlaying: ComputedRef isPresenter: ComputedRef @@ -83,6 +83,7 @@ export interface SlidevContextNavState { clicksContext: ComputedRef queryClicksRaw: Ref queryClicks: WritableComputedRef + printRange: Ref getPrimaryClicks: (route: SlideRoute) => ClicksContext } @@ -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 @@ -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(), @@ -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('clicks', '0') @@ -342,7 +343,7 @@ const useNavState = createSharedComposable((): SlidevContextNavState => { return { router, - currentRoute, + currentRoute: computed(() => currentRoute), isPrintMode, isPrintWithClicks, isEmbedded, @@ -356,6 +357,7 @@ const useNavState = createSharedComposable((): SlidevContextNavState => { clicksContext, queryClicksRaw, queryClicks, + printRange, getPrimaryClicks, } }) diff --git a/packages/client/composables/usePrintStyles.ts b/packages/client/composables/usePrintStyles.ts new file mode 100644 index 0000000000..eb05058801 --- /dev/null +++ b/packages/client/composables/usePrintStyles.ts @@ -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 ` diff --git a/packages/client/internals/FormCheckbox.vue b/packages/client/internals/FormCheckbox.vue new file mode 100644 index 0000000000..5070b7e20e --- /dev/null +++ b/packages/client/internals/FormCheckbox.vue @@ -0,0 +1,16 @@ + + + diff --git a/packages/client/internals/FormItem.vue b/packages/client/internals/FormItem.vue new file mode 100644 index 0000000000..053205264e --- /dev/null +++ b/packages/client/internals/FormItem.vue @@ -0,0 +1,42 @@ + + + diff --git a/packages/client/internals/IconButton.vue b/packages/client/internals/IconButton.vue index bbc7c52ef7..abc395829c 100644 --- a/packages/client/internals/IconButton.vue +++ b/packages/client/internals/IconButton.vue @@ -1,13 +1,18 @@