feature: vor und nachteile

This commit is contained in:
Hikyu
2026-05-31 13:54:31 +02:00
parent 552a947a1c
commit e3dad17446
23270 changed files with 3024794 additions and 200 deletions
+46
View File
@@ -0,0 +1,46 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useNav } from '../composables/useNav'
const { currentRoute } = useRouter()
const { total } = useNav()
const guessedSlide = computed(() => {
const path = currentRoute.value.path
const match = path.match(/\d+/)
if (match) {
const slideNo = +match[0]
if (slideNo > 0 && slideNo <= total.value)
return slideNo
}
return null
})
</script>
<template>
<div class="grid justify-center text-center pt-15% gap-5">
<div>
<h1 class="text-9xl font-light">
404
</h1>
<p class="text-2xl">
Page <code class="op-60">{{ currentRoute.path }}</code> not found
</p>
</div>
<div class="mt-3 flex flex-col gap-2 max-w-xs mx-auto w-full">
<RouterLink v-if="guessedSlide !== 1" to="/" class="page-link">
Go Home
</RouterLink>
<RouterLink v-if="guessedSlide" :to="`/${guessedSlide}`" class="page-link">
Go to Slide {{ guessedSlide }}
</RouterLink>
</div>
</div>
</template>
<style scoped lang="postcss">
.page-link {
@apply py-2 px-4 bg-gray/10 hover:bg-gray/20 rounded;
}
</style>
+26
View File
@@ -0,0 +1,26 @@
<template>
<div class="h-full w-full flex items-center justify-center gap-5 lt-md:flex-col">
<RouterLink to="/" class="page-link">
<div class="i-carbon:presentation-file" /> Slides
</RouterLink>
<RouterLink to="/presenter" class="page-link">
<div class="i-carbon:user-speaker" /> Presenter
</RouterLink>
<RouterLink to="/notes" class="page-link">
<div class="i-carbon:catalog" /> Notes
</RouterLink>
<RouterLink to="/overview" class="page-link">
<div class="i-carbon:list-boxes" /> Overview
</RouterLink>
</div>
</template>
<style scoped lang="postcss">
.page-link {
@apply flex flex-col gap-2 items-center justify-center h-40 min-w-40 rounded bg-gray:10 p4 hover:bg-gray/20;
}
.page-link > svg {
@apply text-3em op50;
}
</style>
+369
View File
@@ -0,0 +1,369 @@
<script setup lang="ts">
import type { ScreenshotSession } from '../logic/screenshot'
import { sleep } from '@antfu/utils'
import { parseRangeString } from '@slidev/parser/utils'
import { useHead } from '@unhead/vue'
import { provideLocal, useElementSize, useLocalStorage, useStyleTag, watchDebounced } from '@vueuse/core'
import { computed, ref, useTemplateRef, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useDarkMode } from '../composables/useDarkMode'
import { useNav } from '../composables/useNav'
import { patchMonacoColors } from '../composables/usePrintStyles'
import { injectionSlideScale } from '../constants'
import { configs, slideHeight, slidesTitle, slideWidth } from '../env'
import ExportPdfTip from '../internals/ExportPdfTip.vue'
import FormCheckbox from '../internals/FormCheckbox.vue'
import FormItem from '../internals/FormItem.vue'
import PrintSlide from '../internals/PrintSlide.vue'
import { isScreenshotSupported, startScreenshotSession } from '../logic/screenshot'
import { skipExportPdfTip } from '../state'
import Play from './play.vue'
const { slides, isPrintWithClicks, hasNext, go, next, currentSlideNo, clicks, printRange } = useNav()
const router = useRouter()
const { isColorSchemaConfigured, isDark } = useDarkMode()
const { width: containerWidth } = useElementSize(useTemplateRef('export-container'))
const { height: contentHeight } = useElementSize(useTemplateRef('export-content'))
const scale = computed(() => containerWidth.value / slideWidth.value)
const contentMarginBottom = computed(() => `${contentHeight.value * (scale.value - 1)}px`)
const rangesRaw = ref('')
const initialWait = ref(1000)
const delay = useLocalStorage('slidev-export-capture-delay', 400, { listenToStorageChanges: false })
type ScreenshotResult = { slideIndex: number, clickIndex: number, dataUrl: string }[]
const screenshotSession = ref<ScreenshotSession | null>(null)
const capturedImages = ref<ScreenshotResult | null>(null)
const title = ref(configs.exportFilename || slidesTitle)
useHead({
title,
})
provideLocal(injectionSlideScale, scale)
const showExportPdfTip = ref(false)
function pdf() {
if (skipExportPdfTip.value) {
doPrint()
}
else {
showExportPdfTip.value = true
}
}
function doPrint() {
patchMonacoColors()
setTimeout(window.print, 100)
}
async function capturePngs() {
if (screenshotSession.value) {
screenshotSession.value.dispose()
screenshotSession.value = null
}
if (capturedImages.value)
return capturedImages.value
try {
const scale = 2
screenshotSession.value = await startScreenshotSession(slideWidth.value * scale, slideHeight.value * scale)
const result: ScreenshotResult = []
go(1, 0, true)
await sleep(initialWait.value + delay.value)
while (true) {
if (!screenshotSession.value) {
break
}
result.push({
slideIndex: currentSlideNo.value - 1,
clickIndex: clicks.value,
dataUrl: screenshotSession.value.screenshot(document.getElementById('slide-content')!),
})
if (hasNext.value) {
await sleep(delay.value)
next()
await sleep(delay.value)
}
else {
break
}
}
if (screenshotSession.value) {
screenshotSession.value.dispose()
capturedImages.value = result
screenshotSession.value = null
}
}
catch (e) {
console.error(e)
capturedImages.value = null
}
finally {
router.push('/export')
}
return capturedImages.value
}
async function pptx() {
const pngs = await capturePngs()
if (!pngs)
return
const pptx = await import('pptxgenjs')
.then(r => r.default)
.then(PptxGen => new PptxGen())
const layoutName = `${slideWidth.value}x${slideHeight.value}`
pptx.defineLayout({
name: layoutName,
width: slideWidth.value / 96,
height: slideHeight.value / 96,
})
pptx.layout = layoutName
if (configs.author)
pptx.author = configs.author
pptx.company = 'Created using Slidev'
pptx.title = title.value
if (typeof configs.info === 'string')
pptx.subject = configs.info
pngs.forEach(({ slideIndex, dataUrl }) => {
const slide = pptx.addSlide()
slide.background = {
data: dataUrl,
}
const note = slides.value[slideIndex].meta.slide.note
if (note)
slide.addNotes(note)
})
const blob = await pptx.write({
outputType: 'blob',
compression: true,
}) as Blob
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${title.value}.pptx`
a.click()
}
async function pngsGz() {
const pngs = await capturePngs()
if (!pngs)
return
const { createTarGzip } = await import('nanotar')
const data = await createTarGzip(
pngs.map(({ slideIndex, dataUrl }) => ({
name: `${slideIndex}.png`,
data: new Uint8Array(atob(dataUrl.split(',')[1]).split('').map(char => char.charCodeAt(0))),
})),
)
const a = document.createElement('a')
const blob = new Blob([data], { type: 'application/gzip' })
a.href = URL.createObjectURL(blob)
a.download = `${title.value}.tar.gz`
a.click()
}
useStyleTag(computed(() => screenshotSession.value?.isActive
? `
html {
cursor: none;
margin-bottom: 20px;
}
body {
pointer-events: none;
}`
: `
:root {
--slidev-slide-scale: ${scale.value};
}
`))
// clear captured images when settings changed
watch(
[
isDark,
printRange,
isPrintWithClicks,
],
() => capturedImages.value = null,
)
watchDebounced(
[slides, rangesRaw],
() => printRange.value = parseRangeString(slides.value.length, rangesRaw.value),
{ debounce: 300 },
)
// clear captured images when HMR
if (import.meta.hot) {
import.meta.hot.on('vite:beforeUpdate', () => {
capturedImages.value = null
})
}
</script>
<template>
<Play v-if="screenshotSession?.isActive" />
<div
v-else
class="fixed inset-0 flex flex-col md:flex-row md:gap-8 print:position-unset print:inset-0 print:block print:min-h-max justify-center of-hidden bg-main"
>
<div class="print:hidden min-w-fit flex flex-wrap md:flex-nowrap md:of-y-auto md:flex-col gap-2 p-6 max-w-100">
<h1 class="text-3xl md:my-4 flex items-center gap-2 w-full">
<RouterLink to="/" class="i-carbon:previous-outline op-70 hover:op-100" />
Browser Exporter
<sup op50 italic text-sm>Experimental</sup>
</h1>
<div flex="~ col gap-2">
<h2>Options</h2>
<FormItem title="Title">
<input v-model="title" type="text">
</FormItem>
<FormItem title="Range">
<input v-model="rangesRaw" type="text" :placeholder="`1-${slides.length}`">
</FormItem>
<FormItem title="Dark mode">
<FormCheckbox v-model="isDark" :disabled="isColorSchemaConfigured" />
</FormItem>
<FormItem title="With clicks">
<FormCheckbox v-model="isPrintWithClicks" />
</FormItem>
</div>
<div class="flex-grow" />
<div class="min-w-fit" flex="~ col gap-3">
<div border="~ main rounded-lg" p3 flex="~ col gap-2">
<h2>Export as Vector File</h2>
<div class="flex flex-col gap-2 items-start min-w-max">
<button @click="pdf">
PDF
</button>
</div>
</div>
<div border="~ main rounded-lg" p3 flex="~ col gap-2" :class="isScreenshotSupported ? '' : 'border-orange'">
<h2>Export as Images</h2>
<div v-if="!isScreenshotSupported" class="min-w-full w-0 text-orange/100 p-1 mb--4 bg-orange/10 rounded">
<span class="i-carbon:warning-alt inline-block mb--.5" />
Your browser may not support image capturing.
If you encounter issues, please use a modern Chromium-based browser,
or export via the CLI.
</div>
<div class="flex flex-col gap-2 items-start min-w-max">
<button @click="pptx">
PPTX
</button>
<button @click="pngsGz">
PNGs.gz
</button>
</div>
<div w-full h-1px border="t main" my2 />
<div class="relative flex flex-col gap-2 flex-nowrap">
<div class="flex flex-col gap-2 items-start min-w-max">
<button v-if="capturedImages" class="flex justify-center items-center gap-2" @click="capturedImages = null">
<span class="i-carbon:trash-can inline-block text-xl" />
Clear Captured Images
</button>
<button v-else class="flex justify-center items-center gap-2" @click="capturePngs">
<div class="i-carbon:camera-action inline-block text-xl" />
Pre-capture Slides as Images
</button>
<FormItem title="Delay" description="Delay between capturing each slide in milliseconds.<br>Increase this value if slides are captured incompletely. <br>(Not related to PDF export)">
<input v-model="delay" type="number" step="50" min="50">
</FormItem>
</div>
</div>
</div>
</div>
</div>
<div id="export-container" ref="export-container" relative>
<div print:hidden fixed right-5 bottom-5 px2 py0 z-label slidev-glass-effect>
<span op75>Rendering as {{ capturedImages ? 'Captured Images' : 'DOM' }} </span>
</div>
<div v-show="!capturedImages" id="export-content" ref="export-content">
<PrintSlide v-for="route, index in slides" :key="index" :hidden="!printRange.includes(index + 1)" :route />
</div>
<div v-if="capturedImages" id="export-content-images" class="print:hidden grid">
<div v-for="png, i of capturedImages" :key="i" class="print-slide-container">
<img :src="png.dataUrl">
</div>
</div>
</div>
<div id="twoslash-container" />
<ExportPdfTip v-model="showExportPdfTip" @print="doPrint" />
</div>
</template>
<style scoped>
@media not print {
#export-container {
scrollbar-width: thin;
scroll-behavior: smooth;
--uno: w-full overflow-x-hidden overflow-y-auto max-h-full max-w-300 p-6;
}
#export-content {
transform: v-bind('`scale(${scale})`');
margin-bottom: v-bind('contentMarginBottom');
--uno: origin-tl;
}
#export-content,
#export-content-images {
--uno: flex flex-col gap-2;
}
}
@media print {
#export-content {
transform: scale(1);
display: block !important;
}
}
button {
--uno: 'w-full rounded bg-gray:10 px-4 py-2 hover:bg-gray/20';
}
label {
--uno: text-xl flex gap-2 items-center select-none;
span {
--uno: flex-grow;
}
input[type='text'],
input[type='number'] {
--uno: border border-main rounded px-2 py-1;
}
}
h2 {
--uno: font-500 op-70;
}
#export-content {
--uno: pointer-events-none;
}
</style>
<style>
@media print {
html,
body,
#app {
overflow: unset !important;
}
}
@media not print {
#export-content-images .print-slide-container,
#export-content .print-slide-container {
--uno: border border-main rounded-md shadow of-hidden;
}
}
</style>
+86
View File
@@ -0,0 +1,86 @@
<script setup lang="ts">
import { useHead } from '@unhead/vue'
import { useLocalStorage } from '@vueuse/core'
import { computed, ref, watch } from 'vue'
import { createClicksContextBase } from '../composables/useClicks'
import { useNav } from '../composables/useNav'
import { slidesTitle } from '../env'
import ClicksSlider from '../internals/ClicksSlider.vue'
import IconButton from '../internals/IconButton.vue'
import NoteDisplay from '../internals/NoteDisplay.vue'
import { fullscreen } from '../state'
import { sharedState } from '../state/shared'
useHead({ title: `Notes - ${slidesTitle}` })
const { slides, total } = useNav()
const { isFullscreen, toggle: toggleFullscreen } = fullscreen
const scroller = ref<HTMLDivElement>()
const fontSize = useLocalStorage('slidev-notes-font-size', 18)
const pageNo = computed(() => sharedState.lastUpdate?.type === 'viewer' ? sharedState.viewerPage : sharedState.page)
const currentRoute = computed(() => slides.value.find(i => i.no === pageNo.value))
watch(pageNo, () => {
scroller.value?.scrollTo({ left: 0, top: 0, behavior: 'smooth' })
window.scrollTo({ left: 0, top: 0, behavior: 'smooth' })
})
function increaseFontSize() {
fontSize.value = fontSize.value + 1
}
function decreaseFontSize() {
fontSize.value = fontSize.value - 1
}
const clicksContext = computed(() => {
const clicks = sharedState.lastUpdate?.type === 'viewer' ? sharedState.viewerClicks : sharedState.clicks
const total = sharedState.lastUpdate?.type === 'viewer' ? sharedState.viewerClicksTotal : sharedState.clicksTotal
return createClicksContextBase(ref(clicks), undefined, total)
})
</script>
<template>
<div
class="fixed top-0 left-0 h-3px bg-primary transition-all duration-500"
:style="{ width: `${(pageNo - 1) / (total - 1) * 100 + 1}%` }"
/>
<div class="h-full pt-2 flex flex-col">
<div
ref="scroller"
class="px-5 flex-auto h-full overflow-auto"
:style="{ fontSize: `${fontSize}px` }"
>
<NoteDisplay
:note="currentRoute?.meta.slide.note"
:note-html="currentRoute?.meta.slide.noteHTML"
:placeholder="`No notes for Slide ${pageNo}.`"
:clicks-context="clicksContext"
:auto-scroll="true"
/>
</div>
<div class="flex-none border-t border-main" px3 py2>
<ClicksSlider :clicks-context="clicksContext" readonly />
</div>
<div class="flex-none border-t border-main">
<div class="flex gap-1 items-center px-6 py-3">
<IconButton :title="isFullscreen ? 'Close fullscreen' : 'Enter fullscreen'" @click="toggleFullscreen">
<div v-if="isFullscreen" class="i-carbon:minimize" />
<div v-else class="i-carbon:maximize" />
</IconButton>
<IconButton title="Increase font size" @click="increaseFontSize">
<div class="i-carbon:zoom-in" />
</IconButton>
<IconButton title="Decrease font size" @click="decreaseFontSize">
<div class="i-carbon:zoom-out" />
</IconButton>
<div class="flex-auto" />
<div class="p2 text-center">
{{ pageNo }} / {{ total }}
</div>
</div>
</div>
</div>
</template>
+261
View File
@@ -0,0 +1,261 @@
<script setup lang="ts">
import type { ClicksContext, SlideRoute } from '@slidev/types'
import { useHead } from '@unhead/vue'
import { computed, nextTick, onMounted, reactive, ref, shallowRef } from 'vue'
import { createFixedClicks } from '../composables/useClicks'
import { useNav } from '../composables/useNav'
import { CLICKS_MAX } from '../constants'
import { pathPrefix, slidesTitle } from '../env'
import ClicksSlider from '../internals/ClicksSlider.vue'
import DrawingPreview from '../internals/DrawingPreview.vue'
import IconButton from '../internals/IconButton.vue'
import NoteEditable from '../internals/NoteEditable.vue'
import SlideContainer from '../internals/SlideContainer.vue'
import SlideWrapper from '../internals/SlideWrapper.vue'
import { isColorSchemaConfigured, isDark, toggleDark } from '../logic/dark'
import { getSlidePath } from '../logic/slides'
const cardWidth = 450
useHead({ title: `Overview - ${slidesTitle}` })
const { openInEditor, slides } = useNav()
const blocks: Map<number, HTMLElement> = reactive(new Map())
const activeBlocks = ref<number[]>([])
const edittingNote = ref<number | null>(null)
const wordCounts = computed(() => slides.value.map(route => wordCount(route.meta?.slide?.note || '')))
const totalWords = computed(() => wordCounts.value.reduce((a, b) => a + b, 0))
const totalClicks = computed(() => slides.value.map(route => getSlideClicks(route)).reduce((a, b) => a + b, 0))
const activeSlide = shallowRef<SlideRoute>()
const clicksContextMap = new WeakMap<SlideRoute, ClicksContext>()
function getClicksContext(route: SlideRoute) {
// We create a local clicks context to calculate the total clicks of the slide
if (!clicksContextMap.has(route))
clicksContextMap.set(route, createFixedClicks(route, CLICKS_MAX))
return clicksContextMap.get(route)!
}
function getSlideClicks(route: SlideRoute) {
return route.meta?.clicks || getClicksContext(route)?.total
}
function toggleRoute(route: SlideRoute) {
if (activeSlide.value === route)
activeSlide.value = undefined
else
activeSlide.value = route
}
function wordCount(str: string) {
const pattern = /[\w`'\-\u0392-\u03C9\u00C0-\u00FF\u0600-\u06FF\u0400-\u04FF]+|[\u4E00-\u9FFF\u3400-\u4DBF\uF900-\uFAFF\u3040-\u309F\uAC00-\uD7AF]+/g
const m = str.match(pattern)
let count = 0
if (!m)
return 0
for (let i = 0; i < m.length; i++) {
if (m[i].charCodeAt(0) >= 0x4E00) {
count += m[i].length
}
else {
count += 1
}
}
return count
}
function isElementInViewport(el: HTMLElement) {
const rect = el.getBoundingClientRect()
const delta = 20
return (
rect.top >= 0 - delta
&& rect.left >= 0 - delta
&& rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) + delta
&& rect.right <= (window.innerWidth || document.documentElement.clientWidth) + delta
)
}
function checkActiveBlocks() {
const active: number[] = []
Array.from(blocks.entries())
.forEach(([idx, el]) => {
if (isElementInViewport(el))
active.push(idx)
})
activeBlocks.value = active
}
function openSlideInNewTab(path: string) {
const a = document.createElement('a')
a.target = '_blank'
a.href = pathPrefix + path.slice(1)
a.click()
}
function scrollToSlide(idx: number) {
const el = blocks.get(idx)
if (el)
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
function onMarkerClick(e: MouseEvent, clicks: number, route: SlideRoute) {
const ctx = getClicksContext(route)
if (ctx.current === clicks)
ctx.current = CLICKS_MAX
else
ctx.current = clicks
e.preventDefault()
}
onMounted(() => {
nextTick(() => {
checkActiveBlocks()
})
})
</script>
<template>
<div class="h-screen w-screen of-hidden flex">
<nav class="grid grid-rows-[auto_max-content] border-r border-main select-none max-h-full h-full">
<div class="relative">
<div class="absolute left-0 top-0 bottom-0 w-200 flex flex-col flex-auto items-end group p2 gap-1 max-h-full of-x-visible of-y-auto" style="direction:rtl">
<div
v-for="(route, idx) of slides"
:key="route.no"
class="relative"
style="direction:ltr"
>
<button
class="relative transition duration-300 w-8 h-8 rounded hover:bg-active hover:op100"
:class="activeBlocks.includes(idx) ? 'op100 text-primary bg-gray:5' : 'op20'"
@click="scrollToSlide(idx)"
>
<div>{{ idx + 1 }}</div>
</button>
<div
v-if="route.meta?.slide?.title"
class="pointer-events-none select-none absolute left-110% top-50% translate-y--50% ws-nowrap z-label px2 slidev-glass-effect transition duration-400 op0 group-hover:op100"
:class="activeBlocks.includes(idx) ? 'text-primary' : 'text-main important-text-op-50'"
>
{{ route.meta?.slide?.title }}
</div>
</div>
</div>
</div>
<div p2 border="t main">
<IconButton
v-if="!isColorSchemaConfigured"
:title="isDark ? 'Switch to light mode theme' : 'Switch to dark mode theme'"
@click="toggleDark()"
>
<carbon-moon v-if="isDark" />
<carbon-sun v-else />
</IconButton>
<IconButton
v-else
:title="isDark ? 'Dark mode' : 'Light mode'"
pointer-events-none op50
>
<carbon-moon v-if="isDark" />
<carbon-sun v-else />
</IconButton>
</div>
</nav>
<main
class="flex-1 h-full of-auto"
:style="`grid-template-columns: repeat(auto-fit,minmax(${cardWidth}px,1fr))`"
@scroll="checkActiveBlocks"
>
<div
v-for="(route, idx) of slides"
:key="route.no"
:ref="el => blocks.set(idx, el as any)"
class="relative border-t border-main of-hidden flex gap-4 min-h-50 group"
:class="idx === 0 ? 'pt5' : ''"
>
<div class="select-none w-13 text-right my4 flex flex-col gap-1 items-end">
<div class="text-3xl op20 mb2">
{{ idx + 1 }}
</div>
<IconButton
class="mr--3 op0 group-hover:op80"
title="Play in new tab"
@click="openSlideInNewTab(getSlidePath(route, false))"
>
<div class="i-carbon:presentation-file" />
</IconButton>
<IconButton
v-if="__DEV__ && route.meta?.slide"
class="mr--3 op0 group-hover:op80"
title="Open in editor"
@click="openInEditor(`${route.meta.slide.filepath}:${route.meta.slide.start}`)"
>
<div class="i-carbon:cics-program" />
</IconButton>
</div>
<div class="flex flex-col gap-2 my5" :style="{ width: `${cardWidth}px` }">
<div
class="border rounded border-main overflow-hidden bg-main select-none h-max"
@dblclick="openSlideInNewTab(getSlidePath(route, false))"
>
<SlideContainer
:key="route.no"
:width="cardWidth"
class="pointer-events-none important:[&_*]:select-none"
>
<SlideWrapper
:clicks-context="getClicksContext(route)"
:route="route"
render-context="overview"
/>
<DrawingPreview :page="route.no" />
</SlideContainer>
</div>
<ClicksSlider
v-if="getSlideClicks(route)"
:active="activeSlide === route"
:clicks-context="getClicksContext(route)"
class="w-full mt-2"
@dblclick="toggleRoute(route)"
@click="activeSlide = route"
/>
</div>
<div class="py3 mt-0.5 mr--8 ml--4 op0 transition group-hover:op100">
<IconButton
title="Edit Note"
class="rounded-full w-9 h-9 text-sm"
:class="edittingNote === route.no ? 'important:op0' : ''"
@click="edittingNote = route.no"
>
<div class="i-carbon:pen" />
</IconButton>
</div>
<NoteEditable
:no="route.no"
class="max-w-250 w-250 text-lg rounded p3"
:auto-height="true"
:highlight="activeSlide === route"
:editing="edittingNote === route.no"
:clicks-context="getClicksContext(route)"
@dblclick="edittingNote !== route.no ? edittingNote = route.no : null"
@update:editing="edittingNote = null"
@marker-click="(e, clicks) => onMarkerClick(e, clicks, route)"
/>
<div
v-if="wordCounts[idx] > 0"
class="select-none absolute bottom-0 right-0 bg-main rounded-tl p2 op35 text-xs"
>
{{ wordCounts[idx] }} words
</div>
</div>
</main>
<div class="absolute top-0 right-0 px3 py1.5 border-b border-l rounded-lb bg-main border-main select-none">
<div class="text-xs op50">
{{ slides.length }} slides ·
{{ totalClicks + slides.length - 1 }} clicks ·
{{ totalWords }} words
</div>
</div>
</div>
</template>
+96
View File
@@ -0,0 +1,96 @@
<script setup lang="ts">
import { useStyleTag } from '@vueuse/core'
import { computed, ref, shallowRef } from 'vue'
import { useDrawings } from '../composables/useDrawings'
import { useNav } from '../composables/useNav'
import { useSwipeControls } from '../composables/useSwipeControls'
import { useWakeLock } from '../composables/useWakeLock'
import Controls from '../internals/Controls.vue'
import NavControls from '../internals/NavControls.vue'
import PresenterMouse from '../internals/PresenterMouse.vue'
import SlideContainer from '../internals/SlideContainer.vue'
import SlidesShow from '../internals/SlidesShow.vue'
import { onContextMenu } from '../logic/contextMenu'
import { registerShortcuts } from '../logic/shortcuts'
import { editorHeight, editorWidth, isEditorVertical, isScreenVertical, showEditor } from '../state'
const { next, prev, isPrintMode } = useNav()
const { isDrawing } = useDrawings()
const root = ref<HTMLDivElement>()
function onClick(e: MouseEvent) {
if (showEditor.value)
return
if (e.button === 0 && (e.target as HTMLElement)?.id === 'slide-container') {
// click right to next, left to previous
if ((e.pageX / window.innerWidth) > 0.5)
next()
else
prev()
}
}
useSwipeControls(root)
registerShortcuts()
if (__SLIDEV_FEATURE_WAKE_LOCK__)
useWakeLock()
if (import.meta.hot) {
useStyleTag(computed(() => showEditor.value
? `
vite-error-overlay {
--width: calc(100vw - ${isEditorVertical.value ? 0 : editorWidth.value}px);
--height: calc(100vh - ${isEditorVertical.value ? editorHeight.value : 0}px);
position: fixed;
left: 0;
top: 0;
width: calc(var(--width) / var(--slidev-slide-scale));
height: calc(var(--height) / var(--slidev-slide-scale));
transform-origin: top left;
transform: scale(var(--slidev-slide-scale));
}`
: '',
))
}
const persistNav = computed(() => isScreenVertical.value || showEditor.value)
const SideEditor = shallowRef<any>()
if (__DEV__ && __SLIDEV_FEATURE_EDITOR__)
import('../internals/SideEditor.vue').then(v => SideEditor.value = v.default)
</script>
<template>
<div
id="page-root" ref="root" class="grid"
:class="isEditorVertical ? 'grid-rows-[1fr_max-content]' : 'grid-cols-[1fr_max-content]'"
>
<SlideContainer
:style="{ background: 'var(--slidev-slide-container-background, black)' }"
is-main
@pointerdown="onClick"
@contextmenu="onContextMenu"
>
<template #default>
<SlidesShow render-context="slide" />
<PresenterMouse />
</template>
<template #controls>
<div
v-if="!isPrintMode"
class="absolute bottom-0 left-0 transition duration-300 opacity-0 hover:opacity-100 focus-within:opacity-100 focus-visible:opacity-100"
:class="[
persistNav ? '!opacity-100 right-0' : 'opacity-0 p-2',
isDrawing ? 'pointer-events-none' : '',
]"
>
<NavControls :persist="persistNav" />
</div>
</template>
</SlideContainer>
<SideEditor v-if="SideEditor && showEditor" :resize="true" />
</div>
<Controls v-if="!isPrintMode" />
<div id="twoslash-container" />
</template>
+305
View File
@@ -0,0 +1,305 @@
<script setup lang="ts">
import { useHead } from '@unhead/vue'
import { useMouse, useWindowFocus } from '@vueuse/core'
import { computed, onMounted, reactive, ref, shallowRef, watch } from 'vue'
import { createFixedClicks } from '../composables/useClicks'
import { useDrawings } from '../composables/useDrawings'
import { useNav } from '../composables/useNav'
import { useSwipeControls } from '../composables/useSwipeControls'
import { useTimer } from '../composables/useTimer'
import { useWakeLock } from '../composables/useWakeLock'
import { slidesTitle } from '../env'
import ClicksSlider from '../internals/ClicksSlider.vue'
import ContextMenu from '../internals/ContextMenu.vue'
import DrawingControls from '../internals/DrawingControls.vue'
import Goto from '../internals/Goto.vue'
import IconButton from '../internals/IconButton.vue'
import NavControls from '../internals/NavControls.vue'
import NoteEditable from '../internals/NoteEditable.vue'
import NoteStatic from '../internals/NoteStatic.vue'
import QuickOverview from '../internals/QuickOverview.vue'
import SlideContainer from '../internals/SlideContainer.vue'
import SlidesShow from '../internals/SlidesShow.vue'
import SlideWrapper from '../internals/SlideWrapper.vue'
import { onContextMenu } from '../logic/contextMenu'
import { registerShortcuts } from '../logic/shortcuts'
import { decreasePresenterFontSize, increasePresenterFontSize, presenterLayout, presenterNotesFontSize, showEditor, showPresenterCursor } from '../state'
import { sharedState } from '../state/shared'
const main = ref<HTMLDivElement>()
registerShortcuts()
useSwipeControls(main)
if (__SLIDEV_FEATURE_WAKE_LOCK__)
useWakeLock()
const {
clicksContext,
currentSlideNo,
currentSlideRoute,
hasNext,
nextRoute,
slides,
getPrimaryClicks,
total,
} = useNav()
const { isDrawing } = useDrawings()
useHead({ title: `Presenter - ${slidesTitle}` })
const notesEditing = ref(false)
const { timer, isTimerAvctive, resetTimer, toggleTimer } = useTimer()
const clicksCtxMap = computed(() => slides.value.map(route => createFixedClicks(route)))
const nextFrame = computed(() => {
if (clicksContext.value.current < clicksContext.value.total)
return [currentSlideRoute.value!, clicksContext.value.current + 1] as const
else if (hasNext.value)
return [nextRoute.value, 0] as const
else
return null
})
const nextFrameClicksCtx = computed(() => {
return nextFrame.value && clicksCtxMap.value[nextFrame.value[0].no - 1]
})
watch(
nextFrame,
() => {
if (nextFrameClicksCtx.value && nextFrame.value)
nextFrameClicksCtx.value.current = nextFrame.value[1]
},
{ immediate: true },
)
const SideEditor = shallowRef<any>()
if (__DEV__ && __SLIDEV_FEATURE_EDITOR__)
import('../internals/SideEditor.vue').then(v => SideEditor.value = v.default)
// sync presenter cursor
onMounted(() => {
const slidesContainer = main.value!.querySelector('#slide-content')!
const mouse = reactive(useMouse())
const focus = useWindowFocus()
watch(
() => {
if (!focus.value || isDrawing.value || !showPresenterCursor.value)
return undefined
const rect = slidesContainer.getBoundingClientRect()
const x = (mouse.x - rect.left) / rect.width * 100
const y = (mouse.y - rect.top) / rect.height * 100
if (x < 0 || x > 100 || y < 0 || y > 100)
return undefined
return { x, y }
},
(pos) => {
sharedState.cursor = pos
},
)
})
</script>
<template>
<div class="bg-main h-full slidev-presenter">
<div class="grid-container" :class="`layout${presenterLayout}`">
<div ref="main" class="relative grid-section main flex flex-col">
<SlideContainer
key="main"
class="p-2 lg:p-4 flex-auto"
is-main
@contextmenu="onContextMenu"
>
<SlidesShow render-context="presenter" />
</SlideContainer>
<ClicksSlider
:key="currentSlideRoute?.no"
:clicks-context="getPrimaryClicks(currentSlideRoute)"
class="w-full pb2 px4 flex-none"
/>
<div class="absolute left-0 top-0 bg-main border-b border-r border-main px2 py1 op50 text-sm">
Current
</div>
</div>
<div class="relative grid-section next flex flex-col p-2 lg:p-4">
<SlideContainer v-if="nextFrame && nextFrameClicksCtx" key="next">
<SlideWrapper
:key="nextFrame[0].no"
:clicks-context="nextFrameClicksCtx"
:route="nextFrame[0]"
render-context="previewNext"
/>
</SlideContainer>
<div v-else class="h-full flex justify-center items-center">
<div class="text-gray-500">
End of the presentation
</div>
</div>
<div class="absolute left-0 top-0 bg-main border-b border-r border-main px2 py1 op50 text-sm">
Next
</div>
</div>
<div v-if="SideEditor && showEditor" class="grid-section note of-auto">
<SideEditor />
</div>
<div v-else class="grid-section note grid grid-rows-[1fr_min-content] overflow-hidden">
<NoteEditable
v-if="__DEV__"
:key="`edit-${currentSlideNo}`"
v-model:editing="notesEditing"
:no="currentSlideNo"
class="w-full max-w-full h-full overflow-auto p-2 lg:p-4"
:clicks-context="clicksContext"
:style="{ fontSize: `${presenterNotesFontSize}em` }"
/>
<NoteStatic
v-else
:key="`static-${currentSlideNo}`"
:no="currentSlideNo"
class="w-full max-w-full h-full overflow-auto p-2 lg:p-4"
:style="{ fontSize: `${presenterNotesFontSize}em` }"
:clicks-context="clicksContext"
/>
<div class="border-t border-main py-1 px-2 text-sm">
<IconButton title="Increase font size" @click="increasePresenterFontSize">
<div class="i-carbon:zoom-in" />
</IconButton>
<IconButton title="Decrease font size" @click="decreasePresenterFontSize">
<div class="i-carbon:zoom-out" />
</IconButton>
<IconButton
v-if="__DEV__"
title="Edit Notes"
@click="notesEditing = !notesEditing"
>
<div class="i-carbon:edit" />
</IconButton>
</div>
</div>
<div class="grid-section bottom flex">
<NavControls :persist="true" />
<div flex-auto />
<div class="group flex items-center justify-center pl-4 select-none">
<div class="w-22px cursor-pointer">
<div class="i-carbon:time group-hover:hidden text-xl" />
<div class="group-not-hover:hidden flex flex-col items-center">
<div class="relative op-80 hover:op-100" @click="toggleTimer">
<div v-if="isTimerAvctive" class="i-carbon:pause text-lg" />
<div v-else class="i-carbon:play" />
</div>
<div class="op-80 hover:op-100" @click="resetTimer">
<div class="i-carbon:renew" />
</div>
</div>
</div>
<div class="text-2xl px-3 my-auto tabular-nums">
{{ timer }}
</div>
</div>
</div>
<DrawingControls v-if="__SLIDEV_FEATURE_DRAWINGS__" />
</div>
<div class="progress-bar">
<div
class="progress h-3px bg-primary transition-all"
:style="{ width: `${(currentSlideNo - 1) / (total - 1) * 100 + 1}%` }"
/>
</div>
</div>
<Goto />
<QuickOverview />
<ContextMenu />
</template>
<style scoped>
.slidev-presenter {
--slidev-controls-foreground: current;
}
.grid-container {
--uno: bg-gray/20;
height: 100%;
width: 100%;
display: grid;
gap: 1px 1px;
}
.grid-container.layout1 {
grid-template-columns: 1fr 1fr;
grid-template-rows: 2fr 1fr min-content;
grid-template-areas:
'main main'
'note next'
'bottom bottom';
}
.grid-container.layout2 {
grid-template-columns: 3fr 2fr;
grid-template-rows: 2fr 1fr min-content;
grid-template-areas:
'note main'
'note next'
'bottom bottom';
}
.grid-container.layout3 {
grid-template-columns: 2fr 3fr;
grid-template-rows: 1fr 1fr min-content;
grid-template-areas:
'note next'
'main next'
'bottom bottom';
}
@media (max-aspect-ratio: 3/5) {
.grid-container.layout1 {
grid-template-columns: 1fr;
grid-template-rows: 1fr 1fr 1fr min-content;
grid-template-areas:
'main'
'note'
'next'
'bottom';
}
}
@media (min-aspect-ratio: 1/1) {
.grid-container.layout1 {
grid-template-columns: 1fr 1.1fr 0.9fr;
grid-template-rows: 1fr 2fr min-content;
grid-template-areas:
'main main next'
'main main note'
'bottom bottom bottom';
}
}
.progress-bar {
--uno: fixed left-0 right-0 top-0;
}
.grid-section {
--uno: bg-main;
}
.grid-section.top {
grid-area: top;
}
.grid-section.main {
grid-area: main;
}
.grid-section.next {
grid-area: next;
}
.grid-section.note {
grid-area: note;
}
.grid-section.bottom {
grid-area: bottom;
}
</style>
+70
View File
@@ -0,0 +1,70 @@
<script setup lang="ts">
import { useHead } from '@unhead/vue'
import { useStyleTag } from '@vueuse/core'
import { computed } from 'vue'
import { useNav } from '../../composables/useNav'
import { configs } from '../../env'
import NoteDisplay from '../../internals/NoteDisplay.vue'
const { slides, total } = useNav()
useStyleTag(`
@page {
size: A4;
margin-top: 1.5cm;
margin-bottom: 1cm;
}
* {
-webkit-print-color-adjust: exact;
}
html,
html body,
html #app,
html #page-root {
height: auto;
overflow: auto !important;
}
`)
useHead({
title: `Notes - ${configs.title}`,
})
const slidesWithNote = computed(() => slides.value
.map(route => route.meta?.slide)
.filter(slide => slide !== undefined && slide.noteHTML !== ''))
</script>
<template>
<div id="page-root">
<div class="m-4">
<div class="mb-10">
<h1 class="text-4xl font-bold mt-2">
{{ configs.title }}
</h1>
<div class="opacity-50">
{{ new Date().toLocaleString() }}
</div>
</div>
<div v-for="(slide, index) of slidesWithNote" :key="index" class="flex flex-col gap-4 break-inside-avoid-page">
<div>
<h2 class="text-lg">
<div class="font-bold flex gap-2">
<div class="opacity-50">
{{ slide?.no }}/{{ total }}
</div>
{{ slide?.title }}
<div class="flex-auto" />
</div>
</h2>
<NoteDisplay
:note-html="slide!.noteHTML"
class="max-w-full"
/>
</div>
<hr v-if="index < slidesWithNote.length - 1" class="border-main mb-8">
</div>
</div>
</div>
</template>
+59
View File
@@ -0,0 +1,59 @@
<script setup lang="ts">
import { recomputeAllPoppers } from 'floating-vue'
import { onMounted, watchEffect } from 'vue'
import { useNav } from '../composables/useNav'
import PrintContainer from '../internals/PrintContainer.vue'
import { windowSize } from '../state'
const { isPrintMode } = useNav()
watchEffect(() => {
if (isPrintMode)
(document.body.parentNode as HTMLElement).classList.add('print')
else
(document.body.parentNode as HTMLElement).classList.remove('print')
})
onMounted(() => {
recomputeAllPoppers()
})
</script>
<template>
<div id="page-root" class="grid grid-cols-[1fr_max-content]">
<PrintContainer
class="w-full h-full"
:style="{ background: 'var(--slidev-slide-container-background, black)' }"
:width="windowSize.width.value"
/>
<div id="twoslash-container" />
</div>
</template>
<style lang="postcss">
html.print,
html.print body,
html.print #app {
height: auto;
overflow: auto;
}
html.print #page-root {
height: auto;
overflow: hidden;
}
html.print * {
-webkit-print-color-adjust: exact;
}
html.print {
width: 100%;
height: 100%;
overflow: visible;
}
html.print body {
margin: 0 auto;
border: 0;
padding: 0;
float: none;
overflow: visible;
}
</style>