Skip to content

Commit b045893

Browse files
committed
feat: handout
1 parent 5637013 commit b045893

File tree

16 files changed

+674
-10
lines changed

16 files changed

+674
-10
lines changed

demo/starter/handout-bottom.vue

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<script setup lang="ts">
2+
defineProps<{ pageNumber: number }>()
3+
// A footer layout that relies on the parent handout container for the full-width
4+
// top rule and page number placement. Avoid absolute positioning so the
5+
// container can reserve space and prevent overlaps.
6+
const year = new Date().getFullYear()
7+
// Replace these with your flavor system if needed
8+
const company = 'Slidev'
9+
const rightText = 'Presentation slides for developers'
10+
</script>
11+
12+
<template>
13+
<div class="handout-footer">
14+
<div class="handout-row">
15+
<div class="left">
16+
© {{ year }} {{ company }}
17+
</div>
18+
<div class="right">
19+
{{ rightText }}
20+
</div>
21+
</div>
22+
</div>
23+
</template>
24+
25+
<style scoped>
26+
.handout-footer {
27+
width: 100%;
28+
}
29+
.handout-row {
30+
display: flex;
31+
justify-content: space-between;
32+
align-items: flex-end;
33+
font-size: 11px;
34+
line-height: 1.2;
35+
padding-top: 3mm; /* space below the rule drawn by container */
36+
}
37+
.left,
38+
.right {
39+
white-space: nowrap;
40+
}
41+
</style>

demo/starter/handout-cover.vue

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<template>
2+
<div>
3+
<div class="break-after-page">
4+
<span class="font-bold text-xl border-b-4 border-r-4 px-1 py-1 border-gray-800 ">
5+
SLIDEV
6+
</span>
7+
<div class="h-56" />
8+
<div class="max-w-md w-full mx-auto">
9+
<div class="w-120 mx-auto font-bold flex flex-wrap gap-4">
10+
<h1 class="text-5xl font-bold border-b-4 border-gray-800">
11+
Welcome to Slidev
12+
</h1>
13+
v1.0
14+
</div>
15+
<div class="h-32" />
16+
<div class="text-2xl font-bold mx-auto w-120 text-right">
17+
Slidev Starter Template
18+
</div>
19+
</div>
20+
</div>
21+
<div class="break-after-page mt-68 mx-8 ">
22+
<div class="font-bold text-xl mt-8">
23+
COPYRIGHT
24+
</div>
25+
<div class="flex flex-row gap-4 mt-4 align-center items-center">
26+
© {{ new Date().getFullYear().toString() }} Slidev.
27+
</div>
28+
<p class="mt-4">
29+
Permission is hereby granted, free of charge, to any person obtaining a copy
30+
of this software and associated documentation files (the "Software"), to deal
31+
in the Software without restriction, including without limitation the rights
32+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
33+
copies of the Software, and to permit persons to whom the Software is
34+
furnished to do so, subject to the following conditions:
35+
</p>
36+
<p class="mt-4">
37+
The above copyright notice and this permission notice shall be included in all
38+
copies or substantial portions of the Software.
39+
</p>
40+
<p class="mt-4">
41+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
42+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OFMERCHANTABILITY,
43+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
44+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
45+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
46+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
47+
SOFTWARE.
48+
</p>
49+
</div>
50+
</div>
51+
</template>

packages/client/composables/useNav.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -280,7 +280,7 @@ const useNavState = createSharedComposable((): SlidevContextNavState => {
280280
router?.currentRoute?.value?.query
281281
return new URLSearchParams(location.search)
282282
})
283-
const isPrintMode = computed(() => query.value.has('print') || currentRoute.name === 'export')
283+
const isPrintMode = computed(() => query.value.has('print') || currentRoute.name === 'export' || currentRoute.name === 'handout' || currentRoute.name === 'cover')
284284
const isPrintWithClicks = ref(query.value.get('print') === 'clicks')
285285
const isEmbedded = computed(() => query.value.has('embedded'))
286286
const isPlaying = computed(() => currentRoute.name === 'play')

packages/client/composables/usePrintStyles.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
import { useStyleTag } from '@vueuse/core'
22
import { computed } from 'vue'
3+
import { useRoute } from 'vue-router'
34
import { slideHeight, slideWidth } from '../env'
45
import { useNav } from './useNav'
56

67
export function usePrintStyles() {
78
const { isPrintMode } = useNav()
9+
const route = useRoute()
810

9-
useStyleTag(computed(() => isPrintMode.value
11+
// Only inject slide-sized @page for the default print/export view.
12+
// Handout and cover have their own A4 page sizing and should not be overridden.
13+
useStyleTag(computed(() => (isPrintMode.value && !['handout', 'cover'].includes((route.name as string) || ''))
1014
? `
1115
@page {
1216
size: ${slideWidth.value}px ${slideHeight.value}px;
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<script setup lang="ts">
2+
import { provideLocal } from '@vueuse/core'
3+
import { computed } from 'vue'
4+
import { useNav } from '../composables/useNav'
5+
import { injectionSlideScale } from '../constants'
6+
import { configs, slideAspect, slideWidth } from '../env'
7+
import PrintHandout from './PrintHandout.vue'
8+
9+
const props = defineProps<{
10+
width: number
11+
pageOffset?: number
12+
}>()
13+
14+
const { slides, printRange } = useNav()
15+
16+
const width = computed(() => props.width)
17+
const height = computed(() => props.width / slideAspect.value)
18+
const screenAspect = computed(() => width.value / height.value)
19+
20+
const scale = computed(() => {
21+
if (screenAspect.value < slideAspect.value)
22+
return width.value / slideWidth.value
23+
return (height.value * slideAspect.value) / slideWidth.value
24+
})
25+
26+
const className = computed(() => ({
27+
'select-none': !configs.selectable,
28+
}))
29+
30+
provideLocal(injectionSlideScale, scale)
31+
</script>
32+
33+
<template>
34+
<div id="print-container" :class="className">
35+
<div id="print-content">
36+
<PrintHandout
37+
v-for="no of printRange"
38+
:key="no"
39+
:route="slides[no - 1]"
40+
:index="no - 1"
41+
:page-offset="props.pageOffset ?? 0"
42+
/>
43+
</div>
44+
</div>
45+
</template>
46+
47+
<style lang="postcss">
48+
#print-content {
49+
@apply bg-main;
50+
}
51+
.print-slide-container {
52+
@apply relative overflow-hidden break-after-page translate-0;
53+
}
54+
/* Ensure printed background is white regardless of theme */
55+
html.print #print-content {
56+
background: #ffffff !important;
57+
}
58+
</style>
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
<script setup lang="ts">
2+
import type { SlideRoute } from '@slidev/types'
3+
import HandoutBottom from '#slidev/global-components/handout-bottom'
4+
import { computed } from 'vue'
5+
import { slideHeight, slideWidth } from '../env'
6+
import NoteDisplay from './NoteDisplay.vue'
7+
import PrintSlide from './PrintSlide.vue'
8+
9+
const props = defineProps<{
10+
route: SlideRoute
11+
index: number
12+
pageOffset?: number
13+
}>()
14+
const route = computed(() => props.route)
15+
const pageOffset = computed(() => props.pageOffset ?? 0)
16+
17+
// Use fixed A4 dimensions in CSS pixels (96 DPI) to avoid relying on
18+
// runtime viewport sizes which can skew scaling during headless export
19+
const PAGE_WIDTH_PX = Math.round(210 / 25.4 * 96)
20+
const PAGE_HEIGHT_PX = Math.round(297 / 25.4 * 96)
21+
const pageWidth = computed(() => PAGE_WIDTH_PX)
22+
const pageHeight = computed(() => PAGE_HEIGHT_PX)
23+
24+
// Target layout: slide ~50% height, notes the rest, footer pinned
25+
const SLIDE_PORTION = 0.5
26+
const PX_PER_MM = 96 / 25.4
27+
// Inner paddings of the page box
28+
const PAGE_PAD_SIDE_MM = 12
29+
const PAGE_PAD_TOP_MM = 10
30+
const PAGE_PAD_BOTTOM_MM = 10
31+
// Extra margins around the slide area
32+
const SLIDE_SIDE_MARGIN_MM = 6
33+
const SLIDE_TOP_MARGIN_MM = 4
34+
35+
// Constrain slide scale by width and by allowed height portion with top/side margins
36+
const scale = computed(() => {
37+
const innerWidth = pageWidth.value - 2 * PAGE_PAD_SIDE_MM * PX_PER_MM
38+
const innerHeight = pageHeight.value - (PAGE_PAD_TOP_MM + PAGE_PAD_BOTTOM_MM) * PX_PER_MM
39+
const byWidth = (innerWidth - 2 * SLIDE_SIDE_MARGIN_MM * PX_PER_MM) / slideWidth.value
40+
const byHeight = ((innerHeight * SLIDE_PORTION) - (SLIDE_TOP_MARGIN_MM * PX_PER_MM)) / slideHeight.value
41+
const s = Math.min(1, byWidth, byHeight)
42+
return Math.max(0, s - 0.006)
43+
})
44+
45+
const slideHeightScaled = computed(() => slideHeight.value * scale.value)
46+
const slideAreaStyle = computed(() => ({
47+
height: `${slideHeightScaled.value}px`,
48+
padding: `${SLIDE_TOP_MARGIN_MM}mm ${SLIDE_SIDE_MARGIN_MM}mm 0`,
49+
}))
50+
</script>
51+
52+
<template>
53+
<div class="break-after-page page">
54+
<div class="slide-area" :style="slideAreaStyle">
55+
<div class="slide-scale-wrap" :style="{ width: `${slideWidth}px`, height: `${slideHeight}px`, transform: `scale(${scale})` }">
56+
<PrintSlide :route="route" />
57+
</div>
58+
</div>
59+
<div class="notes-area">
60+
<NoteDisplay
61+
v-if="route.meta?.slide!.noteHTML"
62+
:note-html="route.meta?.slide!.noteHTML"
63+
class="w-full mx-auto px-4 handout-notes"
64+
/>
65+
</div>
66+
<div class="footer-area">
67+
<div class="footer-bleed">
68+
<HandoutBottom :page-number="index + 1 + pageOffset" :page-offset="pageOffset" />
69+
</div>
70+
</div>
71+
</div>
72+
</template>
73+
74+
<style scoped>
75+
/* One handout page per printed page, sized to A4 with internal padding */
76+
.page {
77+
display: flex;
78+
flex-direction: column;
79+
width: 210mm;
80+
min-height: 297mm;
81+
margin: 0 auto;
82+
padding: 10mm 12mm 4mm; /* top, sides, bottom */
83+
84+
box-sizing: border-box;
85+
break-after: page;
86+
page-break-after: always; /* Chromium fallback */
87+
break-inside: avoid-page;
88+
}
89+
.slide-area {
90+
flex: 0 0 auto;
91+
display: flex;
92+
justify-content: center;
93+
align-items: flex-start;
94+
}
95+
.slide-area {
96+
/* Allow slide borders/shadows to render fully */
97+
overflow: visible;
98+
margin-bottom: 4mm;
99+
}
100+
.slide-scale-wrap {
101+
transform-origin: top center;
102+
margin: 0 auto;
103+
}
104+
.slide-scale-wrap :deep(.print-slide-container) {
105+
break-after: auto;
106+
/* Thin dark gray border around each slide in handout */
107+
border: 1px solid rgba(0, 0, 0, 0.6);
108+
border-radius: 2px;
109+
}
110+
.notes-area {
111+
flex: 1 1 auto;
112+
overflow: hidden;
113+
display: flex;
114+
/* add a bit more top spacing above notes */
115+
padding: 8mm 3mm 12mm;
116+
}
117+
.handout-notes {
118+
margin: 0 auto;
119+
max-width: none;
120+
}
121+
.footer-area {
122+
position: relative;
123+
display: flex;
124+
align-items: flex-end;
125+
gap: 6mm;
126+
flex: 0 0 auto;
127+
min-height: 14mm;
128+
}
129+
/* full-width top rule across footer */
130+
.footer-area::before {
131+
content: '';
132+
position: absolute;
133+
left: -12mm;
134+
right: -12mm;
135+
top: 0;
136+
height: 1px;
137+
background: #222;
138+
opacity: 0.9;
139+
}
140+
.footer-bleed {
141+
flex: 1 1 auto;
142+
position: relative;
143+
margin: 0;
144+
overflow: visible;
145+
padding-top: 2mm;
146+
}
147+
.footer-bleed :deep(*) {
148+
max-width: 100% !important;
149+
margin-left: 0 !important;
150+
margin-right: 0 !important;
151+
border-top-width: 0 !important;
152+
}
153+
.page-num {
154+
flex: 0 0 auto;
155+
font-size: 11px;
156+
text-align: right;
157+
padding-bottom: 1mm;
158+
z-index: 1;
159+
}
160+
</style>

0 commit comments

Comments
 (0)