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
+16
View File
@@ -0,0 +1,16 @@
<script setup lang="ts">
import { watchEffect } from 'vue'
import { themeVars } from './env'
import setupRoot from './setup/root'
setupRoot()
watchEffect(() => {
for (const [key, value] of Object.entries(themeVars.value))
document.body.style.setProperty(key, value.toString())
})
</script>
<template>
<RouterView />
</template>
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020-PRESENT Anthony Fu
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+9
View File
@@ -0,0 +1,9 @@
# @slidev/client
[![NPM version](https://img.shields.io/npm/v/@slidev/client?color=3AB9D4&label=)](https://www.npmjs.com/package/@slidev/client)
Client code for [Slidev](https://sli.dev). Shipped with [`@slidev/cli`](https://www.npmjs.com/package/@slidev/cli).
## License
MIT License © 2021 [Anthony Fu](https://github.com/antfu)
Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

+60
View File
@@ -0,0 +1,60 @@
<svg width="115" height="115" viewBox="0 0 115 115" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0)">
<g filter="url(#filter0_d)">
<rect x="14" y="14" width="90" height="90" rx="15" fill="url(#paint0_linear)"/>
</g>
<g filter="url(#filter1_d)">
<rect width="90" height="90" rx="45" fill="url(#paint1_linear)"/>
</g>
<g filter="url(#filter2_d)">
<path d="M56.127 63.925C54.9501 59.5327 54.3617 57.3366 54.9439 55.8199C55.4517 54.497 56.497 53.4517 57.8199 52.9439C59.3366 52.3617 61.5327 52.9501 65.925 54.127L87.7563 59.9767C92.1485 61.1536 94.3446 61.742 95.367 63.0046C96.2588 64.1058 96.6414 65.5338 96.4197 66.9334C96.1656 68.5379 94.5579 70.1456 91.3426 73.3609L75.3609 89.3426C72.1456 92.5579 70.5379 94.1656 68.9333 94.4197C67.5337 94.6414 66.1058 94.2588 65.0046 93.367C63.742 92.3446 63.1536 90.1485 61.9767 85.7563L56.127 63.925Z" fill="url(#paint2_linear)"/>
</g>
</g>
<defs>
<filter id="filter0_d" x="8" y="8" width="110" height="110" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset dx="4" dy="4"/>
<feGaussianBlur stdDeviation="5"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
</filter>
<filter id="filter1_d" x="-6" y="-6" width="110" height="110" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset dx="4" dy="4"/>
<feGaussianBlur stdDeviation="5"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
</filter>
<filter id="filter2_d" x="34.8833" y="32.8833" width="77.6614" height="77.6614" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset dx="4" dy="4"/>
<feGaussianBlur stdDeviation="5"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
</filter>
<linearGradient id="paint0_linear" x1="14" y1="6" x2="104" y2="104" gradientUnits="userSpaceOnUse">
<stop stop-color="#3ACBD4"/>
<stop offset="1" stop-color="#2988B1"/>
</linearGradient>
<linearGradient id="paint1_linear" x1="-9.5" y1="-11" x2="76.0825" y2="90" gradientUnits="userSpaceOnUse">
<stop stop-color="#95F0CF"/>
<stop offset="1" stop-color="#3AB9D5"/>
</linearGradient>
<linearGradient id="paint2_linear" x1="54.6616" y1="49.3453" x2="59.8793" y2="96.3585" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFEB83"/>
<stop offset="0.0001" stop-color="#FFEB83"/>
<stop offset="0.0833333" stop-color="#FFDD35"/>
<stop offset="0.601773" stop-color="#FFBB13"/>
<stop offset="1" stop-color="#FFA800"/>
</linearGradient>
<clipPath id="clip0">
<rect width="115" height="115" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

+72
View File
@@ -0,0 +1,72 @@
<!--
Simple Arrow
<arrow x1="10" y1="20" x2="100" y2="200" color="green" width="3" />
<arrow v-bind="{ x1:10, y1:10, x2:200, y2:200 }"/>
-->
<script setup lang="ts">
import { onClickOutside } from '@vueuse/core'
import { ref } from 'vue'
import { makeId } from '../logic/utils'
defineProps<{
x1: number | string
y1: number | string
x2: number | string
y2: number | string
width?: number | string
color?: string
twoWay?: boolean
}>()
const emit = defineEmits(['dblclick', 'clickOutside'])
const id = makeId()
const markerAttrs = {
markerUnits: 'strokeWidth',
markerHeight: 7,
refY: 3.5,
orient: 'auto',
}
const clickArea = ref<HTMLElement>()
onClickOutside(clickArea, () => emit('clickOutside'))
</script>
<template>
<svg
class="absolute left-0 top-0"
:width="Math.max(+x1, +x2) + 50"
:height="Math.max(+y1, +y2) + 50"
>
<defs>
<marker :id="id" markerWidth="10" refX="9" v-bind="markerAttrs">
<polygon points="0 0, 10 3.5, 0 7" :fill="color || 'currentColor'" @dblclick="emit('dblclick')" />
</marker>
<marker v-if="twoWay" :id="`${id}-rev`" markerWidth="20" refX="11" v-bind="markerAttrs">
<polygon points="20 0, 10 3.5, 20 7" :fill="color || 'currentColor'" @dblclick="emit('dblclick')" />
</marker>
</defs>
<line
:x1 :y1 :x2 :y2
:stroke="color || 'currentColor'"
:stroke-width="width || 2"
:marker-end="`url(#${id})`"
:marker-start="twoWay ? `url(#${id}-rev)` : 'none'"
@dblclick="emit('dblclick')"
/>
<line
ref="clickArea"
:x1 :y1 :x2 :y2
stroke="transparent"
stroke-linecap="round"
:stroke-width="20"
@dblclick="emit('dblclick')"
/>
</svg>
</template>
+88
View File
@@ -0,0 +1,88 @@
<!--
[Experimental]
Think this component as the TextBox you that will see
in PowerPoint or Keynote. It will automatically resize
the font size based on it's content to fit them in.
Usage:
<AutoFitText modelValue="text"/>
or
<AutoFitText :max="80" :min="100" v-model="text"/>
-->
<script setup lang="ts">
import { useElementSize, useVModel } from '@vueuse/core'
import { computed, ref, watch } from 'vue'
const props = defineProps({
modelValue: {
default: '',
},
max: {
default: 100,
},
min: {
default: 30,
},
})
const emit = defineEmits<{
(e: any): void
}>()
const container = ref<HTMLDivElement>()
const inner = ref<HTMLDivElement>()
const size = ref(100)
const fontSize = computed(() => `${size.value}px`)
const value = useVModel(props, 'modelValue', emit)
const containerSize = useElementSize(container)
const innerSize = useElementSize(inner)
const wrapLen = ref(0)
const wrap = ref('nowrap')
watch([container, value, containerSize.width, innerSize.width], async () => {
if (!container.value || innerSize.width.value <= 0)
return
const ratio = containerSize.width.value / innerSize.width.value
if (Number.isNaN(ratio) || ratio <= 0)
return
let newSize = size.value * (containerSize.width.value / innerSize.width.value)
if (newSize < props.min) {
wrapLen.value = value.value.length
wrap.value = ''
}
else {
if (value.value.length < wrapLen.value)
wrap.value = 'nowrap'
}
newSize = Math.max(props.min, Math.min(props.max, newSize))
size.value = newSize
})
</script>
<template>
<div ref="container" class="slidev-auto-fit-text">
<div ref="inner" class="slidev-auto-fit-text-inner">
<slot>
{{ value }}
</slot>
</div>
</div>
</template>
<style scoped>
.slidev-auto-fit-text {
overflow: auto;
font-size: v-bind(fontSize);
white-space: v-bind(wrap);
}
.slidev-auto-fit-text-inner {
display: inline-block;
}
</style>
+143
View File
@@ -0,0 +1,143 @@
<!--
Line highlighting for code blocks.
(auto transformed, you don't need to use this component directly)
Usage:
```ts {1,3-5|2,4}
const your_code = 'here'
```
Learn more: https://sli.dev/guide/syntax.html#line-highlighting
-->
<script setup lang="ts">
import type { PropType } from 'vue'
import { useClipboard } from '@vueuse/core'
import { computed, onMounted, onUnmounted, ref, watchEffect } from 'vue'
import { CLASS_VCLICK_HIDDEN, CLICKS_MAX } from '../constants'
import { useSlideContext } from '../context'
import { configs } from '../env'
import { makeId, updateCodeHighlightRange } from '../logic/utils'
const props = defineProps({
ranges: {
type: Array as PropType<string[]>,
default: () => [],
},
finally: {
type: [String, Number],
default: 'last',
},
startLine: {
type: Number,
default: 1,
},
lines: {
type: Boolean,
default: configs.lineNumbers,
},
at: {
type: [String, Number],
default: '+1',
},
maxHeight: {
type: String,
default: undefined,
},
})
const { $clicksContext: clicks } = useSlideContext()
const el = ref<HTMLDivElement>()
const id = makeId()
onUnmounted(() => {
clicks!.unregister(id)
})
watchEffect(() => {
el.value?.classList.toggle('slidev-code-line-numbers', props.lines)
})
onMounted(() => {
if (!clicks || !props.ranges?.length)
return
const clicksInfo = clicks.calculateSince(props.at, props.ranges.length - 1)
clicks.register(id, clicksInfo)
const index = computed(() => clicksInfo ? Math.max(0, clicks.current - clicksInfo.start + 1) : CLICKS_MAX)
const finallyRange = computed(() => {
return props.finally === 'last' ? props.ranges.at(-1) : props.finally.toString()
})
watchEffect(() => {
if (!el.value)
return
let rangeStr = props.ranges[index.value] ?? finallyRange.value
const hide = rangeStr === 'hide'
el.value.classList.toggle(CLASS_VCLICK_HIDDEN, hide)
if (hide)
rangeStr = props.ranges[index.value + 1] ?? finallyRange.value
const pre = el.value.querySelector('.shiki')!
const lines = Array.from(pre.querySelectorAll('code > .line'))
const linesCount = lines.length
updateCodeHighlightRange(
rangeStr,
linesCount,
props.startLine,
no => [lines[no]],
)
// Scroll to the highlighted line if `maxHeight` is set
if (props.maxHeight) {
const highlightedEls = Array.from(pre.querySelectorAll('.line.highlighted')) as HTMLElement[]
const height = highlightedEls.reduce((acc, el) => el.offsetHeight + acc, 0)
if (height > el.value.offsetHeight) {
highlightedEls[0].scrollIntoView({ behavior: 'smooth', block: 'start' })
}
else if (highlightedEls.length > 0) {
const middleEl = highlightedEls[Math.round((highlightedEls.length - 1) / 2)]
middleEl.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}
})
})
const { copied, copy } = useClipboard()
function copyCode() {
const code = el.value?.querySelector('.slidev-code')?.textContent
if (code)
copy(code)
}
</script>
<template>
<div
ref="el"
class="slidev-code-wrapper relative group"
:class="{
'slidev-code-line-numbers': props.lines,
}"
:style="{
'max-height': props.maxHeight,
'overflow-y': props.maxHeight ? 'scroll' : undefined,
'--start': props.startLine,
}"
>
<slot />
<button
v-if="configs.codeCopy"
class="slidev-code-copy absolute top-0 right-0 transition opacity-0 group-hover:opacity-20 hover:!opacity-100"
:title="copied ? 'Copied' : 'Copy'" @click="copyCode()"
>
<ph-check-circle v-if="copied" class="p-2 w-8 h-8" />
<ph-clipboard v-else class="p-2 w-8 h-8" />
</button>
</div>
</template>
+119
View File
@@ -0,0 +1,119 @@
<!--
Line highlighting for KaTex blocks/
(auto transformed, you don't need to use this component directly)
Usage:
$$ {1|3|all}
\begin{array}{c}
\nabla \times \vec{\mathbf{B}} -\, \frac1c\, \frac{\partial\vec{\mathbf{E}}}{\partial t} &
= \frac{4\pi}{c}\vec{\mathbf{j}} \nabla \cdot \vec{\mathbf{E}} & = 4 \pi \rho \\
\nabla \times \vec{\mathbf{E}}\, +\, \frac1c\, \frac{\partial\vec{\mathbf{B}}}{\partial t} & = \vec{\mathbf{0}} \\
\nabla \cdot \vec{\mathbf{B}} & = 0
\end{array}
$$
Learn more: https://sli.dev/guide/syntax.html#latex-line-highlighting
-->
<script setup lang="ts">
import type { PropType } from 'vue'
import { parseRangeString } from '@slidev/parser/utils'
import { computed, onMounted, onUnmounted, ref, watchEffect } from 'vue'
import { CLASS_VCLICK_HIDDEN, CLASS_VCLICK_TARGET, CLICKS_MAX } from '../constants'
import { useSlideContext } from '../context'
import { makeId } from '../logic/utils'
const props = defineProps({
ranges: {
type: Array as PropType<string[]>,
default: () => [],
},
finally: {
type: [String, Number],
default: 'last',
},
startLine: {
type: Number,
default: 1,
},
at: {
type: [String, Number],
default: '+1',
},
})
const { $clicksContext: clicks } = useSlideContext()
const el = ref<HTMLDivElement>()
const id = makeId()
onUnmounted(() => {
clicks!.unregister(id)
})
onMounted(() => {
if (!clicks || !props.ranges?.length)
return
const clicksInfo = clicks.calculateSince(props.at, props.ranges.length - 1)
clicks.register(id, clicksInfo)
const index = computed(() => clicksInfo ? Math.max(0, clicks.current - clicksInfo.start + 1) : CLICKS_MAX)
const finallyRange = computed(() => {
return props.finally === 'last' ? props.ranges.at(-1) : props.finally.toString()
})
watchEffect(() => {
if (!el.value)
return
let rangeStr = props.ranges[index.value] ?? finallyRange.value
const hide = rangeStr === 'hide'
el.value.classList.toggle(CLASS_VCLICK_HIDDEN, hide)
if (hide)
rangeStr = props.ranges[index.value + 1] ?? finallyRange.value
// KaTeX equations have col-align-XXX as parent
const equationParents = el.value.querySelectorAll('.mtable > [class*=col-align]')
if (!equationParents)
return
// For each row we extract the individual equation rows
const equationRowsOfEachParent = Array.from(equationParents)
.map(item => Array.from(item.querySelectorAll(':scope > .vlist-t > .vlist-r > .vlist > span > .mord')))
// This list maps rows from different parents to line them up
const lines: Element[][] = []
for (const equationRowParent of equationRowsOfEachParent) {
equationRowParent.forEach((equationRow, idx) => {
if (!equationRow)
return
if (Array.isArray(lines[idx]))
lines[idx].push(equationRow)
else
lines[idx] = [equationRow]
})
}
const startLine = props.startLine
const highlights: number[] = parseRangeString(lines.length + startLine - 1, rangeStr)
lines.forEach((line, idx) => {
const highlighted = highlights.includes(idx + startLine)
line.forEach((node) => {
node.classList.toggle(CLASS_VCLICK_TARGET, true)
node.classList.toggle('highlighted', highlighted)
node.classList.toggle('dishonored', !highlighted)
})
})
})
})
</script>
<template>
<div ref="el" class="slidev-katex-wrapper">
<slot />
</div>
</template>
+8
View File
@@ -0,0 +1,8 @@
<script setup lang="ts">
import { isDark } from '../logic/dark'
</script>
<template>
<slot v-if="isDark" name="dark" />
<slot v-else name="light" />
</template>
+28
View File
@@ -0,0 +1,28 @@
<!--
Create a link in the presentation
Usage:
<Link :to="5" >Go to slide 5</Link>
<Link :to="5" title="Go to slide 5" />
-->
<script setup lang="ts">
import { useNav } from '../composables/useNav'
defineProps<{
to: number | string
title?: string
}>()
const { isPrintMode } = useNav()
</script>
<template>
<RouterLink v-if="!isPrintMode && title" :to="String(to)" @click="$event.target.blur()" v-html="title" />
<RouterLink v-else-if="!isPrintMode && !title" :to="String(to)" @click="$event.target.blur()">
<slot />
</RouterLink>
<a v-else-if="isPrintMode && title" :href="`#${to}`" v-html="title" />
<a v-else :href="`#${to}`"><slot /></a>
</template>
+82
View File
@@ -0,0 +1,82 @@
<!--
Mermaid
(auto transformed, you don't need to use this component directly)
Usage:
```mermaid
pie
"Dogs" : 386
"Cats" : 85
"Rats" : 15
```
-->
<script setup lang="ts">
import { getCurrentInstance, ref, watch, watchEffect } from 'vue'
import ShadowRoot from '../internals/ShadowRoot.vue'
import { isDark } from '../logic/dark'
import { renderMermaid } from '../modules/mermaid'
const props = defineProps<{
codeLz: string
scale?: number
theme?: string
}>()
const vm = getCurrentInstance()
const el = ref<ShadowRoot>()
const error = ref<string | null>(null)
const html = ref('')
watchEffect(async (onCleanup) => {
let disposed = false
onCleanup(() => {
disposed = true
})
error.value = null
try {
const svg = await renderMermaid(
props.codeLz || '',
{
theme: props.theme || (isDark.value ? 'dark' : undefined),
...vm!.attrs,
},
)
if (!disposed)
html.value = svg
}
catch (e) {
error.value = `${e}`
console.warn(e)
}
})
const actualHeight = ref<number>()
watch(html, () => {
actualHeight.value = undefined
})
watchEffect(() => {
const svgEl = el.value?.children?.[0] as SVGElement | undefined
if (svgEl && svgEl.hasAttribute('viewBox') && actualHeight.value == null) {
const v = Number.parseFloat(svgEl.getAttribute('viewBox')?.split(' ')[3] || '')
actualHeight.value = Number.isNaN(v) ? undefined : v
}
}, { flush: 'post' })
watchEffect(() => {
const svgEl = el.value?.children?.[0] as SVGElement | undefined
if (svgEl != null && props.scale != null && actualHeight.value != null) {
svgEl.setAttribute('height', `${actualHeight.value * props.scale}`)
svgEl.removeAttribute('width')
svgEl.removeAttribute('style')
}
}, { flush: 'post' })
</script>
<template>
<pre v-if="error" border="1 red rounded" class="pa-3 text-wrap">{{ error }}</pre>
<ShadowRoot v-else class="mermaid" :inner-html="html" @shadow="el = $event" />
</template>
+253
View File
@@ -0,0 +1,253 @@
<!--
Monaco Editor
(auto transformed, you don't need to use this component directly)
Usage:
```ts {monaco}
const your_code = 'here'
```
Learn more: https://sli.dev/guide/syntax.html#monaco-editor
-->
<script setup lang="ts">
import type { RawAtValue } from '@slidev/types'
import type * as monaco from 'monaco-editor'
import { debounce } from '@antfu/utils'
import { whenever } from '@vueuse/core'
import lz from 'lz-string'
import { computed, defineAsyncComponent, nextTick, onMounted, ref } from 'vue'
import { useNav } from '../composables/useNav'
import { useSlideContext } from '../context'
import { makeId } from '../logic/utils'
const props = withDefaults(defineProps<{
codeLz: string
diffLz?: string
lang?: string
readonly?: boolean
lineNumbers?: 'on' | 'off' | 'relative' | 'interval'
height?: number | string // Posible values: 'initial', 'auto', '100%', '200px', etc.
editorOptions?: monaco.editor.IEditorOptions
ata?: boolean
runnable?: boolean
writable?: string
autorun?: boolean | 'once'
showOutputAt?: RawAtValue
outputHeight?: string
highlightOutput?: boolean
runnerOptions?: Record<string, unknown>
}>(), {
codeLz: '',
lang: 'typescript',
readonly: false,
lineNumbers: 'off',
height: 'initial',
ata: true,
runnable: false,
autorun: true,
highlightOutput: true,
})
const CodeRunner = defineAsyncComponent(() => import('../internals/CodeRunner.vue').then(r => r.default))
const code = ref(lz.decompressFromBase64(props.codeLz).trimEnd())
const diff = props.diffLz && ref(lz.decompressFromBase64(props.diffLz).trimEnd())
const isWritable = computed(() => props.writable && !props.readonly && __DEV__)
const langMap: Record<string, string> = {
ts: 'typescript',
js: 'javascript',
}
const lang = langMap[props.lang] ?? props.lang
const extMap: Record<string, string> = {
typescript: 'mts',
javascript: 'mjs',
ts: 'mts',
js: 'mjs',
}
const ext = extMap[props.lang] ?? props.lang
const outer = ref<HTMLDivElement>()
const container = ref<HTMLDivElement>()
const contentHeight = ref(0)
const initialHeight = ref<number>()
const height = computed(() => {
if (props.height === 'auto')
return `${contentHeight.value}px`
if (props.height === 'initial')
return `${initialHeight.value}px`
return props.height
})
const loadTypes = ref<() => void>()
const { $page: thisSlideNo, $renderContext: renderContext } = useSlideContext()
const { currentSlideNo } = useNav()
const stopWatchTypesLoading = whenever(
() => Math.abs(thisSlideNo.value - currentSlideNo.value) <= 1 && loadTypes.value,
(loadTypes) => {
if (['slide', 'presenter'].includes(renderContext.value))
loadTypes()
else
setTimeout(loadTypes, 5000)
},
)
onMounted(async () => {
// Lazy load monaco, so it will be bundled in async chunk
const { default: setup } = await import('../setup/monaco')
const { ata, monaco, editorOptions } = await setup()
const model = monaco.editor.createModel(code.value, lang, monaco.Uri.parse(`file:///${makeId()}.${ext}`))
model.onDidChangeContent(() => code.value = model.getValue())
const commonOptions = {
automaticLayout: true,
readOnly: props.readonly,
lineNumbers: props.lineNumbers,
minimap: { enabled: false },
overviewRulerBorder: false,
overviewRulerLanes: 0,
padding: { top: 10, bottom: 10 },
lineNumbersMinChars: 3,
bracketPairColorization: { enabled: false },
tabSize: 2,
fontSize: 11.5,
fontFamily: 'var(--slidev-code-font-family)',
scrollBeyondLastLine: false,
useInlineViewWhenSpaceIsLimited: false,
...editorOptions,
...props.editorOptions,
} satisfies monaco.editor.IStandaloneEditorConstructionOptions & monaco.editor.IDiffEditorConstructionOptions
let editableEditor: monaco.editor.IStandaloneCodeEditor
if (diff) {
const diffModel = monaco.editor.createModel(diff.value, lang, monaco.Uri.parse(`file:///${makeId()}.${ext}`))
diffModel.onDidChangeContent(() => code.value = model.getValue())
const editor = monaco.editor.createDiffEditor(container.value!, {
renderOverviewRuler: false,
...commonOptions,
})
editor.setModel({
original: model,
modified: diffModel,
})
const originalEditor = editor.getOriginalEditor()
const modifiedEditor = editor.getModifiedEditor()
const onContentSizeChange = () => {
const newHeight = Math.max(originalEditor.getContentHeight(), modifiedEditor.getContentHeight()) + 4
initialHeight.value ??= newHeight
contentHeight.value = newHeight
nextTick(() => editor.layout())
}
originalEditor.onDidContentSizeChange(onContentSizeChange)
modifiedEditor.onDidContentSizeChange(onContentSizeChange)
editableEditor = modifiedEditor
}
else {
const editor = monaco.editor.create(container.value!, {
model,
lineDecorationsWidth: 0,
...commonOptions,
})
editor.onDidContentSizeChange((e) => {
const newHeight = e.contentHeight + 4
initialHeight.value ??= newHeight
contentHeight.value = newHeight
nextTick(() => editableEditor.layout())
})
editableEditor = editor
}
loadTypes.value = () => {
stopWatchTypesLoading()
import('#slidev/monaco-types')
if (props.ata) {
ata(editableEditor.getValue())
editableEditor.onDidChangeModelContent(debounce(1000, () => {
ata(editableEditor.getValue())
}))
}
}
const originalLayoutContentWidget = editableEditor.layoutContentWidget.bind(editableEditor)
editableEditor.layoutContentWidget = (widget: any) => {
originalLayoutContentWidget(widget)
const id = widget.getId()
if (id === 'editor.contrib.resizableContentHoverWidget') {
widget._resizableNode.domNode.style.transform = widget._positionPreference === 1
? /* ABOVE */ `translateY(calc(100% * (var(--slidev-slide-scale) - 1)))`
: /* BELOW */ `` // reset
}
}
editableEditor.addAction({
id: 'slidev-save',
label: 'Save',
keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS],
run: () => {
if (!isWritable.value || !import.meta.hot?.send) {
console.warn('[Slidev] this monaco editor is not writable, save action is ignored.')
return
}
import.meta.hot.send('slidev:monaco-write', {
file: props.writable!,
content: editableEditor.getValue(),
})
},
})
nextTick(() => monaco.editor.remeasureFonts())
setTimeout(() => monaco.editor.remeasureFonts(), 1000)
})
</script>
<template>
<div class="relative slidev-monaco-container">
<div ref="outer" class="relative slidev-monaco-container-inner" :style="{ height }">
<div ref="container" class="absolute inset-0.5" />
</div>
<CodeRunner
v-if="props.runnable"
v-model="code"
:lang="lang"
:autorun="props.autorun"
:show-output-at="props.showOutputAt"
:height="props.outputHeight"
:highlight-output="props.highlightOutput"
:runner-options="props.runnerOptions"
/>
</div>
</template>
<style>
div[widgetid='messageoverlay'] {
transform: translateY(calc(100% * (var(--slidev-slide-scale) - 1)));
}
.slidev-monaco-container {
position: relative;
margin: var(--slidev-code-margin);
line-height: var(--slidev-code-line-height);
border-radius: var(--slidev-code-radius);
background: var(--slidev-code-background);
}
.slidev-monaco-container-inner {
padding: var(--slidev-code-padding);
}
.slidev-monaco-container .monaco-editor {
--monaco-monospace-font: var(--slidev-code-font-family);
--vscode-editor-background: var(--slidev-code-background);
--vscode-editorGutter-background: var(--slidev-code-background);
}
/** Revert styles */
.slidev-monaco-container .monaco-editor a {
border-bottom: none;
}
.slidev-monaco-container .monaco-editor a:hover {
border-bottom: none;
}
</style>
+30
View File
@@ -0,0 +1,30 @@
<!--
PlantUML
(auto transformed, you don't need to use this component directly)
Usage:
```plantuml
@startuml
Alice -> Bob : Hello!
@enduml
```
-->
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(defineProps<{
code: string
server: string
scale?: number
alt?: string
}>(), {
alt: 'PlantUML diagram',
})
const uri = computed(() => `${props.server}/svg/${props.code}`)
</script>
<template>
<img :src="uri" :style="{ scale }" :alt="alt">
</template>
@@ -0,0 +1,8 @@
<template>
<span inline-flex items-center>
<span text-main>Powered by</span>
<a href="https://sli.dev" class="!border-none">
<img alt="Slidev logo" src="../assets/logo-title-horizontal.png" h-1.5em>
</a>
</span>
</template>
+54
View File
@@ -0,0 +1,54 @@
<script setup lang="ts">
import type { RenderContext } from '@slidev/types'
import { useElementVisibility } from '@vueuse/core'
import { computed, ref } from 'vue'
import { useNav } from '../composables/useNav'
import { useSlideContext } from '../context'
type Context = 'main' | 'visible' | 'print' | RenderContext
const props = defineProps<{
context: Context | Context[]
}>()
const { context } = props
const target = ref(null)
const targetVisible = useElementVisibility(target)
// When context has `visible`, we need to wrap the content with a div to track the visibility
const needsDomWrapper = Array.isArray(context) ? context.includes('visible') : context === 'visible'
const { $renderContext: currentContext } = useSlideContext()
const { isPrintMode } = useNav()
const shouldRender = computed(() => {
const anyContext = Array.isArray(context) ? context.some(contextMatch) : contextMatch(context)
const allConditions = Array.isArray(context) ? context.every(conditionsMatch) : conditionsMatch(context)
return anyContext && allConditions
})
function contextMatch(context: Context) {
if (context === currentContext?.value)
return true
if (context === 'main' && (currentContext?.value === 'slide' || currentContext?.value === 'presenter'))
return true
if (context === 'visible')
return true
if (context === 'print' && isPrintMode.value)
return true
return false
}
function conditionsMatch(context: Context) {
if (context === 'visible')
return targetVisible.value
return true
}
</script>
<template>
<div v-if="needsDomWrapper" ref="target">
<slot v-if="shouldRender" />
<slot v-else name="fallback" />
</div>
<slot v-else-if="shouldRender" />
<slot v-else name="fallback" />
</template>
+135
View File
@@ -0,0 +1,135 @@
<script setup lang="ts">
import type { KeyedTokensInfo } from 'shiki-magic-move/types'
import type { PropType } from 'vue'
import { sleep } from '@antfu/utils'
import lz from 'lz-string'
import { ShikiMagicMovePrecompiled } from 'shiki-magic-move/vue'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useNav } from '../composables/useNav'
import { CLICKS_MAX } from '../constants'
import { useSlideContext } from '../context'
import { configs } from '../env'
import { makeId, updateCodeHighlightRange } from '../logic/utils'
const props = defineProps({
at: {
type: [String, Number],
default: '+1',
},
stepsLz: {
type: String,
required: true,
},
stepRanges: {
type: Array as PropType<string[][]>,
required: true,
},
lines: {
type: Boolean,
default: configs.lineNumbers,
},
})
const steps = JSON.parse(lz.decompressFromBase64(props.stepsLz)) as KeyedTokensInfo[]
const { $clicksContext: clicks, $scale: scale, $zoom: zoom } = useSlideContext()
const { isPrintMode } = useNav()
const id = makeId()
const stepIndex = ref(0)
const container = ref<HTMLElement>()
// Normalized the ranges, to at least have one range
const ranges = computed(() => props.stepRanges.map(i => i.length ? i : ['all']))
onUnmounted(() => {
clicks?.unregister(id)
})
onMounted(() => {
if (!clicks)
return
if (ranges.value.length !== steps.length)
throw new Error('[slidev] The length of stepRanges does not match the length of steps, this is an internal error.')
const clickCounts = ranges.value.map(s => s.length).reduce((a, b) => a + b, 0)
const clickInfo = clicks.calculateSince(props.at, clickCounts - 1)
clicks.register(id, clickInfo)
watch(
() => clicks.current,
() => {
// Calculate the step and rangeStr based on the current click count
const clickCount = clickInfo ? clicks.current - clickInfo.start : CLICKS_MAX
let step = steps.length - 1
let currentClickSum = 0
let rangeStr = 'all'
for (let i = 0; i < ranges.value.length; i++) {
const current = ranges.value[i]
if (clickCount < currentClickSum + current.length - 1) {
step = i
rangeStr = current[clickCount - currentClickSum + 1]
break
}
currentClickSum += current.length || 1
}
nextTick(async () => {
stepIndex.value = step
await sleep(0)
const pre = container.value?.querySelector('.shiki') as HTMLElement
if (!pre)
return
const children = (Array.from(pre.children) as HTMLElement[])
.slice(1) // Remove the first anchor
.filter(i => !i.className.includes('shiki-magic-move-leave')) // Filter the leaving elements
// Group to lines between `<br>`
const lines = children.reduce((acc, el) => {
if (el.tagName === 'BR')
acc.push([])
else
acc[acc.length - 1].push(el)
return acc
}, [[]] as HTMLElement[][])
// Update highlight range
updateCodeHighlightRange(
rangeStr,
lines.length,
1,
no => lines[no],
)
})
},
{ immediate: true },
)
})
</script>
<template>
<div ref="container" class="slidev-code-wrapper slidev-code-magic-move relative">
<ShikiMagicMovePrecompiled
class="slidev-code relative shiki overflow-visible"
:steps="steps"
:step="stepIndex"
:animate="!isPrintMode"
:options="{
globalScale: scale * zoom,
// TODO: make this configurable later
duration: 800,
stagger: 1,
}"
/>
</div>
</template>
<style>
.slidev-code-magic-move .shiki-magic-move-enter-from,
.slidev-code-magic-move .shiki-magic-move-leave-to {
opacity: 0;
}
</style>
+9
View File
@@ -0,0 +1,9 @@
<script setup lang="ts">
import { useSlideContext } from '../context'
const { $page } = useSlideContext()
</script>
<template>
<span>{{ $page }}</span>
</template>
+9
View File
@@ -0,0 +1,9 @@
<script setup lang="ts">
import { useSlideContext } from '../context'
const { $nav } = useSlideContext()
</script>
<template>
<span>{{ $nav.total }}</span>
</template>
+74
View File
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { and } from '@vueuse/math'
import { computed, onMounted, ref, watch } from 'vue'
import { useNav } from '../composables/useNav'
import { useSlideContext } from '../context'
import { resolvedClickMap } from '../modules/v-click'
const props = defineProps<{
autoplay?: boolean | 'once'
autoreset?: 'slide' | 'click'
poster?: string
printPoster?: string
timestamp?: string | number
printTimestamp?: string | number | 'last'
controls?: boolean
}>()
const printPoster = computed(() => props.printPoster ?? props.poster)
const printTimestamp = computed(() => props.printTimestamp ?? props.timestamp ?? 0)
const { $slidev, $renderContext, $route } = useSlideContext()
const { isPrintMode } = useNav()
const noPlay = computed(() => isPrintMode.value || !['slide', 'presenter'].includes($renderContext.value))
const video = ref<HTMLMediaElement>()
const played = ref(false)
onMounted(() => {
if (noPlay.value)
return
const timestamp = +(props.timestamp ?? 0)
video.value!.currentTime = timestamp
const matchRoute = computed(() => !!$route && $route.no === $slidev?.nav.currentSlideNo)
const matchClick = computed(() => !!video.value && (resolvedClickMap.get(video.value)?.isShown?.value ?? true))
const matchRouteAndClick = and(matchRoute, matchClick)
watch(matchRouteAndClick, () => {
if (matchRouteAndClick.value) {
if (props.autoplay === true || (props.autoplay === 'once' && !played.value))
video.value!.play()
}
else {
video.value!.pause()
if (props.autoreset === 'click' || (props.autoreset === 'slide' && !matchRoute.value))
video.value!.currentTime = timestamp
}
}, { immediate: true })
})
function onLoadedMetadata(ev: Event) {
// The video may be loaded before component mounted
const element = ev.target as HTMLMediaElement
if (noPlay.value && (!printPoster.value || props.printTimestamp)) {
element.currentTime = printTimestamp.value === 'last'
? element.duration
: +printTimestamp.value
}
}
</script>
<template>
<video
ref="video"
:poster="noPlay ? printPoster : props.poster"
:controls="!noPlay && props.controls"
@play="played = true"
@loadedmetadata="onLoadedMetadata"
>
<slot />
</video>
</template>
+100
View File
@@ -0,0 +1,100 @@
<!--
Table Of content
`mode` can be either 'all', 'onlyCurrentTree' or 'onlySiblings'
Usage:
<Toc columns='2' maxDepth='3' mode='onlySiblings'/>
-->
<script setup lang='ts'>
import type { TocItem } from '@slidev/types'
import { computed } from 'vue'
import { useSlideContext } from '../context'
import TocList from './TocList.vue'
const props = withDefaults(
defineProps<{
columns?: string | number
listClass?: string | string[]
start?: string | number
listStyle?: string | string[]
maxDepth?: string | number
minDepth?: string | number
mode?: 'all' | 'onlyCurrentTree' | 'onlySiblings'
}>(),
{
columns: 1,
listClass: '',
start: 1,
listStyle: '',
maxDepth: Number.POSITIVE_INFINITY,
minDepth: 1,
mode: 'all',
},
)
const { $slidev } = useSlideContext()
function filterTreeDepth(tree: TocItem[], level = 1): TocItem[] {
if (level > Number(props.maxDepth)) {
return []
}
else if (level < Number(props.minDepth)) {
const activeItem = tree.find((item: TocItem) => item.active || item.activeParent)
return activeItem ? filterTreeDepth(activeItem.children, level + 1) : []
}
return tree
.map((item: TocItem) => ({
...item,
children: filterTreeDepth(item.children, level + 1),
}))
}
function filterOnlyCurrentTree(tree: TocItem[]): TocItem[] {
return tree
.filter(
(item: TocItem) => item.active || item.activeParent || item.hasActiveParent,
)
.map((item: TocItem) => ({
...item,
children: filterOnlyCurrentTree(item.children),
}))
}
function filterOnlySiblings(tree: TocItem[]): TocItem[] {
const treehasActiveItem = tree.some(
(item: TocItem) => item.active || item.activeParent || item.hasActiveParent,
)
return tree
.filter(() => treehasActiveItem)
.map((item: TocItem) => ({
...item,
children: filterOnlySiblings(item.children),
}))
}
const toc = computed(() => {
const tree = $slidev?.nav.tocTree
if (!tree)
return []
let tocTree = filterTreeDepth(tree)
if (props.mode === 'onlyCurrentTree')
tocTree = filterOnlyCurrentTree(tocTree)
else if (props.mode === 'onlySiblings')
tocTree = filterOnlySiblings(tocTree)
return tocTree
})
</script>
<template>
<div class="slidev-toc" :style="`column-count:${columns}`">
<TocList
:level="1"
:start="start"
:list-style="listStyle"
:list="toc"
:list-class="listClass"
/>
</div>
</template>
+75
View File
@@ -0,0 +1,75 @@
<!--
TOC list
(used by Toc component, you don't need to use this component directly)
Usage:
<TocList :list="list"/>
-->
<script setup lang="ts">
import type { TocItem } from '@slidev/types'
import TitleRenderer from '#slidev/title-renderer'
import { toArray } from '@antfu/utils'
import { computed } from 'vue'
import { useNav } from '../composables/useNav'
const props = withDefaults(defineProps<{
level: number
start?: string | number
listStyle?: string | string[]
list: TocItem[]
listClass?: string | string[]
}>(), { level: 1 })
const { isPresenter } = useNav()
const classes = computed(() => {
return [
...toArray(props.listClass || []),
'slidev-toc-list',
`slidev-toc-list-level-${props.level}`,
]
})
const styles = computed(() => {
return [
...toArray(props.listStyle || []),
]
})
</script>
<template>
<ol
v-if="list && list.length > 0"
:class="classes"
:start="level === 1 ? start : undefined"
:style="styles.length >= level ? `list-style:${styles[level - 1]}` : undefined"
>
<li
v-for="item of list"
:key="item.path" class="slidev-toc-item"
:class="[{ 'slidev-toc-item-active': item.active }, { 'slidev-toc-item-parent-active': item.activeParent }]"
>
<Link :to="isPresenter ? `/presenter${item.path}` : item.path">
<TitleRenderer :no="item.no" />
</Link>
<TocList
v-if="item.children.length > 0"
:level="level + 1"
:list-style="listStyle"
:list="item.children"
:list-class="listClass"
/>
</li>
</ol>
</template>
<style>
.slidev-layout .slidev-toc-item p {
margin: 0;
}
.slidev-layout .slidev-toc-item div,
.slidev-layout .slidev-toc-item div p {
display: initial;
}
</style>
+35
View File
@@ -0,0 +1,35 @@
<!--
Apply scaling or transforming to elements.
Usage:
<Transform :scale="0.5">
<YourElements />
</Transform>
-->
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
scale?: number | string
origin?: string
}>()
const style = computed(() => {
const transforms = []
if (props.scale != null)
transforms.push(`scale(${props.scale || 1})`)
return {
'transform': transforms.join(' '),
'transform-origin': props.origin || 'top left',
}
})
</script>
<template>
<div :style="style">
<slot />
</div>
</template>
+71
View File
@@ -0,0 +1,71 @@
<!--
A simple wrapper for embedded Tweet
Usage:
<Tweet id="20" />
-->
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { isDark } from '../logic/dark'
const props = defineProps<{
id: string | number
scale?: string | number
conversation?: string
cards?: 'hidden' | 'visible'
}>()
const tweet = ref<HTMLElement | null>()
const loaded = ref(false)
const tweetNotFound = ref(false)
async function create(retries = 10) {
// @ts-expect-error global
if (!window.twttr?.widgets?.createTweet) {
if (retries <= 0)
return console.error('Failed to load Twitter widget after 10 retries.')
setTimeout(() => create(retries - 1), 1000)
return
}
// @ts-expect-error global
const element = await window.twttr.widgets.createTweet(
props.id.toString(),
tweet.value,
{
theme: isDark.value ? 'dark' : 'light',
conversation: props.conversation || 'none',
cards: props.cards,
},
)
loaded.value = true
if (element === undefined)
tweetNotFound.value = true
}
onMounted(() => {
create()
})
</script>
<template>
<Transform :scale="scale || 1">
<div ref="tweet" class="tweet slidev-tweet">
<div v-if="!loaded || tweetNotFound" class="w-30 h-30 my-10px bg-gray-400 bg-opacity-10 rounded-lg flex opacity-50">
<div class="m-auto animate-pulse text-4xl">
<div class="i-carbon:logo-twitter" />
<span v-if="tweetNotFound">Could not load tweet with id="{{ props.id }}"</span>
</div>
</div>
</div>
</Transform>
</template>
<style>
.slidev-tweet iframe {
border-radius: 12px;
overflow: hidden;
}
</style>
+28
View File
@@ -0,0 +1,28 @@
/**
* <v-after/> click animations component
*
* Learn more: https://sli.dev/guide/animations.html#click-animation
*/
import type { Directive, VNode } from 'vue'
import { toArray } from '@antfu/utils'
import { defineComponent, h, resolveDirective, withDirectives } from 'vue'
export default defineComponent({
render() {
const after = resolveDirective('after')!
function applyDirective(node: VNode, directive: Directive) {
return withDirectives(node, [[directive]])
}
let defaults = this.$slots.default?.()
if (!defaults)
return
defaults = toArray(defaults)
return defaults.map(i => applyDirective(h(i), after))
},
})
+51
View File
@@ -0,0 +1,51 @@
/**
* <v-click/> click animations component
*
* Learn more: https://sli.dev/guide/animations.html#click-animation
*/
import type { PropType, VNode } from 'vue'
import { defineComponent, h, Text } from 'vue'
import { CLICKS_MAX } from '../constants'
import VClicks from './VClicks'
export default defineComponent({
props: {
at: {
type: [Number, String],
default: '+1',
},
hide: {
type: Boolean,
default: false,
},
fade: {
type: Boolean,
default: false,
},
wrapText: {
type: Function as PropType<(text: VNode) => VNode>,
default: (text: VNode) => h('span', text),
},
},
render() {
return h(
VClicks,
{
every: CLICKS_MAX,
at: this.at,
hide: this.hide,
fade: this.fade,
handleSpecialElements: false,
},
{
default: () =>
this.$slots.default?.().map(v =>
v.type === Text
? this.wrapText(v)
: v,
),
},
)
},
})
+34
View File
@@ -0,0 +1,34 @@
<script setup lang="ts">
import { Fragment, onMounted, onUnmounted } from 'vue'
import { useSlideContext } from '../context'
import { makeId } from '../logic/utils'
const props = defineProps({
size: {
type: [String, Number],
default: 1,
},
})
const { $clicksContext: clicks } = useSlideContext()
const id = makeId()
let delta = +props.size
if (Number.isNaN(delta)) {
console.warn(`[slidev] Invalid size for VClickGap: ${props.size}`)
delta = 1
}
onMounted(() => {
const max = clicks.currentOffset + delta - 1
clicks.register(id, { max, delta })
})
onUnmounted(() => {
clicks.unregister(id)
})
</script>
<template>
<Fragment />
</template>
+149
View File
@@ -0,0 +1,149 @@
/**
* <v-clicks/> click animations component
*
* Learn more: https://sli.dev/guide/animations.html#click-animation
*/
import type { VNode, VNodeArrayChildren } from 'vue'
import { toArray } from '@antfu/utils'
import { Comment, createVNode, defineComponent, h, isVNode, resolveDirective, withDirectives } from 'vue'
import { normalizeSingleAtValue } from '../composables/useClicks'
import VClickGap from './VClickGap.vue'
const listTags = ['ul', 'ol']
export default defineComponent({
props: {
depth: {
type: [Number, String],
default: 1,
},
every: {
type: [Number, String],
default: 1,
},
at: {
type: [Number, String],
default: '+1',
},
hide: {
type: Boolean,
default: false,
},
fade: {
type: Boolean,
default: false,
},
handleSpecialElements: {
type: Boolean,
default: true,
},
},
render() {
const every = +this.every
const at = normalizeSingleAtValue(this.at)
const isRelative = typeof at === 'string'
if (!at) {
console.warn('[slidev] Invalid at prop for v-clicks component:', at)
return
}
const click = resolveDirective('click')!
const applyDirective = (node: VNode, value: number | string) => {
return withDirectives(node, [[
click,
value,
'',
{
hide: this.hide,
fade: this.fade,
},
]])
}
const openAllTopLevelSlots = <T extends VNodeArrayChildren | VNode[]>(children: T): T => {
return children.flatMap((i) => {
if (isVNode(i) && typeof i.type === 'symbol' && Array.isArray(i.children))
return openAllTopLevelSlots(i.children)
else
return [i]
}) as T
}
let elements = this.$slots.default?.()
if (!elements)
return
elements = openAllTopLevelSlots(toArray(elements))
const mapSubList = (children: VNodeArrayChildren, depth = 1): VNodeArrayChildren => {
const vNodes = openAllTopLevelSlots(children).map((i) => {
if (!isVNode(i))
return i
if (listTags.includes(i.type as string) && Array.isArray(i.children)) {
// eslint-disable-next-line ts/no-use-before-define
const vNodes = mapChildren(i.children, depth + 1)
return h(i, {}, vNodes)
}
return h(i)
})
return vNodes
}
let globalIdx = 1
let execIdx = 0
const mapChildren = (children: VNodeArrayChildren, depth = 1): VNodeArrayChildren => {
const vNodes = openAllTopLevelSlots(children).map((i) => {
if (!isVNode(i) || i.type === Comment)
return i
const thisShowIdx = +at + Math.ceil(globalIdx++ / every) - 1
let vNode
if (depth < +this.depth && Array.isArray(i.children))
vNode = h(i, {}, mapSubList(i.children, depth))
else
vNode = h(i)
const delta = thisShowIdx - execIdx
execIdx = thisShowIdx
return applyDirective(
vNode,
isRelative
? delta >= 0 ? `+${delta}` : `${delta}`
: thisShowIdx,
)
})
return vNodes
}
const lastGap = () => createVNode(VClickGap, {
size: +at + Math.ceil((globalIdx - 1) / every) - 1 - execIdx,
})
if (this.handleSpecialElements) {
// handle ul, ol list
if (elements.length === 1 && listTags.includes(elements[0].type as string) && Array.isArray(elements[0].children))
return h(elements[0], {}, [...mapChildren(elements[0].children), lastGap()])
if (elements.length === 1 && elements[0].type as string === 'table') {
const tableNode = elements[0]
if (Array.isArray(tableNode.children)) {
return h(tableNode, {}, tableNode.children.map((i) => {
if (!isVNode(i))
return i
else if (i.type === 'tbody' && Array.isArray(i.children))
return h(i, {}, [...mapChildren(i.children), lastGap()])
else
return h(i)
}))
}
}
}
return [...mapChildren(elements), lastGap()]
},
})
+27
View File
@@ -0,0 +1,27 @@
<script setup lang="ts">
import type { DragElementMarkdownSource } from '../composables/useDragElements'
import { onMounted, onUnmounted } from 'vue'
import { useDragElement } from '../composables/useDragElements'
const props = defineProps<{
pos?: string
markdownSource?: DragElementMarkdownSource
}>()
const { dragId, container, containerStyle, mounted, unmounted, startDragging } = useDragElement(null, props.pos, props.markdownSource)
onMounted(mounted)
onUnmounted(unmounted)
</script>
<template>
<div
ref="container"
:data-drag-id="dragId"
:style="containerStyle"
class="p-1"
@dblclick="startDragging"
>
<slot />
</div>
</template>
+36
View File
@@ -0,0 +1,36 @@
<script setup lang="ts">
import type { DragElementMarkdownSource } from '../composables/useDragElements'
import { computed, onMounted, onUnmounted } from 'vue'
import { useDragElement } from '../composables/useDragElements'
import Arrow from './Arrow.vue'
const props = defineProps<{
pos?: string
markdownSource?: DragElementMarkdownSource
width?: number | string
color?: string
twoWay?: boolean
}>()
const { dragId, mounted, unmounted, startDragging, stopDragging, x0, y0, width, height } = useDragElement(null, props.pos ?? '100,100,300,300', props.markdownSource, true)
onMounted(mounted)
onUnmounted(unmounted)
const x1 = computed(() => x0.value - width.value / 2)
const y1 = computed(() => y0.value - height.value / 2)
const x2 = computed(() => x0.value + width.value / 2)
const y2 = computed(() => y0.value + height.value / 2)
</script>
<template>
<Arrow
:x1 :y1 :x2 :y2
:data-drag-id="dragId"
:width="props.width"
:color="props.color"
:two-way="props.twoWay"
@dblclick="startDragging"
@click-outside="stopDragging"
/>
</template>
+114
View File
@@ -0,0 +1,114 @@
import type { PropType, Ref, Slot, TransitionGroupProps, VNode } from 'vue'
import { recomputeAllPoppers } from 'floating-vue'
import { defineComponent, h, onMounted, onUnmounted, ref, TransitionGroup, watchEffect } from 'vue'
import { CLASS_VCLICK_CURRENT, CLASS_VCLICK_DISPLAY_NONE, CLASS_VCLICK_PRIOR, CLASS_VCLICK_TARGET, CLICKS_MAX } from '../constants'
import { useSlideContext } from '../context'
import { skipTransition } from '../logic/hmr'
import { resolveTransition } from '../logic/transition'
import { makeId } from '../logic/utils'
export default defineComponent({
props: {
at: {
type: [Number, String],
default: '+1',
},
/**
* unmount or hide the content when it's not visible
*/
unmount: {
type: Boolean,
default: false,
},
transition: {
type: [Object, String, Boolean] as PropType<TransitionGroupProps | string | false>,
default: false,
},
tag: {
type: String,
default: 'div',
},
childTag: {
type: String,
default: 'div',
},
},
setup({ at, unmount, transition, tag, childTag }, { slots }) {
const slotEntries = Object.entries(slots).sort((a, b) => -a[0].split('-')[0] + +b[0].split('-')[0])
const contents: [start: number, end: number, slot: Slot<any> | undefined, elRef: Ref<HTMLElement | undefined>][] = []
let lastStart: number | undefined
for (const [range, slot] of slotEntries) {
const elRef = ref<HTMLElement>()
if (Number.isFinite(+range)) {
contents.push([+range, lastStart ?? +range + 1, slot, elRef])
lastStart = +range
}
else {
const [start, end] = range.split('-').map(Number)
if (!Number.isFinite(start) || !Number.isFinite(end))
throw new Error(`Invalid range for v-switch: ${range}`)
contents.push([start, end, slot, elRef])
lastStart = start
}
}
const size = Math.max(...contents.map(c => c[1])) - 1
const id = makeId()
const offset = ref(0)
const { $clicksContext: clicks, $nav: nav } = useSlideContext()
onMounted(() => {
const clicksInfo = clicks.calculateSince(at, size)
if (!clicksInfo) {
offset.value = CLICKS_MAX
return
}
clicks.register(id, clicksInfo)
watchEffect(() => {
offset.value = clicksInfo.currentOffset.value + 1
})
})
onUnmounted(() => {
clicks.unregister(id)
})
function onAfterLeave() {
// Refer to SlidesShow.vue
skipTransition.value = true
recomputeAllPoppers()
}
const transitionProps = transition && {
...resolveTransition(transition, nav.value.navDirection < 0),
tag,
onAfterLeave,
}
return () => {
const children: VNode[] = []
for (let i = contents.length - 1; i >= 0; i--) {
const [start, end, slot, ref] = contents[i]
const visible = start <= offset.value && offset.value < end
if (unmount && !visible)
continue
children.push(h(childTag, {
'key': i,
ref,
'class': [
CLASS_VCLICK_TARGET,
offset.value === start && CLASS_VCLICK_CURRENT,
offset.value >= end && CLASS_VCLICK_PRIOR,
!visible && CLASS_VCLICK_DISPLAY_NONE,
].filter(Boolean),
'data-slidev-clicks-start': start,
'data-slidev-clicks-end': end,
}, slot?.()))
}
return transitionProps
? h(TransitionGroup, skipTransition.value ? {} : transitionProps, () => children)
: h(tag, children)
}
},
})
+28
View File
@@ -0,0 +1,28 @@
<!--
A simple wrapper for embeded YouTube videos
Usage:
<Youtube id="luoMHjh-XcQ" />
-->
<script setup lang="ts">
defineProps<{
id: string
width?: number
height?: number
}>()
</script>
<template>
<iframe
class="youtube"
:width="width"
:height="height"
:src="`https://www.youtube.com/embed/${id}`"
title="YouTube"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
/>
</template>
+172
View File
@@ -0,0 +1,172 @@
import type { ClicksContext, NormalizedRangeClickValue, NormalizedSingleClickValue, RawAtValue, RawSingleAtValue, SlideRoute } from '@slidev/types'
import type { MaybeRefOrGetter, Ref } from 'vue'
import { clamp, sum } from '@antfu/utils'
import { computed, isReadonly, onMounted, onUnmounted, ref, shallowReactive, toValue } from 'vue'
export function normalizeSingleAtValue(at: RawSingleAtValue): NormalizedSingleClickValue {
if (at === false || at === 'false')
return null
if (at == null || at === true || at === 'true')
return '+1'
if (typeof at === 'string' && '+-'.includes(at[0]))
return at
const v = +at
if (Number.isNaN(v)) {
console.error(`Invalid "at" prop value: ${at}`)
return null
}
return v
}
export function normalizeRangeAtValue(at: RawAtValue): NormalizedRangeClickValue {
if (Array.isArray(at))
return [normalizeSingleAtValue(at[0])!, normalizeSingleAtValue(at[1])!]
return null
}
export function createClicksContextBase(
current: Ref<number>,
clicksStart = 0,
clicksTotalOverrides?: number,
): ClicksContext {
const isMounted = ref(false)
let relativeSizeMap: ClicksContext['relativeSizeMap'] = new Map()
let maxMap: ClicksContext['maxMap'] = new Map()
const context: ClicksContext = {
get current() {
return clamp(+current.value, clicksStart, context.total)
},
set current(value) {
current.value = isMounted.value
? clamp(value, clicksStart, context.total)
: value /* context.total is not available yet */
},
clicksStart,
get relativeSizeMap() {
if (__DEV__ && isMounted.value)
console.warn('[slidev] ClicksContext: Unexpected access to relativeSizeMap after mounted')
return relativeSizeMap
},
get maxMap() {
return maxMap
},
get isMounted() {
return isMounted.value
},
setup() {
onMounted(() => {
isMounted.value = true
// Convert maxMap to reactive
maxMap = shallowReactive(maxMap)
// Make sure the query is not greater than the total
if (!isReadonly(current))
context.current = current.value
})
onUnmounted(() => {
isMounted.value = false
relativeSizeMap = new Map()
maxMap = new Map()
})
},
calculateSince(rawAt, size = 1) {
const at = normalizeSingleAtValue(rawAt)
if (at == null)
return null
let start: number, max: number, delta: number
if (typeof at === 'string') {
const offset = context.currentOffset
const value = +at
start = offset + value
max = offset + value + size - 1
delta = value + size - 1
}
else {
start = at
max = at + size - 1
delta = 0
}
return {
start,
end: +Number.POSITIVE_INFINITY,
max,
delta,
currentOffset: computed(() => context.current - start),
isCurrent: computed(() => context.current === start),
isActive: computed(() => context.current >= start),
}
},
calculateRange(rawAt) {
const at = normalizeRangeAtValue(rawAt)
if (at == null)
return null
const [a, b] = at
let start: number, end: number, delta: number
if (typeof a === 'string') {
const offset = context.currentOffset
start = offset + +a
delta = +a
}
else {
start = a
delta = 0
}
if (typeof b === 'string') {
end = start + +b
delta += +b
}
else {
end = b
}
return {
start,
end,
max: end,
delta,
currentOffset: computed(() => context.current - start),
isCurrent: computed(() => context.current === start),
isActive: computed(() => start <= context.current && context.current < end),
}
},
calculate(at) {
if (Array.isArray(at))
return context.calculateRange(at)
return context.calculateSince(at)
},
register(el, info) {
if (!info)
return
if (__DEV__ && isMounted.value)
console.warn('[slidev] ClicksContext: Unexpected register after mounted')
const { delta, max } = info
relativeSizeMap.set(el, delta)
maxMap.set(el, max)
},
unregister(el) {
relativeSizeMap.delete(el)
maxMap.delete(el)
},
get currentOffset() {
return sum(...relativeSizeMap.values())
},
get total() {
return clicksTotalOverrides
?? (isMounted.value
? Math.max(0, ...maxMap.values())
: 0 /* fallback value */
)
},
}
return context
}
export function createFixedClicks(
route?: SlideRoute | undefined,
currentInit: MaybeRefOrGetter<number> = 0,
): ClicksContext {
const clicksStart = route?.meta.slide?.frontmatter.clicksStart ?? 0
return createClicksContextBase(
computed(() => Math.max(toValue(currentInit), clicksStart)),
clicksStart,
route?.meta?.clicks,
)
}
+9
View File
@@ -0,0 +1,9 @@
import { isColorSchemaConfigured, isDark, toggleDark } from '../logic/dark'
export function useDarkMode() {
return {
isColorSchemaConfigured,
isDark,
toggleDark,
}
}
@@ -0,0 +1,300 @@
import type { SlidePatch } from '@slidev/types'
import type { CSSProperties, DirectiveBinding, InjectionKey, WatchStopHandle } from 'vue'
import { debounce, ensureSuffix } from '@antfu/utils'
import { injectLocal, onClickOutside, useWindowFocus } from '@vueuse/core'
import { computed, ref, watch } from 'vue'
import { injectionCurrentPage, injectionFrontmatter, injectionRenderContext, injectionSlideElement, injectionSlideScale, injectionSlideZoom } from '../constants'
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'
export type DragElementDataSource = 'frontmatter' | 'prop' | 'directive'
/**
* Markdown source position, injected by markdown-it plugin
*/
export type DragElementMarkdownSource = [startLine: number, endLine: number, index: number]
export type DragElementsUpdater = (id: string, posStr: string, type: DragElementDataSource, markdownSource?: DragElementMarkdownSource) => void
const map: Record<number, DragElementsUpdater> = {}
export function useDragElementsUpdater(no: number) {
if (!(__DEV__ && __SLIDEV_FEATURE_EDITOR__))
return () => {}
if (map[no])
return map[no]
const { info, update } = useDynamicSlideInfo(no)
let newPatch: SlidePatch | null = null
async function save() {
if (newPatch) {
await update({
...newPatch,
skipHmr: true,
})
newPatch = null
}
}
const debouncedSave = debounce(500, save)
return map[no] = (id, posStr, type, markdownSource) => {
if (!info.value)
return
if (type === 'frontmatter') {
const frontmatter = info.value.frontmatter
frontmatter.dragPos ||= {}
if (frontmatter.dragPos[id] === posStr)
return
frontmatter.dragPos[id] = posStr
newPatch = {
frontmatter: {
dragPos: frontmatter.dragPos,
},
}
}
else {
if (!markdownSource)
throw new Error(`[Slidev] VDrag Element ${id} is missing markdown source`)
const [startLine, endLine, idx] = markdownSource
const lines = info.value.content.split(/\r?\n/g)
let section = lines.slice(startLine, endLine).join('\n')
let replaced = false
section = type === 'prop'
// eslint-disable-next-line regexp/no-super-linear-backtracking
? section.replace(/<(v-?drag-?\w*)(.*?)(\/)?>/gi, (full, tag, attrs, selfClose = '', index) => {
if (index === idx) {
replaced = true
const posMatch = attrs.match(/pos=".*?"/)
if (!posMatch)
return `<${tag}${ensureSuffix(' ', attrs)}pos="${posStr}"${selfClose}>`
const start = posMatch.index
const end = start + posMatch[0].length
return `<${tag}${attrs.slice(0, start)}pos="${posStr}"${attrs.slice(end)}${selfClose}>`
}
return full
})
: section.replace(/(?<![</\w])v-drag(?:=".*?")?/gi, (full, index) => {
if (index === idx) {
replaced = true
return `v-drag="${posStr}"`
}
return full
})
if (!replaced)
throw new Error(`[Slidev] VDrag Element ${id} is not found in the markdown source`)
lines.splice(
startLine,
endLine - startLine,
section,
)
const newContent = lines.join('\n')
if (info.value.content === newContent)
return
newPatch = {
content: newContent,
}
info.value = {
...info.value,
content: newContent,
}
}
debouncedSave()
}
}
export function useDragElement(directive: DirectiveBinding | null, posRaw?: string | number | number[], markdownSource?: DragElementMarkdownSource, isArrow = false) {
function inject<T>(key: InjectionKey<T> | string): T | undefined {
return directive
? directiveInject(directive, key)
: injectLocal(key)
}
const renderContext = inject(injectionRenderContext)!
const frontmatter = inject(injectionFrontmatter) ?? {}
const page = inject(injectionCurrentPage)!
const updater = computed(() => useDragElementsUpdater(page.value))
const scale = inject(injectionSlideScale) ?? ref(1)
const zoom = inject(injectionSlideZoom) ?? ref(1)
const { left: slideLeft, top: slideTop, stop: stopWatchBounds } = useSlideBounds(inject(injectionSlideElement) ?? ref())
const { isPrintMode } = useNav()
const enabled = ['slide', 'presenter'].includes(renderContext.value) && !isPrintMode.value
let dataSource: DragElementDataSource = directive ? 'directive' : 'prop'
let dragId: string = makeId()
let pos: number[] | undefined
if (Array.isArray(posRaw)) {
pos = posRaw
}
else if (typeof posRaw === 'string' && posRaw.includes(',')) {
pos = posRaw.split(',').map(Number)
}
else if (posRaw != null) {
dataSource = 'frontmatter'
dragId = `${posRaw}`
posRaw = frontmatter?.dragPos?.[dragId]
pos = (posRaw as string)?.split(',').map(Number)
}
if (dataSource !== 'frontmatter' && !markdownSource)
throw new Error('[Slidev] Can not identify the source position of the v-drag element, please provide an explicit `id` prop.')
const watchStopHandles: WatchStopHandle[] = [stopWatchBounds]
const autoHeight = !isArrow && posRaw != null && !Number.isFinite(pos?.[3])
pos ??= [Number.NaN, Number.NaN, 0]
const width = ref(pos[2])
const x0 = ref(pos[0] + pos[2] / 2)
const rotate = ref(isArrow ? 0 : (pos[4] ?? 0))
const rotateRad = computed(() => rotate.value * Math.PI / 180)
const rotateSin = computed(() => Math.sin(rotateRad.value))
const rotateCos = computed(() => Math.cos(rotateRad.value))
const container = ref<HTMLElement>()
const bounds = ref({ left: 0, top: 0, width: 0, height: 0 })
const actualHeight = ref(0)
function updateBounds() {
if (!container.value)
return
const rect = container.value.getBoundingClientRect()
bounds.value = {
left: rect.left / zoom.value,
top: rect.top / zoom.value,
width: rect.width / zoom.value,
height: rect.height / zoom.value,
}
actualHeight.value = ((bounds.value.width + bounds.value.height) / scale.value / (Math.abs(rotateSin.value) + Math.abs(rotateCos.value)) - width.value)
}
watchStopHandles.push(watch(width, updateBounds, { flush: 'post' }))
const configuredHeight = ref(pos[3] ?? 0)
const height = autoHeight
? computed({
get: () => (autoHeight ? actualHeight.value : configuredHeight.value) || 0,
set: v => !autoHeight && (configuredHeight.value = v),
})
: configuredHeight
const configuredY0 = autoHeight ? ref(pos[1]) : ref(pos[1] + pos[3] / 2)
const y0 = autoHeight
? computed({
get: () => configuredY0.value + height.value / 2,
set: v => configuredY0.value = v - height.value / 2,
})
: configuredY0
const containerStyle = computed(() => {
return Number.isFinite(x0.value)
? {
position: 'absolute',
zIndex: 100,
left: `${x0.value - width.value / 2}px`,
top: `${y0.value - height.value / 2}px`,
width: `${width.value}px`,
height: autoHeight ? undefined : `${height.value}px`,
transformOrigin: 'center center',
transform: `rotate(${rotate.value}deg)`,
} satisfies CSSProperties
: {
position: 'absolute',
zIndex: 100,
} satisfies CSSProperties
})
watchStopHandles.push(
watch(
[x0, y0, width, height, rotate],
([x0, y0, w, h, r]) => {
let posStr = [x0 - w / 2, y0 - h / 2, w].map(Math.round).join()
if (autoHeight)
posStr += dataSource === 'directive' ? ',NaN' : ',_'
else
posStr += `,${Math.round(h)}`
if (Math.round(r) !== 0)
posStr += `,${Math.round(r)}`
if (dataSource === 'directive')
posStr = `[${posStr}]`
updater.value(dragId, posStr, dataSource, markdownSource)
},
),
)
const state = {
dragId,
dataSource,
markdownSource,
isArrow,
zoom,
autoHeight,
x0,
y0,
width,
height,
rotate,
container,
containerStyle,
watchStopHandles,
dragging: computed((): boolean => activeDragElement.value === state),
mounted() {
if (!enabled)
return
updateBounds()
if (!posRaw) {
setTimeout(() => {
updateBounds()
x0.value = (bounds.value.left + bounds.value.width / 2 - slideLeft.value) / scale.value
y0.value = (bounds.value.top - slideTop.value) / scale.value
width.value = bounds.value.width / scale.value
height.value = bounds.value.height / scale.value
}, 100)
}
},
unmounted() {
if (!enabled)
return
state.stopDragging()
},
startDragging(): void {
if (!enabled)
return
updateBounds()
activeDragElement.value = state
},
stopDragging(): void {
if (!enabled)
return
if (activeDragElement.value === state)
activeDragElement.value = null
},
}
watchStopHandles.push(
onClickOutside(container, (ev) => {
const container = document.querySelector('#drag-control-container')
if (container && ev.target && container.contains(ev.target as HTMLElement))
return
state.stopDragging()
}),
watch(useWindowFocus(), (focused) => {
if (!focused)
state.stopDragging()
}),
)
return state
}
export type DragElementState = ReturnType<typeof useDragElement>
+181
View File
@@ -0,0 +1,181 @@
import type { Brush, Options as DrauuOptions, DrawingMode } from 'drauu'
import { createSharedComposable, toReactive, useLocalStorage } from '@vueuse/core'
import { createDrauu } from 'drauu'
import { computed, markRaw, nextTick, reactive, ref, watch } from 'vue'
import { configs } from '../env'
import { isInputting } from '../state'
import { drawingState, onPatchDrawingState, patchDrawingState } from '../state/drawings'
import { useNav } from './useNav'
export const useDrawings = createSharedComposable(() => {
const { currentSlideNo, isPresenter } = useNav()
const brushColors = [
'#ff595e',
'#ffca3a',
'#8ac926',
'#1982c4',
'#6a4c93',
'#ffffff',
'#000000',
]
const drawingEnabled = useLocalStorage('slidev-drawing-enabled', false)
const drawingPinned = useLocalStorage('slidev-drawing-pinned', false)
const brush = toReactive(useLocalStorage<Brush>('slidev-drawing-brush', {
color: brushColors[0],
size: 4,
mode: 'stylus',
}))
const isDrawing = ref(false)
const canUndo = ref(false)
const canRedo = ref(false)
const canClear = ref(false)
const _mode = ref<DrawingMode | 'arrow'>('stylus')
const syncUp = computed(() => configs.drawings.syncAll || isPresenter.value)
let disableDump = false
const drawingMode = computed({
get() {
return _mode.value
},
set(v: DrawingMode | 'arrow') {
_mode.value = v
if (v === 'arrow') {
// eslint-disable-next-line ts/no-use-before-define
drauu.mode = 'line'
brush.arrowEnd = true
}
else {
// eslint-disable-next-line ts/no-use-before-define
drauu.mode = v
brush.arrowEnd = false
}
},
})
const drauuOptions: DrauuOptions = reactive({
brush,
acceptsInputTypes: computed(() => (drawingEnabled.value && (!configs.drawings.presenterOnly || isPresenter.value)) ? undefined : ['pen' as const]),
coordinateTransform: false,
})
const drauu = markRaw(createDrauu(drauuOptions))
function clearDrauu() {
drauu.clear()
if (syncUp.value)
patchDrawingState(currentSlideNo.value, '')
}
function updateState() {
canRedo.value = drauu.canRedo()
canUndo.value = drauu.canUndo()
canClear.value = !!drauu.el?.children.length
}
function loadCanvas(page?: number) {
disableDump = true
const data = drawingState[page || currentSlideNo.value]
if (data != null)
drauu.load(data)
else
drauu.clear()
updateState()
disableDump = false
}
drauu.on('changed', () => {
updateState()
if (!disableDump) {
const dump = drauu.dump()
const key = currentSlideNo.value
if ((drawingState[key] || '') !== dump && syncUp.value)
patchDrawingState(key, drauu.dump())
}
})
onPatchDrawingState((state) => {
disableDump = true
if (state[currentSlideNo.value] != null)
drauu.load(state[currentSlideNo.value] || '')
disableDump = false
updateState()
})
nextTick(() => {
watch(currentSlideNo, () => {
if (!drauu.mounted)
return
loadCanvas()
}, { immediate: true })
})
drauu.on('start', () => isDrawing.value = true)
drauu.on('end', () => isDrawing.value = false)
window.addEventListener('keydown', (e) => {
if (!drawingEnabled.value || isInputting.value)
return
const noModifier = !e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey
let handled = true
if (e.code === 'KeyZ' && (e.ctrlKey || e.metaKey)) {
if (e.shiftKey)
drauu.redo()
else
drauu.undo()
}
else if (e.code === 'Escape') {
drawingEnabled.value = false
}
else if (e.code === 'KeyL' && noModifier) {
drawingMode.value = 'line'
}
else if (e.code === 'KeyA' && noModifier) {
drawingMode.value = 'arrow'
}
else if (e.code === 'KeyS' && noModifier) {
drawingMode.value = 'stylus'
}
else if (e.code === 'KeyR' && noModifier) {
drawingMode.value = 'rectangle'
}
else if (e.code === 'KeyE' && noModifier) {
drawingMode.value = 'ellipse'
}
else if (e.code === 'KeyC' && noModifier) {
clearDrauu()
}
else if (e.code.startsWith('Digit') && noModifier && +e.code[5] <= brushColors.length) {
brush.color = brushColors[+e.code[5] - 1]
}
else {
handled = false
}
if (handled) {
e.preventDefault()
e.stopPropagation()
}
}, false)
return {
brush,
brushColors,
canClear,
canRedo,
canUndo,
clear: clearDrauu,
drauu,
drauuOptions,
drawingEnabled,
drawingMode,
drawingPinned,
drawingState,
isDrawing,
loadCanvas,
}
})
@@ -0,0 +1,56 @@
import { throttledWatch } from '@vueuse/core'
import { useNav } from '../composables/useNav'
import { isDark } from '../logic/dark'
export function useEmbeddedControl() {
const nav = useNav()
const clientId = `${Date.now()}`
window.addEventListener('message', ({ data }) => {
if (data && data.target === 'slidev') {
if (data.type === 'navigate') {
if (data.no || data.clicks) {
nav.go(+data.no, +data.clicks || 0)
}
else if (typeof data.operation === 'string') {
const fn = nav[data.operation as keyof typeof nav]
if (typeof fn === 'function')
(fn as any)(...(data.args ?? []))
}
}
else if (data.type === 'css-vars') {
const root = document.documentElement
for (const [key, value] of Object.entries(data.vars || {}))
root.style.setProperty(key, value as any)
}
else if (data.type === 'color-schema') {
isDark.value = data.color === 'dark'
}
}
})
if (nav.isEmbedded.value) {
throttledWatch(
[nav.currentSlideNo, nav.clicks, nav.hasNext, nav.hasPrev],
([no, clicks, hasNext, hasPrev]) => {
window.parent.postMessage(
{
target: 'slidev',
clientId,
navState: {
no,
clicks,
hasNext,
hasPrev,
},
},
'*',
)
},
{
throttle: 300,
immediate: true,
},
)
}
}
+400
View File
@@ -0,0 +1,400 @@
import type { ClicksContext, SlideRoute, TocItem } from '@slidev/types'
import type { ComputedRef, Ref, TransitionGroupProps, WritableComputedRef } from 'vue'
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 { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { CLICKS_MAX } from '../constants'
import { configs } from '../env'
import { skipTransition } from '../logic/hmr'
import { useRouteQuery } from '../logic/route'
import { getSlide, getSlidePath } from '../logic/slides'
import { getCurrentTransition } from '../logic/transition'
import { createClicksContextBase } from './useClicks'
import { useTocTree } from './useTocTree'
export interface SlidevContextNav {
slides: Ref<SlideRoute[]>
total: ComputedRef<number>
currentPath: ComputedRef<string>
currentPage: ComputedRef<number>
currentSlideNo: ComputedRef<number>
currentSlideRoute: ComputedRef<SlideRoute>
currentTransition: ComputedRef<TransitionGroupProps | undefined>
currentLayout: ComputedRef<string>
nextRoute: ComputedRef<SlideRoute>
prevRoute: ComputedRef<SlideRoute>
hasNext: ComputedRef<boolean>
hasPrev: ComputedRef<boolean>
clicksContext: ComputedRef<ClicksContext>
clicks: ComputedRef<number>
clicksStart: ComputedRef<number>
clicksTotal: ComputedRef<number>
/** The table of content tree */
tocTree: ComputedRef<TocItem[]>
/** The direction of the navigation, 1 for forward, -1 for backward */
navDirection: Ref<number>
/** The direction of the clicks, 1 for forward, -1 for backward */
clicksDirection: Ref<number>
/** Utility function for open file in editor, only avaible in dev mode */
openInEditor: (url?: string) => Promise<boolean>
/** Go to next click */
next: () => Promise<void>
/** Go to previous click */
prev: () => Promise<void>
/** Go to next slide */
nextSlide: (lastClicks?: boolean) => Promise<void>
/** Go to previous slide */
prevSlide: (lastClicks?: boolean) => Promise<void>
/** Go to slide */
go: (no: number | string, clicks?: number, force?: boolean) => Promise<void>
/** Go to the first slide */
goFirst: () => Promise<void>
/** Go to the last slide */
goLast: () => Promise<void>
/** Enter presenter mode */
enterPresenter: () => void
/** Exit presenter mode */
exitPresenter: () => void
}
export interface SlidevContextNavState {
router: Router
currentRoute: ComputedRef<RouteLocationNormalized>
isPrintMode: ComputedRef<boolean>
isPrintWithClicks: Ref<boolean>
isEmbedded: ComputedRef<boolean>
isPlaying: ComputedRef<boolean>
isPresenter: ComputedRef<boolean>
isNotesViewer: ComputedRef<boolean>
isPresenterAvailable: ComputedRef<boolean>
hasPrimarySlide: ComputedRef<boolean>
currentSlideNo: ComputedRef<number>
currentSlideRoute: ComputedRef<SlideRoute>
clicksContext: ComputedRef<ClicksContext>
queryClicksRaw: Ref<string>
queryClicks: WritableComputedRef<number>
printRange: Ref<number[]>
getPrimaryClicks: (route: SlideRoute) => ClicksContext
}
export interface SlidevContextNavFull extends SlidevContextNav, SlidevContextNavState { }
export function useNavBase(
currentSlideRoute: ComputedRef<SlideRoute>,
clicksContext: ComputedRef<ClicksContext>,
queryClicks: Ref<number> = ref(0),
isPresenter: Ref<boolean>,
isPrint: Ref<boolean>,
router?: Router,
): SlidevContextNav {
const total = computed(() => slides.value.length)
const navDirection = ref(0)
const clicksDirection = ref(0)
const currentPath = computed(() => getSlidePath(currentSlideRoute.value, isPresenter.value))
const currentSlideNo = computed(() => currentSlideRoute.value.no)
const currentLayout = computed(() => currentSlideRoute.value.meta?.layout || (currentSlideNo.value === 1 ? 'cover' : 'default'))
const clicks = computed(() => clicksContext.value.current)
const clicksStart = computed(() => clicksContext.value.clicksStart)
const clicksTotal = computed(() => clicksContext.value.total)
const nextRoute = computed(() => slides.value[Math.min(slides.value.length, currentSlideNo.value + 1) - 1])
const prevRoute = computed(() => slides.value[Math.max(1, currentSlideNo.value - 1) - 1])
const hasNext = computed(() => currentSlideNo.value < slides.value.length || clicks.value < clicksTotal.value)
const hasPrev = computed(() => currentSlideNo.value > 1 || clicks.value > 0)
const currentTransition = computed(() => isPrint.value ? undefined : getCurrentTransition(navDirection.value, currentSlideRoute.value, prevRoute.value))
watch(currentSlideRoute, (next, prev) => {
navDirection.value = next.no - prev.no
})
async function openInEditor(url?: string) {
if (!__DEV__)
return false
if (url == null) {
const slide = currentSlideRoute.value?.meta?.slide
if (!slide)
return false
url = `${slide.filepath}:${slide.start}`
}
await fetch(`/__open-in-editor?file=${encodeURIComponent(url)}`)
return true
}
const tocTree = useTocTree(
slides,
currentSlideNo,
currentSlideRoute,
)
async function next() {
clicksDirection.value = 1
if (clicksTotal.value <= queryClicks.value)
await nextSlide()
else
queryClicks.value += 1
}
async function prev() {
clicksDirection.value = -1
if (queryClicks.value <= clicksStart.value)
await prevSlide(true)
else
queryClicks.value -= 1
}
async function nextSlide(lastClicks = false) {
clicksDirection.value = 1
if (currentSlideNo.value < slides.value.length) {
await go(
currentSlideNo.value + 1,
lastClicks && !isPrint.value ? CLICKS_MAX : undefined,
)
}
}
async function prevSlide(lastClicks = false) {
clicksDirection.value = -1
if (currentSlideNo.value > 1) {
await go(
currentSlideNo.value - 1,
lastClicks && !isPrint.value ? CLICKS_MAX : undefined,
)
}
}
function goFirst() {
return go(1)
}
function goLast() {
return go(total.value)
}
async function go(no: number | string, clicks: number = 0, force = false) {
skipTransition.value = false
const pageChanged = currentSlideNo.value !== no
const clicksChanged = clicks !== queryClicks.value
const meta = getSlide(no)?.meta
const clicksStart = meta?.slide?.frontmatter.clicksStart ?? 0
clicks = clamp(clicks, clicksStart, meta?.__clicksContext?.total ?? CLICKS_MAX)
if (force || pageChanged || clicksChanged) {
await router?.push({
path: getSlidePath(no, isPresenter.value, router.currentRoute.value.name === 'export'),
query: {
...router.currentRoute.value.query,
clicks: clicks === 0 ? undefined : clicks.toString(),
embedded: location.search.includes('embedded') ? 'true' : undefined,
},
})
}
}
function enterPresenter() {
router?.push({
path: getSlidePath(currentSlideNo.value, true),
query: { ...router.currentRoute.value.query },
})
}
function exitPresenter() {
router?.push({
path: getSlidePath(currentSlideNo.value, false),
query: { ...router.currentRoute.value.query },
})
}
return {
slides,
total,
currentPath,
currentSlideNo,
currentPage: currentSlideNo,
currentSlideRoute,
currentLayout,
currentTransition,
clicksDirection,
nextRoute,
prevRoute,
clicksContext,
clicks,
clicksStart,
clicksTotal,
hasNext,
hasPrev,
tocTree,
navDirection,
openInEditor,
next,
prev,
go,
goLast,
goFirst,
nextSlide,
prevSlide,
enterPresenter,
exitPresenter,
}
}
export function useFixedNav(
currentSlideRoute: SlideRoute,
clicksContext: ClicksContext,
): SlidevContextNav {
const noop = async () => { }
return {
...useNavBase(
computed(() => currentSlideRoute),
computed(() => clicksContext),
ref(CLICKS_MAX),
ref(false),
ref(false),
),
next: noop,
prev: noop,
nextSlide: noop,
prevSlide: noop,
goFirst: noop,
goLast: noop,
go: noop,
}
}
const useNavState = createSharedComposable((): SlidevContextNavState => {
const router = useRouter()
const currentRoute = useRoute()
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') || currentRoute.name === 'export')
const isPrintWithClicks = ref(query.value.get('print') === 'clicks')
const isEmbedded = computed(() => query.value.has('embedded'))
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 = 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')
const clicksContext = computed(() => getPrimaryClicks(currentSlideRoute.value))
const queryClicks = computed({
get() {
let v = +(queryClicksRaw.value || 0)
if (Number.isNaN(v))
v = 0
return v
},
set(v) {
skipTransition.value = false
queryClicksRaw.value = v.toString()
},
})
function getPrimaryClicks(
route: SlideRoute,
): ClicksContext {
if (route?.meta?.__clicksContext)
return route.meta.__clicksContext
const thisNo = route.no
const context = createClicksContextBase(
computed({
get() {
if (currentSlideNo.value === thisNo)
return Math.max(+(queryClicksRaw.value ?? 0), context.clicksStart)
else if (currentSlideNo.value > thisNo)
return CLICKS_MAX
else
return context.clicksStart
},
set(v) {
if (currentSlideNo.value === thisNo)
queryClicksRaw.value = v.toString()
},
}),
route?.meta.slide?.frontmatter.clicksStart ?? 0,
route?.meta.clicks,
)
if (route?.meta)
route.meta.__clicksContext = context
return context
}
return {
router,
currentRoute: computed(() => currentRoute),
isPrintMode,
isPrintWithClicks,
isEmbedded,
isPlaying,
isPresenter,
isNotesViewer,
isPresenterAvailable,
hasPrimarySlide,
currentSlideNo,
currentSlideRoute,
clicksContext,
queryClicksRaw,
queryClicks,
printRange,
getPrimaryClicks,
}
})
export const useNav = createSharedComposable((): SlidevContextNavFull => {
const state = useNavState()
const router = useRouter()
const nav = useNavBase(
state.currentSlideRoute,
state.clicksContext,
state.queryClicks,
state.isPresenter,
state.isPrintMode,
router,
)
watch(
[nav.total, state.currentRoute],
async () => {
const no = state.currentRoute.value.params.no as string
if (state.hasPrimarySlide.value && !getSlide(no)) {
if (no && no !== 'index.html') {
// The current slide may has been removed. Redirect to the last slide.
await nav.go(nav.total.value, 0, true)
}
else {
// Redirect to the first slide
await nav.go(1, 0, true)
}
}
},
{ flush: 'pre', immediate: true },
)
return {
...nav,
...state,
}
})
@@ -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 = ''
})
}
@@ -0,0 +1,30 @@
import { useElementBounding } from '@vueuse/core'
import { inject, ref, watch } from 'vue'
import { injectionSlideElement } from '../constants'
import { editorHeight, editorWidth, isEditorVertical, showEditor, slideScale, windowSize } from '../state'
export function useSlideBounds(slideElement = inject(injectionSlideElement, ref())) {
const bounding = useElementBounding(slideElement)
const stop = watch(
[
showEditor,
isEditorVertical,
editorWidth,
editorHeight,
slideScale,
windowSize.width,
windowSize.height,
],
() => {
setTimeout(bounding.update, 300)
},
{
flush: 'post',
immediate: true,
},
)
return {
...bounding,
stop,
}
}
+80
View File
@@ -0,0 +1,80 @@
import type { SlideInfo, SlidePatch } from '@slidev/types'
import type { MaybeRef } from '@vueuse/core'
import type { Ref } from 'vue'
import { useFetch } from '@vueuse/core'
import { computed, ref, unref } from 'vue'
import { getSlide } from '../logic/slides'
export interface UseSlideInfo {
info: Ref<SlideInfo | null>
update: (data: SlidePatch) => Promise<SlideInfo | void>
}
export function useSlideInfo(no: number): UseSlideInfo {
if (!__SLIDEV_HAS_SERVER__) {
return {
info: ref(getSlide(no)?.meta.slide ?? null) as Ref<SlideInfo | null>,
update: async () => {},
}
}
const url = `/__slidev/slides/${no}.json`
const { data: info, execute } = useFetch(url).json<SlideInfo>().get()
execute()
const update = async (data: SlidePatch) => {
return await fetch(
url,
{
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
},
).then(r => r.json())
}
if (__DEV__) {
import.meta.hot?.on('slidev:update-slide', (payload) => {
if (payload.no === no)
info.value = payload.data
})
import.meta.hot?.on('slidev:update-note', (payload) => {
if (payload.no === no && info.value && info.value.note?.trim() !== payload.note?.trim())
info.value = { ...info.value, ...payload }
})
}
return {
info,
update,
}
}
const map: Record<number, UseSlideInfo> = {}
export function useDynamicSlideInfo(no: MaybeRef<number>) {
function get(no: number) {
return map[no] ??= useSlideInfo(no)
}
return {
info: computed({
get() {
return get(unref(no)).info.value
},
set(newInfo) {
get(unref(no)).info.value = newInfo
},
}),
update: async (data: SlidePatch, newId?: number) => {
const info = get(newId ?? unref(no))
const newData = await info.update(data)
if (newData)
info.info.value = newData
return newData
},
}
}
@@ -0,0 +1,43 @@
import type { Ref } from 'vue'
import { timestamp, usePointerSwipe } from '@vueuse/core'
import { ref } from 'vue'
import { useNav } from '../composables/useNav'
import { useDrawings } from './useDrawings'
export function useSwipeControls(root: Ref<HTMLElement | undefined>) {
const { next, nextSlide, prev, prevSlide } = useNav()
const { isDrawing } = useDrawings()
const swipeBegin = ref(0)
const { direction, distanceX, distanceY } = usePointerSwipe(root, {
pointerTypes: ['touch'],
onSwipeStart() {
if (isDrawing.value)
return
swipeBegin.value = timestamp()
},
onSwipeEnd() {
if (!swipeBegin.value)
return
if (isDrawing.value)
return
const x = Math.abs(distanceX.value)
const y = Math.abs(distanceY.value)
if (x / window.innerWidth > 0.3 || x > 75) {
if (direction.value === 'left')
next()
else
prev()
}
else if (y / window.innerHeight > 0.4 || y > 200) {
if (direction.value === 'down')
prevSlide()
else
nextSlide()
}
},
})
}
+20
View File
@@ -0,0 +1,20 @@
import { useInterval } from '@vueuse/core'
import { computed } from 'vue'
export function useTimer() {
const { counter, isActive, reset, pause, resume } = useInterval(1000, { controls: true })
const timer = computed(() => {
const passed = counter.value
const sec = Math.floor(passed % 60).toString().padStart(2, '0')
const min = Math.floor(passed / 60).toString().padStart(2, '0')
return `${min}:${sec}`
})
return {
timer,
isTimerAvctive: isActive,
resetTimer: reset,
toggleTimer: () => (isActive.value ? pause() : resume()),
}
}
+82
View File
@@ -0,0 +1,82 @@
import type { SlideRoute, TocItem } from '@slidev/types'
import type { ComputedRef, Ref } from 'vue'
import { computed } from 'vue'
import { getSlidePath } from '../logic/slides'
function addToTree(tree: TocItem[], route: SlideRoute, level = 1) {
const titleLevel = route.meta.slide.level ?? level
if (titleLevel && titleLevel > level && tree.length > 0) {
addToTree(tree[tree.length - 1].children, route, level + 1)
}
else {
tree.push({
no: route.no,
children: [],
level,
titleLevel,
path: getSlidePath(route.meta.slide?.frontmatter?.routeAlias ?? route.no, false),
hideInToc: Boolean(route.meta?.slide?.frontmatter?.hideInToc),
title: route.meta?.slide?.title,
})
}
}
function getTreeWithActiveStatuses(
tree: TocItem[],
currentRoute?: SlideRoute,
hasActiveParent = false,
parent?: TocItem,
currentSlideNo?: Ref<number>,
): TocItem[] {
return tree.map((item: TocItem) => {
const clone = {
...item,
active: item.no === currentSlideNo?.value,
hasActiveParent,
}
if (clone.children.length > 0) {
clone.children = getTreeWithActiveStatuses(
clone.children,
currentRoute,
clone.active || clone.hasActiveParent,
clone,
currentSlideNo,
)
}
if (parent && (clone.active || clone.activeParent))
parent.activeParent = true
return clone
})
}
function filterTree(tree: TocItem[], level = 1): TocItem[] {
return tree
.filter((item: TocItem) => !item.hideInToc)
.map((item: TocItem) => ({
...item,
children: filterTree(item.children, level + 1),
}))
}
export function useTocTree(
slides: Ref<SlideRoute[]>,
currentSlideNo: Ref<number>,
currentSlideRoute: Ref<SlideRoute>,
): ComputedRef<TocItem[]> {
const rawTree = computed(() => slides.value
.filter((route: SlideRoute) => route.meta?.slide?.title)
.reduce((acc: TocItem[], route: SlideRoute) => {
addToTree(acc, route)
return acc
}, []))
const treeWithActiveStatuses = computed(() => getTreeWithActiveStatuses(
rawTree.value,
currentSlideRoute.value,
undefined,
undefined,
currentSlideNo,
))
return computed(() => filterTree(treeWithActiveStatuses.value))
}
@@ -0,0 +1,61 @@
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { configs } from '../env'
import { getSlide } from '../logic/slides'
export function useViewTransition() {
const router = useRouter()
const isViewTransition = ref(false)
let viewTransitionFinish: undefined | (() => void)
let viewTransitionAbort: undefined | (() => void)
const supportViewTransition = typeof document !== 'undefined' && 'startViewTransition' in document
router.beforeResolve((to, from) => {
const fromMeta = getSlide(from.params.no as string)?.meta
const toMeta = getSlide(to.params.no as string)?.meta
const fromNo = fromMeta?.slide?.no
const toNo = toMeta?.slide?.no
const transitionType = fromNo != null && toNo != null && fromNo !== toNo
&& ((fromNo < toNo ? fromMeta?.transition : toMeta?.transition) ?? configs.transition)
if (transitionType !== 'view-transition') {
isViewTransition.value = false
return
}
if (!supportViewTransition) {
isViewTransition.value = false
console.warn('View transition is not supported in your browser, fallback to normal transition.')
return
}
isViewTransition.value = true
const promise = new Promise<void>((resolve, reject) => {
viewTransitionFinish = resolve
viewTransitionAbort = reject
})
let changeRoute: () => void
const ready = new Promise<void>(resolve => (changeRoute = resolve))
// Wait for `TransitionGroup` to become normal `div`
setTimeout(() => {
document.startViewTransition(() => {
changeRoute()
return promise
})
}, 50)
return ready
})
if (supportViewTransition) {
router.afterEach(() => {
viewTransitionFinish?.()
viewTransitionAbort?.()
})
}
return isViewTransition
}
+14
View File
@@ -0,0 +1,14 @@
import { useWakeLock as useVueUseWakeLock } from '@vueuse/core'
import { watch } from 'vue'
import { wakeLockEnabled } from '../state'
export function useWakeLock() {
const { request, release } = useVueUseWakeLock()
watch(wakeLockEnabled, (enabled) => {
if (enabled)
request('screen')
else
release()
}, { immediate: true })
}
+87
View File
@@ -0,0 +1,87 @@
import type { ClicksContext, RenderContext, SlideRoute } from '@slidev/types'
import type { ComputedRef, InjectionKey, Ref, UnwrapNestedRefs } from 'vue'
import type { SlidevContext } from './modules/context'
// Here we use string literal instead of symbols to make HMR more stable
// The value of the injections keys are implementation details, you should always use them with the reference to the constant instead of the value
export const injectionClicksContext = '$$slidev-clicks-context' as unknown as InjectionKey<Ref<ClicksContext>>
export const injectionCurrentPage = '$$slidev-page' as unknown as InjectionKey<Ref<number>>
export const injectionSlideElement = '$$slidev-slide-element' as unknown as InjectionKey<Ref<HTMLElement | null>>
export const injectionSlideScale = '$$slidev-slide-scale' as unknown as InjectionKey<ComputedRef<number>>
export const injectionSlidevContext = '$$slidev-context' as unknown as InjectionKey<UnwrapNestedRefs<SlidevContext>>
export const injectionRoute = '$$slidev-route' as unknown as InjectionKey<SlideRoute>
export const injectionRenderContext = '$$slidev-render-context' as unknown as InjectionKey<Ref<RenderContext>>
export const injectionFrontmatter = '$$slidev-fontmatter' as unknown as InjectionKey<Record<string, any>>
export const injectionSlideZoom = '$$slidev-slide-zoom' as unknown as InjectionKey<ComputedRef<number>>
export const CLASS_VCLICK_TARGET = 'slidev-vclick-target'
export const CLASS_VCLICK_HIDDEN = 'slidev-vclick-hidden'
export const CLASS_VCLICK_FADE = 'slidev-vclick-fade'
export const CLASS_VCLICK_GONE = 'slidev-vclick-gone'
export const CLASS_VCLICK_HIDDEN_EXP = 'slidev-vclick-hidden-explicitly'
export const CLASS_VCLICK_CURRENT = 'slidev-vclick-current'
export const CLASS_VCLICK_PRIOR = 'slidev-vclick-prior'
export const CLASS_VCLICK_DISPLAY_NONE = 'slidev-vclick-display-none'
export const CLICKS_MAX = 999999
export const TRUST_ORIGINS = [
'localhost',
'127.0.0.1',
]
export const FRONTMATTER_FIELDS = [
'clicks',
'clicksStart',
'disabled',
'hide',
'hideInToc',
'layout',
'level',
'preload',
'routeAlias',
'src',
'title',
'transition',
'zoom',
'dragPos',
'lang',
]
export const HEADMATTER_FIELDS = [
...FRONTMATTER_FIELDS,
'theme',
'titleTemplate',
'info',
'author',
'keywords',
'presenter',
'browserExporter',
'download',
'exportFilename',
'export',
'highlighter',
'lineNumbers',
'monaco',
'monacoTypesSource',
'monacoTypesAdditionalPackages',
'monacoRunAdditionalDeps',
'remoteAssets',
'selectable',
'record',
'colorSchema',
'routerMode',
'aspectRatio',
'canvasWidth',
'themeConfig',
'favicon',
'plantUmlServer',
'fonts',
'defaults',
'drawings',
'htmlAttrs',
'mdc',
'contextMenu',
'wakeLock',
'overviewSnapshots',
]
+58
View File
@@ -0,0 +1,58 @@
import { injectLocal, objectOmit } from '@vueuse/core'
import { computed, ref, toRef } from 'vue'
import {
FRONTMATTER_FIELDS,
HEADMATTER_FIELDS,
injectionClicksContext,
injectionCurrentPage,
injectionFrontmatter,
injectionRenderContext,
injectionRoute,
injectionSlideScale,
injectionSlidevContext,
injectionSlideZoom,
} from './constants'
/**
* Get the current slide context, should be called inside the setup function of a component inside slide
*/
export function useSlideContext() {
const $slidev = injectLocal(injectionSlidevContext)!
const $nav = toRef($slidev, 'nav')
const $clicksContext = injectLocal(injectionClicksContext)!.value
const $clicks = toRef($clicksContext, 'current')
const $page = injectLocal(injectionCurrentPage)!
const $renderContext = injectLocal(injectionRenderContext)!
const $frontmatter = injectLocal(injectionFrontmatter, {})
const $route = injectLocal(injectionRoute, undefined)
const $scale = injectLocal(injectionSlideScale, ref(1))
const $zoom = injectLocal(injectionSlideZoom, computed(() => 1))
return {
$slidev,
$nav,
$clicksContext,
$clicks,
$page,
$route,
$renderContext,
$frontmatter,
$scale,
$zoom,
}
}
export type SlideContext = ReturnType<typeof useSlideContext>
/**
* Convert frontmatter options to props for v-bind
* It removes known options fields, and expose an extra `frontmatter` field that contains full frontmatter
*
* @internal
*/
export function frontmatterToProps(frontmatter: Record<string, any>, pageNo: number) {
return {
...objectOmit(frontmatter, pageNo === 0 ? HEADMATTER_FIELDS : FRONTMATTER_FIELDS),
frontmatter,
}
}
+22
View File
@@ -0,0 +1,22 @@
import configs from '#slidev/configs'
import { objectMap } from '@antfu/utils'
import { computed } from 'vue'
export { configs }
export const mode = __DEV__ ? 'dev' : 'build'
export const slideAspect = computed(() => configs.aspectRatio)
export const slideWidth = computed(() => configs.canvasWidth)
// To honor the aspect ratio more as possible, we need to approximate the height to the next integer.
// Doing this, we will prevent on print, to create an additional empty white page after each page.
export const slideHeight = computed(() => Math.ceil(slideWidth.value / slideAspect.value))
export const themeVars = computed(() => {
return objectMap(configs.themeConfig || {}, (k, v) => [`--slidev-theme-${k}`, v])
})
export const slidesTitle = configs.slidesTitle
export const pathPrefix = import.meta.env.BASE_URL + (__SLIDEV_HASH_ROUTE__ ? '#/' : '')
+14
View File
@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- head -->
</head>
<body>
<div id="app"></div>
<script type="module" src="__ENTRY__"></script>
<div id="mermaid-rendering-container"></div>
<!-- body -->
</body>
</html>
+31
View File
@@ -0,0 +1,31 @@
/**
* This file is the public APIs that you might use in your app.
*
* The other files despite they are accessable, are not meant to be used directly, breaking changes might happen.
*/
export { useDarkMode } from './composables/useDarkMode'
export { useDrawings } from './composables/useDrawings'
export { useNav } from './composables/useNav'
export { useSlideContext } from './context'
export * from './env'
export * from './layoutHelper'
export {
onSlideEnter,
onSlideLeave,
useIsSlideActive,
} from './logic/slides'
export type { DrawingsState } from './state/drawings'
export { drawingState, onDrawingUpdate } from './state/drawings'
export type { SharedState } from './state/shared'
export { onSharedUpdate, sharedState } from './state/shared'
export {
addSyncMethod,
createSyncState,
disableBuiltinSync,
} from './state/syncState'
+116
View File
@@ -0,0 +1,116 @@
<script setup lang="ts">
import type { ClicksContext } from '@slidev/types'
import { clamp, range } from '@antfu/utils'
import { computed } from 'vue'
import { CLICKS_MAX } from '../constants'
const props = withDefaults(defineProps<{
clicksContext: ClicksContext
readonly?: boolean
active?: boolean
}>(), {
active: true,
})
const total = computed(() => props.clicksContext.total)
const start = computed(() => clamp(0, props.clicksContext.clicksStart, total.value))
const length = computed(() => total.value - start.value + 1)
const current = computed({
get() {
return props.clicksContext.current > total.value ? -1 : props.clicksContext.current
},
set(value: number) {
// eslint-disable-next-line vue/no-mutating-props
props.clicksContext.current = value
},
})
const clicksRange = computed(() => range(start.value, total.value + 1))
function onMousedown() {
if (props.readonly)
return
if (current.value < 0 || current.value > total.value)
current.value = 0
}
</script>
<template>
<div
class="flex gap-1 items-center select-none"
:title="`Clicks in this slide: ${length}`"
:class="length && props.clicksContext.isMounted ? '' : 'op50'"
>
<div class="flex gap-0.2 items-center min-w-16 font-mono mr1">
<div class="i-carbon:cursor-1 text-sm op50" />
<template v-if="current >= 0 && current !== CLICKS_MAX && active">
<div flex-auto />
<span text-primary>{{ current }}</span>
<span op25 text-sm>/</span>
<span op50 text-sm>{{ total }}</span>
</template>
<div
v-else
op50 flex-auto pl1
>
{{ total }}
</div>
</div>
<div
relative flex-auto h5 font-mono flex="~"
>
<div
v-for="i of clicksRange" :key="i"
border="y main" of-hidden relative
:class="[
i === 0 ? 'rounded-l border-l' : '',
i === total ? 'rounded-r border-r' : '',
]"
:style="{ width: length > 0 ? `${1 / length * 100}%` : '100%' }"
>
<div
absolute inset-0
:class="(i <= current && active) ? 'bg-primary op15' : ''"
/>
<div
:class="[
(+i === +current && active) ? 'text-primary font-bold op100 border-primary' : 'op30 border-main',
i === 0 ? 'rounded-l' : '',
i === total ? 'rounded-r' : 'border-r-2',
]"
w-full h-full text-xs flex items-center justify-center z-1
>
{{ i }}
</div>
</div>
<input
v-model="current"
class="range"
type="range" :min="start" :max="total" :step="1"
absolute inset-0 z-label op0
:class="readonly ? 'pointer-events-none' : ''"
:style="{ '--thumb-width': `${1 / (length + 1) * 100}%` }"
@mousedown="onMousedown"
@focus="event => (event.currentTarget as HTMLElement)?.blur()"
>
</div>
</div>
</template>
<style scoped>
.range {
-webkit-appearance: none;
appearance: none;
background: transparent;
}
.range::-webkit-slider-thumb {
-webkit-appearance: none;
height: 100%;
width: var(--thumb-width, 0.5rem);
}
.range::-moz-range-thumb {
height: 100%;
width: var(--thumb-width, 0.5rem);
}
</style>
+163
View File
@@ -0,0 +1,163 @@
<script setup lang="ts">
import type { CodeRunnerOutputs, RawAtValue } from '@slidev/types'
import { debounce, toArray } from '@antfu/utils'
import { useVModel } from '@vueuse/core'
import { computed, onMounted, onUnmounted, ref, shallowRef, toValue, watch, watchSyncEffect } from 'vue'
import { useNav } from '../composables/useNav'
import { useSlideContext } from '../context'
import { makeId } from '../logic/utils'
import setupCodeRunners from '../setup/code-runners'
import DomElement from './DomElement.vue'
import IconButton from './IconButton.vue'
const props = defineProps<{
modelValue: string
lang: string
autorun: boolean | 'once'
height?: string
showOutputAt?: RawAtValue
highlightOutput: boolean
runnerOptions?: Record<string, unknown>
}>()
const emit = defineEmits(['update:modelValue'])
const { isPrintMode } = useNav()
const code = useVModel(props, 'modelValue', emit)
const { $renderContext, $clicksContext } = useSlideContext()
const disabled = computed(() => !['slide', 'presenter'].includes($renderContext.value))
const autorun = isPrintMode.value ? 'once' : props.autorun
const isRunning = ref(autorun)
const outputs = shallowRef<CodeRunnerOutputs>()
const runCount = ref(0)
const highlightFn = ref<(code: string, lang: string) => string>()
const hidden = ref(props.showOutputAt)
if (props.showOutputAt) {
const id = makeId()
onMounted(() => {
const info = $clicksContext.calculate(props.showOutputAt)
if (info) {
$clicksContext.register(id, info)
watchSyncEffect(() => {
hidden.value = !info.isActive.value
})
}
else {
hidden.value = false
}
})
onUnmounted(() => $clicksContext.unregister(id))
}
const triggerRun = debounce(200, async () => {
if (disabled.value)
return
const { highlight, run } = await setupCodeRunners()
highlightFn.value = highlight
const setAsRunning = setTimeout(() => {
isRunning.value = true
}, 500)
outputs.value = await run(code.value, props.lang, props.runnerOptions ?? {})
runCount.value += 1
isRunning.value = false
clearTimeout(setAsRunning)
})
if (autorun === 'once')
triggerRun()
else if (autorun)
watch(code, triggerRun, { immediate: true })
</script>
<template>
<div
v-show="!hidden"
class="relative flex flex-col rounded-b border-t border-main"
:style="{ height: props.height }"
data-waitfor=".slidev-runner-output"
>
<div v-if="disabled" class="text-sm text-center opacity-50">
Code is disabled in the "{{ $renderContext }}" mode
</div>
<div v-else-if="isRunning" class="text-sm text-center opacity-50">
Running...
</div>
<div v-else-if="!outputs" class="text-sm text-center opacity-50">
Click the play button to run the code
</div>
<div v-else :key="`run-${runCount}`" class="slidev-runner-output">
<template v-for="output, _idx1 of toArray(toValue(outputs))" :key="_idx1">
<div v-if="'html' in output" v-html="output.html" />
<div v-else-if="'error' in output" class="text-red-500">
{{ output.error }}
</div>
<DomElement v-else-if="'element' in output" :element="output.element" />
<div v-else class="output-line">
<template
v-for="item, idx2 in toArray(output)"
:key="idx2"
>
<span
v-if="item.highlightLang && highlightFn"
class="highlighted"
v-html="highlightFn(item.text, item.highlightLang)"
/>
<span v-else :class="item.class">{{ item.text }}</span>
<span v-if="idx2 < toArray(output).length - 1" class="separator">,</span>
</template>
</div>
</template>
</div>
</div>
<div v-if="code.trim()" class="absolute right-1 top-1 max-h-full flex gap-1">
<IconButton class="w-8 h-8 max-h-full flex justify-center items-center" title="Run code" @click="triggerRun">
<div class="i-carbon:play" />
</IconButton>
</div>
</template>
<style lang="postcss">
.slidev-runner-output {
@apply px-5 py-3 flex-grow text-xs leading-[.8rem] font-$slidev-code-font-family select-text;
}
.slidev-runner-output .log-type {
@apply font-bold op-70;
&.DBG {
@apply text-gray-500;
}
&.LOG {
@apply text-blue-500;
}
&.WRN {
@apply text-orange-500;
}
&.ERR {
@apply text-red-500;
}
}
.slidev-runner-output .output-line {
@apply flex my-1 w-full;
}
.slidev-runner-output .separator {
@apply op-40 mr-1;
}
.slidev-runner-output .highlighted > pre {
@apply inline text-wrap !bg-transparent;
}
</style>
+112
View File
@@ -0,0 +1,112 @@
<script setup lang="ts">
import { onClickOutside, useElementBounding, useEventListener, useWindowFocus } from '@vueuse/core'
import { computed, ref, watch } from 'vue'
import { useDynamicSlideInfo } from '../composables/useSlideInfo'
import { configs } from '../env'
import { closeContextMenu, currentContextMenu } from '../logic/contextMenu'
import { windowSize } from '../state'
const container = ref<HTMLElement>()
onClickOutside(container, closeContextMenu)
useEventListener(document, 'mousedown', (ev) => {
if (ev.buttons & 2)
closeContextMenu()
}, {
passive: true,
capture: true,
})
const isExplicitEnabled = computed(() => configs.contextMenu != null)
const windowFocus = useWindowFocus()
watch(windowFocus, (hasFocus) => {
if (!hasFocus)
closeContextMenu()
})
const firstSlide = useDynamicSlideInfo(1)
function disableContextMenu() {
const info = firstSlide.info.value
if (!info)
return
firstSlide.update({
frontmatter: {
contextMenu: false,
},
})
}
const { width, height } = useElementBounding(container)
const left = computed(() => {
const x = currentContextMenu.value?.x
if (!x)
return 0
if (x + width.value > windowSize.width.value)
return windowSize.width.value - width.value
return x
})
const top = computed(() => {
const y = currentContextMenu.value?.y
if (!y)
return 0
if (y + height.value > windowSize.height.value)
return windowSize.height.value - height.value
return y
})
</script>
<template>
<div
v-if="currentContextMenu"
ref="container"
:style="`left:${left}px;top:${top}px`"
class="slidev-glass-effect fixed z-context-menu w-60 flex flex-wrap justify-items-start p-1 animate-fade-in animate-duration-100 rounded-md shadow overflow-hidden select-none"
@contextmenu.prevent=""
@click="closeContextMenu"
>
<template v-for="item, index of currentContextMenu.items.value" :key="index">
<div v-if="item === 'separator'" :key="index" class="w-full my1 border-t border-main" />
<div
v-else-if="item.small"
class="p-2 w-[40px] h-[40px] inline-block text-center cursor-pointer rounded flex"
:class="item.disabled ? `op40` : `hover:bg-active`"
:title="(item.label as string)"
@click="item.action"
>
<div v-if="typeof item.icon === 'string'" :class="item.icon" class="text-1.2em ma" />
<component :is="item.icon" v-else />
</div>
<div
v-else
class="w-full grid grid-cols-[35px_1fr] p-2 pl-0 cursor-pointer rounded"
:class="item.disabled ? `op40` : `hover:bg-active`"
@click="item.action"
>
<div class="mx-auto flex">
<div v-if="typeof item.icon === 'string'" :class="item.icon" class="text-1.2em ma" />
<component :is="item.icon" v-else />
</div>
<div v-if="typeof item.label === 'string'">
{{ item.label }}
</div>
<component :is="item.label" v-else />
</div>
</template>
<template v-if="!isExplicitEnabled">
<div class="w-full my1 border-t border-main" />
<div class="w-full text-xs p2">
<div class="text-main text-opacity-50!">
Hold <kbd class="border px1 py0.5 border-main rounded text-primary">Shift</kbd> and right click to open the native context menu
<button
v-if="__DEV__"
class="underline op50 hover:op100 mt1 block"
@click="disableContextMenu()"
>
Disable custom context menu
</button>
</div>
</div>
</template>
</div>
</template>
+33
View File
@@ -0,0 +1,33 @@
<script setup lang="ts">
import { shallowRef } from 'vue'
import { useNav } from '../composables/useNav'
import { configs } from '../env'
import { showInfoDialog, showRecordingDialog } from '../state'
import ContextMenu from './ContextMenu.vue'
import Goto from './Goto.vue'
import InfoDialog from './InfoDialog.vue'
import QuickOverview from './QuickOverview.vue'
const { isEmbedded } = useNav()
const drawingEnabled = __SLIDEV_FEATURE_DRAWINGS__ && !configs.drawings.presenterOnly && !isEmbedded.value
const DrawingControls = shallowRef<any>()
if (drawingEnabled)
import('../internals/DrawingControls.vue').then(v => DrawingControls.value = v.default)
const WebCamera = shallowRef<any>()
const RecordingDialog = shallowRef<any>()
if (__SLIDEV_FEATURE_RECORD__) {
import('./WebCamera.vue').then(v => WebCamera.value = v.default)
import('./RecordingDialog.vue').then(v => RecordingDialog.value = v.default)
}
</script>
<template>
<DrawingControls v-if="DrawingControls" />
<QuickOverview />
<Goto />
<WebCamera v-if="WebCamera" />
<RecordingDialog v-if="RecordingDialog" v-model="showRecordingDialog" />
<InfoDialog v-if="configs.info" v-model="showInfoDialog" />
<ContextMenu />
</template>
+56
View File
@@ -0,0 +1,56 @@
<script setup lang="ts">
import type { SelectionItem } from './types'
import { computed } from 'vue'
import {
cameras,
ensureDevicesListPermissions,
microphones,
mimeExtMap,
mimeType,
supportedMimeTypes,
} from '../logic/recording'
import { currentCamera, currentMic } from '../state'
import SelectList from './SelectList.vue'
const camerasItems = computed<SelectionItem<string>[]>(() => [
{
value: 'none',
display: 'None',
},
...cameras.value.map(i => ({
value: i.deviceId,
display: i.label,
})),
])
const microphonesItems = computed<SelectionItem<string>[]>(() => [
{
value: 'none',
display: 'None',
},
...microphones.value.map(i => ({
value: i.deviceId,
display: i.label,
})),
])
const mimeTypeItems = supportedMimeTypes.map(mime => ({
value: mime,
display: mimeExtMap[mime].toUpperCase(),
}))
ensureDevicesListPermissions()
</script>
<template>
<div class="text-sm">
<SelectList v-model="currentCamera" title="Camera" :items="camerasItems" />
<SelectList v-model="currentMic" title="Microphone" :items="microphonesItems" />
<SelectList
v-if="mimeTypeItems.length"
v-model="mimeType"
title="mimeType"
:items="mimeTypeItems"
/>
</div>
</template>
+18
View File
@@ -0,0 +1,18 @@
<script setup lang="ts">
import { ref, watchEffect } from 'vue'
const props = defineProps<{
element: HTMLElement
}>()
const container = ref<HTMLElement>()
watchEffect(() => {
if (container.value)
container.value.appendChild(props.element)
})
</script>
<template>
<div ref="container" />
</template>
+405
View File
@@ -0,0 +1,405 @@
<script setup lang="ts">
import type { Pausable } from '@vueuse/core'
import type { DragElementState } from '../composables/useDragElements'
import { clamp } from '@antfu/utils'
import { useIntervalFn } from '@vueuse/core'
import { computed, inject, ref, watchEffect } from 'vue'
import { useSlideBounds } from '../composables/useSlideBounds'
import { injectionSlideScale } from '../constants'
import { slideHeight, slideWidth } from '../env'
import { magicKeys } from '../state'
const { data } = defineProps<{ data: DragElementState }>()
const { dragId, zoom, autoHeight, x0, y0, width, height, rotate, isArrow } = data
const slideScale = inject(injectionSlideScale, ref(1))
const scale = computed(() => slideScale.value * zoom.value)
const { left: slideLeft, top: slideTop } = useSlideBounds()
const ctrlSize = 10
const minSize = isArrow ? Number.NEGATIVE_INFINITY : 40
const minRemain = 10
const rotateRad = computed(() => rotate.value * Math.PI / 180)
const rotateSin = computed(() => Math.sin(rotateRad.value))
const rotateCos = computed(() => Math.cos(rotateRad.value))
const boundingWidth = computed(() => width.value * rotateCos.value + height.value * rotateSin.value)
const boundingHeight = computed(() => width.value * rotateSin.value + height.value * rotateCos.value)
const boundingLeft = computed(() => x0.value - boundingWidth.value / 2)
const boundingTop = computed(() => y0.value - boundingHeight.value / 2)
const boundingRight = computed(() => x0.value + boundingWidth.value / 2)
const boundingBottom = computed(() => y0.value + boundingHeight.value / 2)
const arrowRevX = computed(() => isArrow && width.value < 0)
const arrowRevY = computed(() => isArrow && height.value < 0)
let currentDrag: {
x0: number
y0: number
width: number
height: number
rotate: number
dx0: number
dy0: number
ltx: number
lty: number
rtx: number
rty: number
lbx: number
lby: number
rbx: number
rby: number
} | null = null
function onPointerdown(ev: PointerEvent) {
if (ev.buttons !== 1)
return
ev.preventDefault()
ev.stopPropagation()
const el = ev.target as HTMLElement
const elBounds = el.getBoundingClientRect()
const cross1x = width.value * rotateCos.value - height.value * rotateSin.value
const cross1y = width.value * rotateSin.value + height.value * rotateCos.value
const cross2x = width.value * rotateCos.value + height.value * rotateSin.value
const cross2y = -width.value * rotateSin.value + height.value * rotateCos.value
currentDrag = {
x0: x0.value,
y0: y0.value,
width: width.value,
height: height.value,
rotate: rotate.value,
dx0: ev.clientX - (elBounds.left + elBounds.right) / 2,
dy0: ev.clientY - (elBounds.top + elBounds.bottom) / 2,
ltx: x0.value - cross1x / 2,
lty: y0.value - cross1y / 2,
rtx: x0.value + cross2x / 2,
rty: y0.value - cross2y / 2,
lbx: x0.value - cross2x / 2,
lby: y0.value + cross2y / 2,
rbx: x0.value + cross1x / 2,
rby: y0.value + cross1y / 2,
};
(ev.currentTarget as HTMLElement).setPointerCapture(ev.pointerId)
}
function onPointermove(ev: PointerEvent) {
if (!currentDrag || ev.buttons !== 1)
return
ev.preventDefault()
ev.stopPropagation()
const x = (ev.clientX - slideLeft.value - currentDrag.dx0) / scale.value
const y = (ev.clientY - slideTop.value - currentDrag.dy0) / scale.value
x0.value = clamp(x, -boundingWidth.value / 2 + minRemain, slideWidth.value + boundingWidth.value / 2 - minRemain)
y0.value = clamp(y, -boundingHeight.value / 2 + minRemain, slideHeight.value + boundingHeight.value / 2 - minRemain)
}
function onPointerup(ev: PointerEvent) {
if (!currentDrag)
return
ev.preventDefault()
ev.stopPropagation()
currentDrag = null
}
const ctrlClasses = `absolute border border-gray bg-gray dark:border-gray-500 dark:bg-gray-800 bg-opacity-30 `
function getCornerProps(isLeft: boolean, isTop: boolean) {
return {
onPointerdown,
onPointermove: (ev: PointerEvent) => {
if (!currentDrag || ev.buttons !== 1)
return
ev.preventDefault()
ev.stopPropagation()
let x = (ev.clientX - slideLeft.value) / scale.value
let y = (ev.clientY - slideTop.value) / scale.value
const { ltx, lty, rtx, rty, lbx, lby, rbx, rby } = currentDrag
const ratio = currentDrag.width / currentDrag.height
const wMin = Math.max(minSize, minSize * ratio)
function getSize(w1: number, h1: number) {
if (ev.shiftKey) {
const w = Math.max(w1, h1 * ratio, wMin)
const h = w / ratio
return { w, h }
}
else {
return { w: Math.max(w1, minSize), h: Math.max(h1, minSize) }
}
}
if (isLeft) {
if (isTop) {
const w1 = (rbx - x) * rotateCos.value + (rby - y) * rotateSin.value
const h1 = -(rbx - x) * rotateSin.value + (rby - y) * rotateCos.value
const { w, h } = getSize(w1, h1)
x = rbx - w * rotateCos.value + h * rotateSin.value
y = rby - w * rotateSin.value - h * rotateCos.value
}
else {
const w1 = (rtx - x) * rotateCos.value - (y - rty) * rotateSin.value
const h1 = (rtx - x) * rotateSin.value + (y - rty) * rotateCos.value
const { w, h } = getSize(w1, h1)
x = rtx - w * rotateCos.value - h * rotateSin.value
y = rty - w * rotateSin.value + h * rotateCos.value
}
}
else {
if (isTop) {
const w1 = (x - lbx) * rotateCos.value - (lby - y) * rotateSin.value
const h1 = (x - lbx) * rotateSin.value + (lby - y) * rotateCos.value
const { w, h } = getSize(w1, h1)
x = lbx + w * rotateCos.value + h * rotateSin.value
y = lby + w * rotateSin.value - h * rotateCos.value
}
else {
const w1 = (x - ltx) * rotateCos.value + (y - lty) * rotateSin.value
const h1 = -(x - ltx) * rotateSin.value + (y - lty) * rotateCos.value
const { w, h } = getSize(w1, h1)
x = ltx + w * rotateCos.value - h * rotateSin.value
y = lty + w * rotateSin.value + h * rotateCos.value
}
}
if (isLeft) {
if (isTop) {
x0.value = (x + rbx) / 2
y0.value = (y + rby) / 2
width.value = (rbx - x) * rotateCos.value + (rby - y) * rotateSin.value
height.value = -(rbx - x) * rotateSin.value + (rby - y) * rotateCos.value
}
else {
x0.value = (x + rtx) / 2
y0.value = (y + rty) / 2
width.value = (rtx - x) * rotateCos.value - (y - rty) * rotateSin.value
height.value = (rtx - x) * rotateSin.value + (y - rty) * rotateCos.value
}
}
else {
if (isTop) {
x0.value = (x + lbx) / 2
y0.value = (y + lby) / 2
width.value = (x - lbx) * rotateCos.value - (lby - y) * rotateSin.value
height.value = (x - lbx) * rotateSin.value + (lby - y) * rotateCos.value
}
else {
x0.value = (x + ltx) / 2
y0.value = (y + lty) / 2
width.value = (x - ltx) * rotateCos.value + (y - lty) * rotateSin.value
height.value = -(x - ltx) * rotateSin.value + (y - lty) * rotateCos.value
}
}
},
onPointerup,
style: {
width: `${ctrlSize}px`,
height: `${ctrlSize}px`,
margin: `-${ctrlSize / 2}px`,
left: isLeft !== arrowRevX.value ? '0' : undefined,
right: isLeft !== arrowRevX.value ? undefined : '0',
top: isTop !== arrowRevY.value ? '0' : undefined,
bottom: isTop !== arrowRevY.value ? undefined : '0',
cursor: isArrow ? 'move' : +isLeft + +isTop === 1 ? 'nesw-resize' : 'nwse-resize',
borderRadius: isArrow ? '50%' : undefined,
},
class: ctrlClasses,
}
}
function getBorderProps(dir: 'l' | 'r' | 't' | 'b') {
return {
onPointerdown,
onPointermove: (ev: PointerEvent) => {
if (!currentDrag || ev.buttons !== 1)
return
ev.preventDefault()
ev.stopPropagation()
const x = (ev.clientX - slideLeft.value) / scale.value
const y = (ev.clientY - slideTop.value) / scale.value
const { ltx, lty, rtx, rty, lbx, lby, rbx, rby } = currentDrag
if (dir === 'l') {
const rx = (rtx + rbx) / 2
const ry = (rty + rby) / 2
width.value = Math.max((rx - x) * rotateCos.value + (ry - y) * rotateSin.value, minSize)
x0.value = rx - width.value * rotateCos.value / 2
y0.value = ry - width.value * rotateSin.value / 2
}
else if (dir === 'r') {
const lx = (ltx + lbx) / 2
const ly = (lty + lby) / 2
width.value = Math.max((x - lx) * rotateCos.value + (y - ly) * rotateSin.value, minSize)
x0.value = lx + width.value * rotateCos.value / 2
y0.value = ly + width.value * rotateSin.value / 2
}
else if (dir === 't') {
const bx = (lbx + rbx) / 2
const by = (lby + rby) / 2
height.value = Math.max((by - y) * rotateCos.value - (bx - x) * rotateSin.value, minSize)
x0.value = bx + height.value * rotateSin.value / 2
y0.value = by - height.value * rotateCos.value / 2
}
else if (dir === 'b') {
const tx = (ltx + rtx) / 2
const ty = (lty + rty) / 2
height.value = Math.max((y - ty) * rotateCos.value - (x - tx) * rotateSin.value, minSize)
x0.value = tx - height.value * rotateSin.value / 2
y0.value = ty + height.value * rotateCos.value / 2
}
},
onPointerup,
style: {
width: `${ctrlSize}px`,
height: `${ctrlSize}px`,
margin: `-${ctrlSize / 2}px`,
left: dir === 'l' ? '0' : dir === 'r' ? `100%` : `50%`,
top: dir === 't' ? '0' : dir === 'b' ? `100%` : `50%`,
cursor: 'lr'.includes(dir) ? 'ew-resize' : 'ns-resize',
borderRadius: '50%',
},
class: ctrlClasses,
}
}
function getRotateProps() {
return {
onPointerdown,
onPointermove: (ev: PointerEvent) => {
if (!currentDrag || ev.buttons !== 1)
return
ev.preventDefault()
ev.stopPropagation()
const x = (ev.clientX - slideLeft.value - currentDrag.dx0) / scale.value - ctrlSize / 4
const y = (ev.clientY - slideTop.value - currentDrag.dy0) / scale.value - ctrlSize / 4
let angle = Math.atan2(y - y0.value, x - x0.value) * 180 / Math.PI + 90
const commonAngles = [0, 90, 180, 270, 360]
for (const a of commonAngles) {
if (Math.abs(angle - a) < 5) {
angle = a % 360
break
}
}
rotate.value = angle
},
onPointerup,
style: {
width: `${ctrlSize}px`,
height: `${ctrlSize}px`,
margin: `-${ctrlSize / 2}px`,
left: '50%',
top: '-20px',
cursor: 'grab',
borderRadius: '50%',
},
class: ctrlClasses,
}
}
const moveInterval = 20
const intervalFnOptions = {
immediate: false,
immediateCallback: false,
}
const moveLeft = useIntervalFn(() => {
if (boundingRight.value <= minRemain)
return
x0.value--
}, moveInterval, intervalFnOptions)
const moveRight = useIntervalFn(() => {
if (boundingLeft.value >= slideWidth.value - minRemain)
return
x0.value++
}, moveInterval, intervalFnOptions)
const moveUp = useIntervalFn(() => {
if (boundingBottom.value <= minRemain)
return
y0.value--
}, moveInterval, intervalFnOptions)
const moveDown = useIntervalFn(() => {
if (boundingTop.value >= slideHeight.value - minRemain)
return
y0.value++
}, moveInterval, intervalFnOptions)
watchEffect(() => {
function shortcut(key: string, fn: Pausable) {
if (magicKeys[key].value)
fn.resume()
else fn.pause()
}
shortcut('left', moveLeft)
shortcut('right', moveRight)
shortcut('up', moveUp)
shortcut('down', moveDown)
})
</script>
<template>
<div
v-if="Number.isFinite(x0)"
id="drag-control-container"
:data-drag-id="dragId"
:style="{
position: 'absolute',
zIndex: 100,
left: `${zoom * (x0 - Math.abs(width) / 2)}px`,
top: `${zoom * (y0 - Math.abs(height) / 2)}px`,
width: `${zoom * Math.abs(width)}px`,
height: `${zoom * Math.abs(height)}px`,
transformOrigin: 'center center',
transform: `rotate(${rotate}deg)`,
}"
@pointerdown="onPointerdown"
@pointermove="onPointermove"
@pointerup="onPointerup"
>
<div class="absolute inset-0 z-nav dark:b-gray-400" :class="isArrow ? '' : 'b b-dark'">
<template v-if="!autoHeight">
<div v-bind="getCornerProps(true, true)" />
<div v-bind="getCornerProps(false, false)" />
<template v-if="!isArrow">
<div v-bind="getCornerProps(true, false)" />
<div v-bind="getCornerProps(false, true)" />
</template>
</template>
<template v-if="!isArrow">
<div v-bind="getBorderProps('l')" />
<div v-bind="getBorderProps('r')" />
<template v-if="!autoHeight">
<div v-bind="getBorderProps('t')" />
<div v-bind="getBorderProps('b')" />
</template>
<div v-bind="getRotateProps()" />
<div
class="absolute -top-15px w-0 b b-dashed b-dark dark:b-gray-400"
:style="{
left: 'calc(50% - 1px)',
height: autoHeight ? '14px' : '10px',
}"
/>
</template>
</div>
</div>
</template>
+22
View File
@@ -0,0 +1,22 @@
<script setup lang="ts">
import { useDraggable, useLocalStorage } from '@vueuse/core'
import { ref } from 'vue'
const props = defineProps<{
storageKey?: string
initial?: { x: number, y: number }
}>()
const el = ref<HTMLElement | null>(null)
const initial = props.initial ?? { x: 0, y: 0 }
const point = props.storageKey
? useLocalStorage(props.storageKey, initial)
: ref(initial)
const { style } = useDraggable(el, { initialValue: point })
</script>
<template>
<div ref="el" class="fixed" :style="style">
<slot />
</div>
</template>
+140
View File
@@ -0,0 +1,140 @@
<script setup lang="ts">
import { Menu } from 'floating-vue'
import { useDrawings } from '../composables/useDrawings'
import Draggable from './Draggable.vue'
import IconButton from './IconButton.vue'
import VerticalDivider from './VerticalDivider.vue'
const {
brush,
canClear,
canRedo,
canUndo,
clear,
drauu,
drawingEnabled,
drawingMode,
drawingPinned,
brushColors,
} = useDrawings()
function undo() {
drauu.undo()
}
function redo() {
drauu.redo()
}
let lastDrawingMode: typeof drawingMode.value = 'stylus'
function setDrawingMode(mode: typeof drawingMode.value) {
drawingMode.value = mode
drawingEnabled.value = true
if (mode !== 'eraseLine')
lastDrawingMode = mode
}
function setBrushColor(color: typeof brush.color) {
brush.color = color
drawingEnabled.value = true
drawingMode.value = lastDrawingMode
}
</script>
<template>
<Draggable
v-if="drawingEnabled || drawingPinned"
class="flex flex-wrap text-xl p-2 gap-1 rounded-md bg-main shadow transition-opacity duration-200 z-nav border border-main"
:class="!drawingEnabled && drawingPinned ? 'opacity-40 hover:opacity-90' : ''"
storage-key="slidev-drawing-pos"
:initial-x="10"
:initial-y="10"
>
<IconButton title="Draw with stylus" :class="{ shallow: drawingMode !== 'stylus' }" @click="setDrawingMode('stylus')">
<div class="i-carbon:pen" />
</IconButton>
<IconButton title="Draw a line" :class="{ shallow: drawingMode !== 'line' }" @click="setDrawingMode('line')">
<svg width="1em" height="1em" class="-mt-0.5" preserveAspectRatio="xMidYMid meet" viewBox="0 0 24 24">
<path d="M21.71 3.29a1 1 0 0 0-1.42 0l-18 18a1 1 0 0 0 0 1.42a1 1 0 0 0 1.42 0l18-18a1 1 0 0 0 0-1.42z" fill="currentColor" />
</svg>
</IconButton>
<IconButton title="Draw an arrow" :class="{ shallow: drawingMode !== 'arrow' }" @click="setDrawingMode('arrow')">
<div class="i-carbon:arrow-up-right" />
</IconButton>
<IconButton title="Draw an ellipse" :class="{ shallow: drawingMode !== 'ellipse' }" @click="setDrawingMode('ellipse')">
<div class="i-carbon:radio-button" />
</IconButton>
<IconButton title="Draw a rectangle" :class="{ shallow: drawingMode !== 'rectangle' }" @click="setDrawingMode('rectangle')">
<div class="i-carbon:checkbox" />
</IconButton>
<IconButton title="Erase" :class="{ shallow: drawingMode !== 'eraseLine' }" @click="setDrawingMode('eraseLine')">
<div class="i-carbon:erase" />
</IconButton>
<VerticalDivider />
<Menu>
<IconButton title="Adjust stroke width" :class="{ shallow: drawingMode === 'eraseLine' }">
<svg viewBox="0 0 32 32" width="1.2em" height="1.2em">
<line x1="2" y1="15" x2="22" y2="4" stroke="currentColor" stroke-width="1" stroke-linecap="round" />
<line x1="2" y1="24" x2="28" y2="10" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<line x1="7" y1="31" x2="29" y2="19" stroke="currentColor" stroke-width="3" stroke-linecap="round" />
</svg>
</IconButton>
<template #popper>
<div class="flex bg-main p-2">
<div class="inline-block w-7 text-center">
{{ brush.size }}
</div>
<div class="pt-.5">
<input v-model="brush.size" type="range" min="1" max="15" @change="drawingMode = lastDrawingMode">
</div>
</div>
</template>
</Menu>
<IconButton
v-for="color of brushColors"
:key="color"
title="Set brush color"
:class="brush.color === color && drawingMode !== 'eraseLine' ? 'active' : 'shallow'"
@click="setBrushColor(color)"
>
<div
class="w-6 h-6 transition-all transform border"
:class="brush.color !== color ? 'rounded-1/2 scale-85 border-white' : 'rounded-md border-gray-300/50'"
:style="drawingEnabled ? { background: color } : { borderColor: color }"
/>
</IconButton>
<VerticalDivider />
<IconButton title="Undo" :class="{ disabled: !canUndo }" @click="undo()">
<div class="i-carbon:undo" />
</IconButton>
<IconButton title="Redo" :class="{ disabled: !canRedo }" @click="redo()">
<div class="i-carbon:redo" />
</IconButton>
<IconButton title="Delete" :class="{ disabled: !canClear }" @click="clear()">
<div class="i-carbon:trash-can" />
</IconButton>
<VerticalDivider />
<IconButton :title="drawingPinned ? 'Unpin drawing' : 'Pin drawing'" :class="{ shallow: !drawingPinned }" @click="drawingPinned = !drawingPinned">
<div v-show="drawingPinned" class="i-carbon:pin-filled transform -rotate-45" />
<div v-show="!drawingPinned" class="i-carbon:pin" />
</IconButton>
<IconButton
v-if="drawingEnabled"
:title="drawingPinned ? 'Drawing pinned' : 'Drawing unpinned'"
:class="{ shallow: !drawingEnabled }"
@click="drawingEnabled = !drawingEnabled"
>
<div v-show="drawingPinned" class="i-carbon:error" />
<div v-show="!drawingPinned" class="i-carbon:close-outline" />
</IconButton>
</Draggable>
</template>
<style>
.v-popper--theme-menu .v-popper__arrow-inner {
--uno: border-main;
}
</style>
+28
View File
@@ -0,0 +1,28 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useDrawings } from '../composables/useDrawings'
import { useSlideContext } from '../context'
const { drauu, drawingEnabled, loadCanvas } = useDrawings()
const scale = useSlideContext().$scale
const svg = ref<SVGSVGElement>()
onMounted(() => {
drauu.mount(svg.value!, svg.value!.parentElement!)
watch(scale, scale => drauu.options.coordinateScale = 1 / scale, { immediate: true })
loadCanvas()
})
onBeforeUnmount(() => {
drauu.unmount()
})
</script>
<template>
<svg
ref="svg"
class="w-full h-full absolute top-0"
:class="{ 'pointer-events-none': !drawingEnabled, 'touch-none': drawingEnabled }"
/>
</template>
+15
View File
@@ -0,0 +1,15 @@
<script setup lang="ts">
import { useDrawings } from '../composables/useDrawings'
defineProps<{ page: number }>()
const { drawingState } = useDrawings()
</script>
<template>
<svg
v-if="drawingState[page]"
class="w-full h-full absolute top-0 pointer-events-none"
v-html="drawingState[page]"
/>
</template>
+90
View File
@@ -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
View File
@@ -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 opacity-0.1 :disabled="disabled">
</div>
</template>
+41
View File
@@ -0,0 +1,41 @@
<script setup lang="ts">
import { Tooltip } from 'floating-vue'
defineProps<{
title: string
nested?: boolean | number
div?: boolean
description?: string
}>()
const emit = defineEmits<{
(event: 'reset'): void
}>()
function reset() {
emit('reset')
}
</script>
<template>
<component :is="div ? 'div' : 'label'" flex="~ row gap-2 items-center" select-none>
<div w-30 h-10 flex="~ gap-1 items-center">
<div
v-if="nested" i-ri-corner-down-right-line op40
:style="typeof nested === 'number' ? { marginLeft: `${nested * 0.5 + 0.5}rem` } : { marginLeft: '0.25rem' }"
/>
<div v-if="!description" op75 @dblclick="reset">
{{ title }}
</div>
<Tooltip v-else distance="10">
<div op75 text-right @dblclick="reset">
{{ title }}
</div>
<template #popper>
<div text-sm min-w-90 v-html="description" />
</template>
</Tooltip>
</div>
<slot />
</component>
</template>
+180
View File
@@ -0,0 +1,180 @@
<script setup lang="ts">
import TitleRenderer from '#slidev/title-renderer'
import Fuse from 'fuse.js'
import { computed, ref, watch } from 'vue'
import { useNav } from '../composables/useNav'
import { activeElement, showGotoDialog } from '../state'
const container = ref<HTMLDivElement>()
const input = ref<HTMLInputElement>()
const list = ref<HTMLUListElement>()
const items = ref<HTMLLIElement[]>()
const text = ref('')
const selectedIndex = ref(0)
const { go, slides } = useNav()
function notNull<T>(value: T | null | undefined): value is T {
return value !== null && value !== undefined
}
const fuse = computed(() => new Fuse(slides.value.map(i => i.meta?.slide).filter(notNull), {
keys: ['no', 'title'],
threshold: 0.3,
shouldSort: true,
minMatchCharLength: 1,
}))
const path = computed(() => text.value.startsWith('/') ? text.value.substring(1) : text.value)
const result = computed(() => fuse.value.search(path.value).map(result => result.item))
const valid = computed(() => !!result.value.length)
function goTo() {
if (valid.value) {
const item = result.value.at(selectedIndex.value || 0)
if (item)
go(item.no)
}
close()
}
function close() {
text.value = ''
showGotoDialog.value = false
}
function focusDown(event: Event) {
event.preventDefault()
selectedIndex.value++
if (selectedIndex.value >= result.value.length)
selectedIndex.value = 0
scroll()
}
function focusUp(event: Event) {
event.preventDefault()
selectedIndex.value--
if (selectedIndex.value <= -2)
selectedIndex.value = result.value.length - 1
scroll()
}
function scroll() {
const item = items.value?.[selectedIndex.value]
if (item && list.value) {
if (item.offsetTop + item.offsetHeight > list.value.offsetHeight + list.value.scrollTop) {
list.value.scrollTo({
behavior: 'smooth',
top: item.offsetTop + item.offsetHeight - list.value.offsetHeight + 1,
})
}
else if (item.offsetTop < list.value.scrollTop) {
list.value.scrollTo({
behavior: 'smooth',
top: item.offsetTop,
})
}
}
}
function updateText(event: Event) {
selectedIndex.value = 0
text.value = (event.target as HTMLInputElement).value
}
function select(no: number) {
go(no)
close()
}
watch(showGotoDialog, async (show) => {
if (show) {
text.value = ''
selectedIndex.value = 0
// delay the focus to avoid the g character coming from the key that triggered showGotoDialog
setTimeout(() => input.value?.focus(), 0)
}
else {
input.value?.blur()
}
})
watch(activeElement, () => {
if (!container.value?.contains(activeElement.value as Node))
close()
})
</script>
<template>
<div
id="slidev-goto-dialog"
ref="container"
class="fixed right-5 transition-all"
w-90 max-w-90 min-w-90
:class="showGotoDialog ? 'top-5' : '-top-20'"
>
<div
class="bg-main transform"
shadow="~"
p="x-4 y-2"
border="~ transparent rounded dark:main"
>
<input
id="slidev-goto-input"
ref="input"
:value="text"
type="text"
:disabled="!showGotoDialog"
class="outline-none bg-transparent"
placeholder="Goto..."
:class="{ 'text-red-400': !valid && text }"
@keydown.enter="goTo"
@keydown.escape="close"
@keydown.down="focusDown"
@keydown.up="focusUp"
@input="updateText"
>
</div>
<div
v-if="result.length > 0"
ref="list"
class="autocomplete-list"
shadow="~"
border="~ transparent rounded dark:main"
>
<ul table w-full border-collapse>
<li
v-for="(item, index) of result"
ref="items"
:key="item.id"
role="button"
tabindex="0"
cursor-pointer
hover="op100"
table-row
items-center
:border="index === 0 ? undefined : 't main'"
:class="selectedIndex === index ? 'bg-active op100' : 'op80'"
@click.stop.prevent="select(item.no)"
>
<div text-right op50 text-sm table-cell py-2 pl-4 pr-3 vertical-middle>
{{ item.no }}
</div>
<TitleRenderer table-cell py-2 pr-4 w-full :no="item.no" />
</li>
</ul>
</div>
</div>
</template>
<style scoped>
.autocomplete-list {
--uno: bg-main mt-1;
overflow: auto;
max-height: calc(100vh - 100px);
}
.autocomplete {
cursor: pointer;
}
</style>
+21
View File
@@ -0,0 +1,21 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
title: string
icon?: string
as?: string
to?: string
}>()
const type = computed(() => props.as || (props.to ? 'router-link' : 'button'))
</script>
<template>
<component :is="type" class="slidev-icon-btn" :title="title" :to="to">
<span class="sr-only">{{ title }}</span>
<slot>
<div :class="icon" />
</slot>
</component>
</template>
+51
View File
@@ -0,0 +1,51 @@
<script setup lang="ts">
import { useVModel } from '@vueuse/core'
import { computed } from 'vue'
import { configs } from '../env'
import Modal from './Modal.vue'
const props = defineProps({
modelValue: {
default: false,
},
})
const emit = defineEmits(['update:modelValue'])
const value = useVModel(props, 'modelValue', emit)
const hasInfo = computed(() => typeof configs.info === 'string')
</script>
<template>
<Modal v-model="value" class="px-6 py-4">
<div class="slidev-info-dialog slidev-layout flex flex-col gap-4 text-base">
<div
v-if="hasInfo"
class="mb-4"
v-html="configs.info"
/>
<a
href="https://github.com/slidevjs/slidev"
target="_blank"
class="!opacity-100 !border-none !text-current"
>
<div class="flex gap-1 children:my-auto">
<div class="opacity-50 text-sm mr-2">Powered by</div>
<img
class="w-5 h-5"
src="../assets/logo.png"
alt="Slidev logo"
>
<div style="color: #2082A6">
<b>Sli</b>dev
</div>
</div>
</a>
</div>
</Modal>
</template>
<style lang="postcss">
.slidev-info-dialog {
@apply !p-4 max-w-150;
}
</style>
+40
View File
@@ -0,0 +1,40 @@
<script setup lang="ts">
import { onClickOutside, useVModel } from '@vueuse/core'
import { ref } from 'vue'
const props = defineProps({
modelValue: {
default: false,
},
disabled: {
default: false,
},
})
const emit = defineEmits<{
(e: any): void
}>()
const value = useVModel(props, 'modelValue', emit, { passive: true })
const el = ref<HTMLDivElement>()
onClickOutside(el, () => {
value.value = false
})
</script>
<template>
<div ref="el" class="flex relative">
<button :class="{ disabled }" @click="value = !value">
<slot name="button" :class="{ disabled }" />
</button>
<KeepAlive>
<div
v-if="value"
class="rounded-md bg-main text-main shadow-xl absolute bottom-10 left-0 z-menu"
dark:border="~ main"
>
<slot name="menu" />
</div>
</KeepAlive>
</div>
</template>
+38
View File
@@ -0,0 +1,38 @@
<script setup lang="ts">
import { useVModel } from '@vueuse/core'
const props = defineProps({
modelValue: {
default: false,
},
class: {
default: '',
},
})
const emit = defineEmits(['update:modelValue'])
const value = useVModel(props, 'modelValue', emit)
function onClick() {
value.value = false
}
</script>
<template>
<KeepAlive>
<div v-if="value" class="fixed top-0 bottom-0 left-0 right-0 grid z-modal">
<div
bg="black opacity-80"
class="absolute top-0 bottom-0 left-0 right-0 -z-1"
@click="onClick()"
/>
<div
class="m-auto rounded-md bg-main shadow"
dark:border="~ main"
:class="props.class"
>
<slot />
</div>
</div>
</KeepAlive>
</template>
+184
View File
@@ -0,0 +1,184 @@
<script setup lang="ts">
import CustomNavControls from '#slidev/custom-nav-controls'
import { computed, ref, shallowRef } from 'vue'
import { useDrawings } from '../composables/useDrawings'
import { useNav } from '../composables/useNav'
import { configs } from '../env'
import { isColorSchemaConfigured, isDark, toggleDark } from '../logic/dark'
import { activeElement, breakpoints, fullscreen, presenterLayout, showEditor, showInfoDialog, showPresenterCursor, toggleOverview, togglePresenterLayout } from '../state'
import { downloadPDF } from '../utils'
import IconButton from './IconButton.vue'
import MenuButton from './MenuButton.vue'
import Settings from './Settings.vue'
import VerticalDivider from './VerticalDivider.vue'
const props = defineProps({
persist: {
default: false,
},
})
const {
currentSlideNo,
hasNext,
hasPrev,
isEmbedded,
isPresenter,
isPresenterAvailable,
next,
prev,
total,
enterPresenter,
exitPresenter,
} = useNav()
const {
brush,
drawingEnabled,
} = useDrawings()
const md = breakpoints.smaller('md')
const { isFullscreen, toggle: toggleFullscreen } = fullscreen
const root = ref<HTMLDivElement>()
function onMouseLeave() {
if (root.value && activeElement.value && root.value.contains(activeElement.value))
activeElement.value.blur()
}
const barStyle = computed(() => props.persist
? 'text-$slidev-controls-foreground bg-transparent'
: 'rounded-md bg-main shadow-xl dark:border dark:border-main')
const RecordingControls = shallowRef<any>()
if (__SLIDEV_FEATURE_RECORD__)
import('./RecordingControls.vue').then(v => RecordingControls.value = v.default)
</script>
<template>
<nav ref="root" class="flex flex-col">
<div
class="flex flex-wrap-reverse text-xl gap-0.5 p-1 lg:p-2"
:class="barStyle"
@mouseleave="onMouseLeave"
>
<IconButton v-if="!isEmbedded" :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 :class="{ disabled: !hasPrev }" title="Go to previous slide" @click="prev">
<div class="i-carbon:arrow-left" />
</IconButton>
<IconButton :class="{ disabled: !hasNext }" title="Go to next slide" @click="next">
<div class="i-carbon:arrow-right" />
</IconButton>
<IconButton v-if="!isEmbedded" title="Show slide overview" @click="toggleOverview()">
<div class="i-carbon:apps" />
</IconButton>
<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>
<VerticalDivider />
<template v-if="!isEmbedded">
<template v-if="!isPresenter && !md && RecordingControls">
<RecordingControls />
<VerticalDivider />
</template>
<IconButton
v-if="isPresenter"
:title="showPresenterCursor ? 'Hide presenter cursor' : 'Show presenter cursor'"
@click="showPresenterCursor = !showPresenterCursor"
>
<ph-cursor-fill v-if="showPresenterCursor" />
<ph-cursor-duotone v-else />
</IconButton>
</template>
<template v-if="__SLIDEV_FEATURE_DRAWINGS__ && (!configs.drawings.presenterOnly || isPresenter) && !isEmbedded">
<IconButton class="relative" :title="drawingEnabled ? 'Hide drawing toolbar' : 'Show drawing toolbar'" @click="drawingEnabled = !drawingEnabled">
<div class="i-carbon:pen" />
<div
v-if="drawingEnabled"
class="absolute left-1 right-1 bottom-0 h-0.7 rounded-full"
:style="{ background: brush.color }"
/>
</IconButton>
<VerticalDivider />
</template>
<template v-if="!isEmbedded">
<IconButton v-if="isPresenter" title="Play Mode" @click="exitPresenter">
<div class="i-carbon:presentation-file" />
</IconButton>
<IconButton v-if="__SLIDEV_FEATURE_PRESENTER__ && isPresenterAvailable" title="Presenter Mode" @click="enterPresenter">
<div class="i-carbon:user-speaker" />
</IconButton>
<IconButton
v-if="__DEV__ && __SLIDEV_FEATURE_EDITOR__"
:title="showEditor ? 'Hide editor' : 'Show editor'"
class="lt-md:hidden"
@click="showEditor = !showEditor"
>
<div class="i-carbon:text-annotation-toggle" />
</IconButton>
<IconButton v-if="isPresenter" title="Toggle Presenter Layout" class="aspect-ratio-initial flex items-center" @click="togglePresenterLayout">
<div class="i-carbon:template" />
{{ presenterLayout }}
</IconButton>
</template>
<template v-if="!__DEV__">
<IconButton v-if="configs.download" title="Download as PDF" @click="downloadPDF">
<div class="i-carbon:download" />
</IconButton>
</template>
<template v-if="__SLIDEV_FEATURE_BROWSER_EXPORTER__">
<IconButton title="Browser Exporter" to="/export">
<div class="i-carbon:document-pdf" />
</IconButton>
</template>
<IconButton
v-if="!isPresenter && configs.info && !isEmbedded"
title="Show info"
@click="showInfoDialog = !showInfoDialog"
>
<div class="i-carbon:information" />
</IconButton>
<template v-if="!isPresenter && !isEmbedded">
<MenuButton>
<template #button>
<IconButton title="More Options">
<div class="i-carbon:settings-adjust" />
</IconButton>
</template>
<template #menu>
<Settings />
</template>
</MenuButton>
</template>
<VerticalDivider v-if="!isEmbedded" />
<div class="h-40px flex" p="l-1 t-0.5 r-2" text="sm leading-2">
<div class="my-auto">
{{ currentSlideNo }}
<span class="opacity-50">/ {{ total }}</span>
</div>
</div>
<CustomNavControls />
</div>
</nav>
</template>
+175
View File
@@ -0,0 +1,175 @@
<script setup lang="ts">
import type { ClicksContext } from '@slidev/types'
import { computed, nextTick, onMounted, ref, watch, watchEffect } from 'vue'
import { CLICKS_MAX } from '../constants'
const props = withDefaults(
defineProps<{
class?: string | string[]
noteHtml?: string
note?: string
highlight?: boolean
placeholder?: string
clicksContext?: ClicksContext
autoScroll?: boolean
}>(),
{
highlight: true,
},
)
const emit = defineEmits<{
(type: 'markerDblclick', e: MouseEvent, clicks: number): void
(type: 'markerClick', e: MouseEvent, clicks: number): void
}>()
const withClicks = computed(() => props.clicksContext != null && props.noteHtml?.includes('slidev-note-click-mark'))
const noteDisplay = ref<HTMLElement | null>(null)
const CLASS_FADE = 'slidev-note-fade'
const CLASS_MARKER = 'slidev-note-click-mark'
function processNote() {
if (!noteDisplay.value || !withClicks.value)
return
const markers = Array.from(noteDisplay.value.querySelectorAll(`.${CLASS_MARKER}`)) as HTMLElement[]
const markersMap = new Map<HTMLElement, number>()
const parentsMap = new Map<HTMLElement, [divider: HTMLElement | null, dividerClicks: number][]>()
let lastClicks = 0
for (const marker of markers) {
const clicks = Number(marker.dataset!.clicks)
markersMap.set(marker, clicks)
// Set parent clicks map
let n = marker
let p = marker.parentElement
while (p && n !== noteDisplay.value) {
if (!parentsMap.has(p))
parentsMap.set(p, [[null, lastClicks]])
parentsMap.get(p)!.push([n, clicks])
n = p
p = p.parentElement
}
lastClicks = clicks
}
const siblingsMap = new Map<HTMLElement, number>()
for (const [parent, dividers] of parentsMap) {
let hasPrefix = false
let dividerIdx = 0
for (const sibling of Array.from(parent.childNodes)) {
let skip = false
while (sibling === dividers[dividerIdx + 1]?.[0]) {
skip = true
dividerIdx++
}
if (skip)
continue
// Convert sibling text nodes to spans
let siblingEl = sibling as HTMLElement
if (sibling.nodeType === 3 /* text node */) {
if (!sibling.textContent?.trim())
continue
siblingEl = document.createElement('span')
siblingEl.textContent = sibling.textContent
parent.insertBefore(siblingEl, sibling)
sibling.remove()
}
hasPrefix ||= dividerIdx === 0
siblingsMap.set(siblingEl, dividers[dividerIdx][1])
}
if (!hasPrefix)
dividers[0][1] = -1
}
// Apply
return (current: number) => {
const enabled = props.highlight
for (const [parent, clicks] of parentsMap)
parent.classList.toggle(CLASS_FADE, enabled && !clicks.some(([_, c]) => c === current))
for (const [parent, clicks] of siblingsMap)
parent.classList.toggle(CLASS_FADE, enabled && clicks !== current)
for (const [marker, clicks] of markersMap) {
marker.classList.remove(CLASS_FADE)
marker.classList.toggle(`${CLASS_MARKER}-past`, enabled && clicks < current)
marker.classList.toggle(`${CLASS_MARKER}-active`, enabled && clicks === current)
marker.classList.toggle(`${CLASS_MARKER}-next`, enabled && clicks === current + 1)
marker.classList.toggle(`${CLASS_MARKER}-future`, enabled && clicks > current + 1)
marker.ondblclick = enabled
? (e) => {
emit('markerDblclick', e, clicks)
if (e.defaultPrevented)
return
props.clicksContext!.current = clicks
e.stopPropagation()
e.stopImmediatePropagation()
}
: null
marker.onclick = enabled
? (e) => {
emit('markerClick', e, clicks)
}
: null
if (enabled && props.autoScroll && clicks === current)
marker.scrollIntoView({ block: 'center', behavior: 'smooth' })
}
}
}
const applyHighlight = ref<ReturnType<typeof processNote>>()
watch(
() => [props.noteHtml, props.highlight],
() => {
nextTick(() => {
applyHighlight.value = processNote()
})
},
{ immediate: true },
)
onMounted(() => {
processNote()
})
watchEffect(() => {
const current = props.clicksContext?.current ?? CLICKS_MAX
applyHighlight.value?.(current)
})
</script>
<template>
<div
v-if="noteHtml"
ref="noteDisplay"
class="prose overflow-auto outline-none slidev-note"
:class="[props.class, withClicks ? 'slidev-note-with-clicks' : '']"
v-html="noteHtml"
/>
<div
v-else-if="note"
class="prose overflow-auto outline-none slidev-note"
:class="props.class"
>
<p v-text="note" />
</div>
<div
v-else
class="prose overflow-auto outline-none opacity-50 italic select-none slidev-note"
:class="props.class"
>
<p v-text="props.placeholder || 'No notes.'" />
</div>
</template>
<style>
.slidev-note :first-child {
margin-top: 0;
}
</style>
+142
View File
@@ -0,0 +1,142 @@
<script setup lang="ts">
import type { ClicksContext } from '@slidev/types'
import type { PropType } from 'vue'
import { ignorableWatch, onClickOutside, useVModel } from '@vueuse/core'
import { nextTick, ref, toRef, watch, watchEffect } from 'vue'
import { useDynamicSlideInfo } from '../composables/useSlideInfo'
import NoteDisplay from './NoteDisplay.vue'
const props = defineProps({
no: {
type: Number,
required: true,
},
class: {
default: '',
},
editing: {
default: false,
},
style: {
default: () => ({}),
},
placeholder: {
default: 'No notes for this slide',
},
clicksContext: {
type: Object as PropType<ClicksContext>,
},
highlight: {
default: true,
},
autoHeight: {
default: false,
},
})
const emit = defineEmits<{
(type: 'update:editing', value: boolean): void
(type: 'markerDblclick', e: MouseEvent, clicks: number): void
(type: 'markerClick', e: MouseEvent, clicks: number): void
}>()
const editing = useVModel(props, 'editing', emit, { passive: true })
const { info, update } = useDynamicSlideInfo(toRef(props, 'no'))
const note = ref('')
let timer: any
// Send back the note on changes
const { ignoreUpdates } = ignorableWatch(
note,
(v) => {
if (!editing.value)
return
const id = props.no
clearTimeout(timer)
timer = setTimeout(() => {
update({ note: v }, id)
}, 500)
},
)
// Update note value when info changes
watch(
() => info.value?.note,
(value = '') => {
if (editing.value)
return
clearTimeout(timer)
ignoreUpdates(() => {
note.value = value
})
},
{ immediate: true, flush: 'sync' },
)
const inputEl = ref<HTMLTextAreaElement>()
const inputHeight = ref<number | null>()
watchEffect(() => {
if (editing.value)
inputEl.value?.focus()
})
onClickOutside(inputEl, () => {
editing.value = false
})
function calculateEditorHeight() {
if (!props.autoHeight || !inputEl.value || !editing.value)
return
if (inputEl.value.scrollHeight > inputEl.value.clientHeight)
inputEl.value.style.height = `${inputEl.value.scrollHeight}px`
}
function onKeyDown(e: KeyboardEvent) {
// Override save shortcut on editing mode
if (editing.value && e.metaKey && e.key === 's') {
e.preventDefault()
update({ note: note.value }, props.no)
}
}
watch(
[note, editing],
() => {
nextTick(() => {
calculateEditorHeight()
})
},
{ flush: 'post', immediate: true },
)
</script>
<template>
<NoteDisplay
v-if="!editing"
class="border-transparent border-2"
:class="[props.class, note ? '' : 'opacity-25 italic select-none']"
:style="props.style"
:note="note || placeholder"
:note-html="info?.noteHTML"
:clicks-context="clicksContext"
:auto-scroll="!autoHeight"
:highlight="props.highlight"
@marker-click="(e, clicks) => emit('markerClick', e, clicks)"
@marker-dblclick="(e, clicks) => emit('markerDblclick', e, clicks)"
/>
<textarea
v-else
ref="inputEl"
v-model="note"
class="prose resize-none overflow-auto outline-none bg-transparent block border-primary border-2"
style="line-height: 1.75;"
:style="[props.style, inputHeight != null ? { height: `${inputHeight}px` } : {}]"
:class="props.class"
:placeholder="placeholder"
@keydown.esc="editing = false"
@keydown="onKeyDown"
/>
</template>
+22
View File
@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { ClicksContext } from '@slidev/types'
import { useSlideInfo } from '../composables/useSlideInfo'
import NoteDisplay from './NoteDisplay.vue'
const props = defineProps<{
no: number
class?: string
clicksContext?: ClicksContext
}>()
const { info } = useSlideInfo(props.no)
</script>
<template>
<NoteDisplay
:class="props.class"
:note="info?.note"
:note-html="info?.noteHTML"
:clicks-context="clicksContext"
/>
</template>
+19
View File
@@ -0,0 +1,19 @@
<script setup lang="ts">
import { sharedState } from '../state/shared'
</script>
<template>
<div
v-if="sharedState.cursor"
class="absolute top-0 left-0 right-0 bottom-0 pointer-events-none text-xl"
>
<ph-cursor-fill
class="absolute stroke-white dark:stroke-black"
:style="{
left: `${sharedState.cursor.x}%`,
top: `${sharedState.cursor.y}%`,
strokeWidth: 16,
}"
/>
</div>
</template>
+39
View File
@@ -0,0 +1,39 @@
<script setup lang="ts">
import { provideLocal } from '@vueuse/core'
import { computed } from 'vue'
import { useNav } from '../composables/useNav'
import { injectionSlideScale } from '../constants'
import { configs, slideAspect, slideWidth } from '../env'
import PrintSlide from './PrintSlide.vue'
const props = defineProps<{
width: number
}>()
const { slides, printRange } = useNav()
const width = computed(() => props.width)
const height = computed(() => props.width / slideAspect.value)
const screenAspect = computed(() => width.value / height.value)
const scale = computed(() => {
if (screenAspect.value < slideAspect.value)
return width.value / slideWidth.value
return (height.value * slideAspect.value) / slideWidth.value
})
const className = computed(() => ({
'select-none': !configs.selectable,
}))
provideLocal(injectionSlideScale, scale)
</script>
<template>
<div id="print-container" :class="className">
<div id="print-content">
<PrintSlide v-for="no of printRange" :key="no" :route="slides[no - 1]" />
</div>
</div>
</template>
+30
View File
@@ -0,0 +1,30 @@
<script setup lang="ts">
import type { SlideRoute } from '@slidev/types'
import { createFixedClicks } from '../composables/useClicks'
import { useFixedNav, useNav } from '../composables/useNav'
import { CLICKS_MAX } from '../constants'
import PrintSlideClick from './PrintSlideClick.vue'
const { route } = defineProps<{ hidden?: boolean, route: SlideRoute }>()
const { isPrintWithClicks } = useNav()
const clicks0 = createFixedClicks(route, () => isPrintWithClicks.value ? 0 : CLICKS_MAX)
</script>
<template>
<PrintSlideClick
v-show="!hidden"
:nav="useFixedNav(route, clicks0)"
/>
<template v-if="isPrintWithClicks">
<!--
clicks0.total can be any number >=0 when rendering.
So total-clicksStart can be negative in intermediate states.
-->
<PrintSlideClick
v-for="i in Math.max(0, clicks0.total - clicks0.clicksStart)"
v-show="!hidden"
:key="i"
:nav="useFixedNav(route, createFixedClicks(route, i + clicks0.clicksStart))"
/>
</template>
</template>
@@ -0,0 +1,66 @@
<script setup lang="ts">
import type { SlidevContextNav } from '../composables/useNav'
import { GlobalBottom, GlobalTop } from '#slidev/global-layers'
import { provideLocal } from '@vueuse/core'
import { computed, reactive, shallowRef, useTemplateRef } from 'vue'
import { injectionSlideElement, injectionSlidevContext } from '../constants'
import { configs, slideHeight, slideWidth } from '../env'
import { getSlideClass } from '../utils'
import SlideWrapper from './SlideWrapper.vue'
const { nav } = defineProps<{
nav: SlidevContextNav
}>()
const route = computed(() => nav.currentSlideRoute.value)
const style = computed(() => ({
height: `${slideHeight.value}px`,
width: `${slideWidth.value}px`,
}))
const DrawingPreview = shallowRef<any>()
if (__SLIDEV_FEATURE_DRAWINGS__ || __SLIDEV_FEATURE_DRAWINGS_PERSIST__)
import('./DrawingPreview.vue').then(v => (DrawingPreview.value = v.default))
const id = computed(() =>
`${route.value.no.toString().padStart(3, '0')}-${(nav.clicks.value + 1).toString().padStart(2, '0')}`,
)
provideLocal(injectionSlidevContext, reactive({
nav,
configs,
themeConfigs: computed(() => configs.themeConfig),
}))
provideLocal(injectionSlideElement, useTemplateRef('slide-element'))
</script>
<template>
<div :id="id" ref="slide-element" class="print-slide-container" :style="style">
<GlobalBottom />
<SlideWrapper
:clicks-context="nav.clicksContext.value"
:class="getSlideClass(route)"
:route="route"
/>
<template
v-if="
(__SLIDEV_FEATURE_DRAWINGS__
|| __SLIDEV_FEATURE_DRAWINGS_PERSIST__)
&& DrawingPreview
"
>
<DrawingPreview :page="route.no" />
</template>
<GlobalTop />
</div>
</template>
<style scoped lang="postcss">
.print-slide-container {
@apply relative overflow-hidden break-after-page translate-0 bg-main;
}
</style>
+176
View File
@@ -0,0 +1,176 @@
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import { computed, ref, watchEffect } from 'vue'
import { createFixedClicks } from '../composables/useClicks'
import { useNav } from '../composables/useNav'
import { CLICKS_MAX } from '../constants'
import { configs, pathPrefix } from '../env'
import { currentOverviewPage, overviewRowCount } from '../logic/overview'
import { breakpoints, showOverview, windowSize } from '../state'
import DrawingPreview from './DrawingPreview.vue'
import IconButton from './IconButton.vue'
import SlideContainer from './SlideContainer.vue'
import SlideWrapper from './SlideWrapper.vue'
const { currentSlideNo, go: goSlide, slides } = useNav()
function close() {
showOverview.value = false
}
function go(page: number) {
goSlide(page)
close()
}
function focus(page: number) {
if (page === currentOverviewPage.value)
return true
return false
}
const xs = breakpoints.smaller('xs')
const sm = breakpoints.smaller('sm')
const padding = 4 * 16 * 2
const gap = 2 * 16
const cardWidth = computed(() => {
if (xs.value)
return windowSize.width.value - padding
else if (sm.value)
return (windowSize.width.value - padding - gap) / 2
return 300
})
const rowCount = computed(() => {
return Math.floor((windowSize.width.value - padding) / (cardWidth.value + gap))
})
const keyboardBuffer = ref<string>('')
useEventListener('keypress', (e) => {
if (!showOverview.value) {
keyboardBuffer.value = ''
return
}
if (e.key === 'Enter') {
e.preventDefault()
if (keyboardBuffer.value) {
go(+keyboardBuffer.value)
keyboardBuffer.value = ''
}
else {
go(currentOverviewPage.value)
}
return
}
const num = Number.parseInt(e.key.replace(/\D/g, ''))
if (Number.isNaN(num)) {
keyboardBuffer.value = ''
return
}
if (!keyboardBuffer.value && num === 0)
return
keyboardBuffer.value += String(num)
// beyond the number of slides, reset
if (+keyboardBuffer.value >= slides.value.length) {
keyboardBuffer.value = ''
return
}
const extactMatch = slides.value.findIndex(i => `/${i.no}` === keyboardBuffer.value)
if (extactMatch !== -1)
currentOverviewPage.value = extactMatch + 1
// When the input number is the largest at the number of digits, we go to that page directly.
if (+keyboardBuffer.value * 10 > slides.value.length) {
go(+keyboardBuffer.value)
keyboardBuffer.value = ''
}
})
watchEffect(() => {
// Watch currentPage, make sure every time we open overview,
// we focus on the right page.
currentOverviewPage.value = currentSlideNo.value
// Watch rowCount, make sure up and down shortcut work correctly.
overviewRowCount.value = rowCount.value
})
</script>
<template>
<Transition
enter-active-class="duration-150 ease-out"
enter-from-class="opacity-0 scale-102 !backdrop-blur-0px"
leave-active-class="duration-200 ease-in"
leave-to-class="opacity-0 scale-102 !backdrop-blur-0px"
>
<div
v-if="showOverview"
class="fixed left-0 right-0 top-0 h-[calc(var(--vh,1vh)*100)] z-modal bg-main !bg-opacity-75 p-16 py-20 overflow-y-auto backdrop-blur-5px select-none"
@click="close"
>
<div
class="grid gap-y-4 gap-x-8 w-full"
:style="`grid-template-columns: repeat(auto-fit,minmax(${cardWidth}px,1fr))`"
>
<div
v-for="(route, idx) of slides"
:key="route.no"
class="relative"
>
<div
class="inline-block border rounded overflow-hidden bg-main hover:border-primary transition"
:class="(focus(idx + 1) || currentOverviewPage === idx + 1) ? 'border-primary' : 'border-main'"
@click="go(route.no)"
>
<SlideContainer
:key="route.no"
:no="route.no"
:use-snapshot="configs.overviewSnapshots"
:width="cardWidth"
class="pointer-events-none"
>
<SlideWrapper
:clicks-context="createFixedClicks(route, CLICKS_MAX)"
:route="route"
render-context="overview"
/>
<DrawingPreview :page="route.no" />
</SlideContainer>
</div>
<div
class="absolute top-0"
:style="`left: ${cardWidth + 5}px`"
>
<template v-if="keyboardBuffer && String(idx + 1).startsWith(keyboardBuffer)">
<span class="text-green font-bold">{{ keyboardBuffer }}</span>
<span class="opacity-50">{{ String(idx + 1).slice(keyboardBuffer.length) }}</span>
</template>
<span v-else class="opacity-50">
{{ idx + 1 }}
</span>
</div>
</div>
</div>
</div>
</Transition>
<div v-if="showOverview" class="fixed top-4 right-4 z-modal text-gray-400 flex flex-col items-center gap-2">
<IconButton title="Close" class="text-2xl" @click="close">
<div class="i-carbon:close" />
</IconButton>
<IconButton
v-if="__SLIDEV_FEATURE_PRESENTER__"
as="a"
title="Slides Overview"
target="_blank"
:href="`${pathPrefix}overview`"
tab-index="-1"
class="text-2xl"
>
<div class="i-carbon:list-boxes" />
</IconButton>
</div>
</template>
+5
View File
@@ -0,0 +1,5 @@
# Internal Components
Components for built-in UIs.
Not exposed to auto-importing. Will need to be imported to use in other components.
@@ -0,0 +1,65 @@
<script setup lang="ts">
import { useLocalStorage } from '@vueuse/core'
import { onMounted, watch } from 'vue'
import { recorder } from '../logic/recording'
import { currentCamera, showRecordingDialog } from '../state'
import DevicesList from './DevicesList.vue'
import IconButton from './IconButton.vue'
import MenuButton from './MenuButton.vue'
const {
recording,
showAvatar,
streamCamera,
stopRecording,
toggleAvatar,
} = recorder
const previousAvatar = useLocalStorage('slidev-webcam-show', false)
watch(showAvatar, () => {
previousAvatar.value = showAvatar.value
})
function toggleRecording() {
if (recording.value)
stopRecording()
else
showRecordingDialog.value = true
}
onMounted(() => {
if (previousAvatar.value && !showAvatar.value)
toggleAvatar()
})
</script>
<template>
<IconButton
v-if="currentCamera !== 'none'"
class="<md:hidden"
:class="{ 'text-green-500': Boolean(showAvatar && streamCamera) }"
title="Toggle camera view"
@click="toggleAvatar"
>
<div class="i-carbon:user-avatar" />
</IconButton>
<IconButton
:class="{ 'text-red-500': recording }"
:title="recording ? 'Stop record video' : 'Record video'"
@click="toggleRecording"
>
<div v-if="recording" class="i-carbon:stop-outline" />
<div v-else class="i-carbon:video" />
</IconButton>
<MenuButton :disabled="recording">
<template #button>
<IconButton title="Select recording device" class="h-full !text-sm !px-0 aspect-initial">
<div class="i-carbon:chevron-up opacity-50" />
</IconButton>
</template>
<template #menu>
<DevicesList />
</template>
</MenuButton>
</template>
+125
View File
@@ -0,0 +1,125 @@
<script setup lang="ts">
import { useVModel } from '@vueuse/core'
import { nextTick } from 'vue'
import { getFilename, mimeType, recordCamera, recorder, recordingName } from '../logic/recording'
import DevicesList from './DevicesList.vue'
import Modal from './Modal.vue'
const props = defineProps({
modelValue: {
default: false,
},
})
const emit = defineEmits<{
(e: any): void
}>()
const value = useVModel(props, 'modelValue', emit)
const { startRecording } = recorder
function close() {
value.value = false
}
async function start() {
close()
await nextTick()
startRecording({
mimeType: mimeType.value,
})
}
</script>
<template>
<Modal v-model="value" class="px-6 py-4 recording-dialog flex flex-col gap-2">
<div class="flex gap-2 text-xl">
<div class="i-carbon:video my-auto" />Recording
</div>
<div class="grid grid-cols-2 gap-4">
<div class="flex flex-col gap-2 py-2">
<div class="form-text">
<label for="title">Recording Name</label>
<input
v-model="recordingName"
class="bg-transparent text-current"
name="title"
type="text"
placeholder="Enter the title..."
>
<div class="text-xs w-full opacity-50 py-2">
<div>This will be used in the output filename that might <br>help you better organize your recording chips.</div>
</div>
</div>
<div class="form-check">
<input
v-model="recordCamera"
name="record-camera"
type="checkbox"
>
<label for="record-camera" @click="recordCamera = !recordCamera">Record camera separately</label>
</div>
<div class="text-xs w-full opacity-50">
<div class="mt-2 opacity-50">
Enumerated filenames
</div>
<div class="font-mono">
{{ getFilename('screen', mimeType) }}
</div>
<div v-if="recordCamera" class="font-mono">
{{ getFilename('camera', mimeType) }}
</div>
</div>
</div>
<DevicesList />
</div>
<div class="flex my-1">
<button class="cancel" @click="close">
Cancel
</button>
<div class="flex-auto" />
<button @click="start">
Start
</button>
</div>
</Modal>
</template>
<style lang="postcss">
.recording-dialog {
.form-text {
@apply flex flex-col;
label {
@apply text-xs uppercase opacity-50 tracking-widest py-1;
}
}
.form-check {
@apply leading-5;
* {
@apply my-auto align-middle;
}
label {
@apply ml-1 text-sm select-none;
}
}
input[type='text'] {
@apply border border-main rounded px-2 py-1;
}
button {
@apply bg-orange-400 text-white px-4 py-1 rounded border-b-2 border-orange-600;
@apply hover:(bg-orange-500 border-orange-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);
}
}
</style>
+62
View File
@@ -0,0 +1,62 @@
<script setup lang="ts">
import type { PropType } from 'vue'
import type { SelectionItem } from './types'
import { useVModel } from '@vueuse/core'
const props = defineProps({
modelValue: {
type: [Object, String, Number, Boolean] as PropType<any>,
},
title: {
type: String,
},
items: {
type: Array as PropType<SelectionItem<any>[]>,
},
})
const emit = defineEmits<{
(e: any): void
}>()
const value = useVModel(props, 'modelValue', emit, { passive: true })
</script>
<template>
<div class="select-list">
<div class="title">
{{ title }}
</div>
<div class="items">
<div
v-for="item of items"
:key="item.value"
class="item"
:class="{ active: value === item.value }"
@click="() => { value = item.value; item.onClick?.() }"
>
<div class="i-carbon:checkmark text-green-500 mya" :class="{ 'opacity-0': value !== item.value }" />
<div :class="{ 'opacity-50': value !== item.value }">
{{ item.display || item.value }}
</div>
</div>
</div>
</div>
</template>
<style lang="postcss" scoped>
.select-list {
@apply my-2;
}
.item {
@apply flex rounded whitespace-nowrap py-1 gap-1 px-2 cursor-default hover:bg-gray-400 hover:bg-opacity-10;
svg {
@apply mr-1 -ml-2 my-auto;
}
}
.title {
@apply text-xs uppercase opacity-50 tracking-widest px-7 py-1 select-none text-nowrap;
}
</style>
+40
View File
@@ -0,0 +1,40 @@
<script setup lang="ts">
import type { SelectionItem } from './types'
import { useWakeLock } from '@vueuse/core'
import { slideScale, wakeLockEnabled } from '../state'
import SelectList from './SelectList.vue'
const scaleItems: SelectionItem<number>[] = [
{
display: 'Fit',
value: 0,
},
{
display: '1:1',
value: 1,
},
]
const { isSupported } = useWakeLock()
const wakeLockItems: SelectionItem<boolean>[] = [
{
display: 'Enabled',
value: true,
},
{
display: 'Disabled',
value: false,
},
]
</script>
<template>
<div class="text-sm select-none mb-2">
<SelectList v-model="slideScale" title="Scale" :items="scaleItems" />
<SelectList
v-if="__SLIDEV_FEATURE_WAKE_LOCK__ && isSupported"
v-model="wakeLockEnabled" title="Wake lock" :items="wakeLockItems"
/>
</div>
</template>
+24
View File
@@ -0,0 +1,24 @@
<script setup lang="ts">
import { computed, ref, watchEffect } from 'vue'
const props = defineProps<{
innerHtml: string
}>()
const emit = defineEmits<{
(event: 'shadow', div: ShadowRoot): void
}>()
const el = ref<HTMLDivElement>()
const shadow = computed(() => el.value ? (el.value.shadowRoot || el.value.attachShadow({ mode: 'open' })) : null)
watchEffect(() => {
if (shadow.value && props.innerHtml) {
emit('shadow', shadow.value)
shadow.value.innerHTML = props.innerHtml
}
})
</script>
<template>
<div ref="el" />
</template>
+43
View File
@@ -0,0 +1,43 @@
<script setup lang="ts">
import { getHighlighter } from '#slidev/shiki'
import { ref, shallowRef } from 'vue'
const props = defineProps<{
placeholder?: string
}>()
const content = defineModel<string>({ required: true })
const textareaEl = ref<HTMLTextAreaElement | null>(null)
const highlight = shallowRef<Awaited<ReturnType<typeof getHighlighter>> | null>(null)
getHighlighter().then(h => highlight.value = h)
</script>
<template>
<div class="absolute left-3 right-0 inset-y-2 font-mono overflow-x-hidden overflow-y-auto cursor-text">
<div v-if="highlight" class="relative w-full h-max min-h-full">
<div class="relative w-full h-max" v-html="`${highlight(content, 'markdown')}&nbsp;`" />
<textarea
ref="textareaEl" v-model="content" :placeholder="props.placeholder"
class="absolute inset-0 resize-none text-transparent bg-transparent focus:outline-none caret-black dark:caret-white overflow-y-hidden"
/>
</div>
</div>
</template>
<style scoped>
:deep(code),
textarea {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
font-feature-settings: normal;
font-variation-settings: normal;
font-size: 1em;
text-wrap: wrap;
word-break: break-all;
display: block;
width: 100%;
}
:deep(pre.shiki) {
background-color: transparent;
}
</style>
+179
View File
@@ -0,0 +1,179 @@
<script setup lang="ts">
import { throttledWatch, useEventListener } from '@vueuse/core'
import { computed, ref, watch } from 'vue'
import { useNav } from '../composables/useNav'
import { useDynamicSlideInfo } from '../composables/useSlideInfo'
import { activeElement, editorHeight, editorWidth, isInputting, showEditor, isEditorVertical as vertical } from '../state'
import IconButton from './IconButton.vue'
import ShikiEditor from './ShikiEditor.vue'
const props = defineProps<{
resize?: boolean
}>()
const { currentSlideNo, openInEditor } = useNav()
const tab = ref<'content' | 'note'>('content')
const content = ref('')
const note = ref('')
const dirty = ref(false)
const { info, update } = useDynamicSlideInfo(currentSlideNo)
watch(
info,
(v) => {
if (!isInputting.value) {
note.value = (v?.note || '').trim()
const frontmatterPart = v?.frontmatterRaw?.trim() ? `---\n${v.frontmatterRaw.trim()}\n---\n\n` : ''
content.value = frontmatterPart + (v?.content || '').trim()
dirty.value = false
}
},
{ immediate: true },
)
async function save() {
dirty.value = false
let frontmatterRaw: string | undefined
const contentOnly = content.value.trim().replace(/^---\n([\s\S]*?)\n---\n/, (_, f) => {
frontmatterRaw = f
return ''
})
await update({
note: note.value || undefined,
content: contentOnly,
frontmatterRaw,
})
}
function close() {
showEditor.value = false
}
useEventListener('keydown', (e) => {
if (activeElement.value?.tagName === 'TEXTAREA' && e.code === 'KeyS' && (e.ctrlKey || e.metaKey)) {
save()
e.preventDefault()
}
})
const contentRef = computed({
get() { return content.value },
set(v) {
if (content.value.trim() !== v.trim())
dirty.value = true
content.value = v
},
})
const noteRef = computed({
get() { return note.value },
set(v) {
note.value = v
dirty.value = true
},
})
const handlerDown = ref(false)
function onHandlerDown() {
handlerDown.value = true
}
function updateSize(v?: number) {
if (vertical.value)
editorHeight.value = Math.min(Math.max(300, v ?? editorHeight.value), window.innerHeight - 200)
else
editorWidth.value = Math.min(Math.max(318, v ?? editorWidth.value), window.innerWidth - 200)
}
function switchTab(newTab: typeof tab.value) {
tab.value = newTab
// @ts-expect-error force cast
document.activeElement?.blur?.()
}
if (props.resize) {
useEventListener('pointermove', (e) => {
if (handlerDown.value) {
updateSize(vertical.value
? window.innerHeight - e.pageY
: window.innerWidth - e.pageX)
}
}, { passive: true })
useEventListener('pointerup', () => {
handlerDown.value = false
})
useEventListener('resize', () => {
updateSize()
})
}
throttledWatch(
[content, note],
() => {
if (dirty.value)
save()
},
{ throttle: 500 },
)
</script>
<template>
<div
v-if="resize" class="fixed bg-gray-400 select-none opacity-0 hover:opacity-10 z-dragging"
:class="vertical ? 'left-0 right-0 w-full h-10px' : 'top-0 bottom-0 w-10px h-full'" :style="{
opacity: handlerDown ? '0.3' : undefined,
bottom: vertical ? `${editorHeight - 5}px` : undefined,
right: !vertical ? `${editorWidth - 5}px` : undefined,
cursor: vertical ? 'row-resize' : 'col-resize',
}" @pointerdown="onHandlerDown"
/>
<div
class="shadow bg-main p-2 pt-4 grid grid-rows-[max-content_1fr] h-full overflow-hidden"
:class="resize ? 'border-l border-gray-400 border-opacity-20' : ''"
:style="resize ? {
height: vertical ? `${editorHeight}px` : undefined,
width: !vertical ? `${editorWidth}px` : undefined,
} : {}"
>
<div class="flex pb-2 text-xl -mt-1">
<div class="mr-4 rounded flex">
<IconButton
title="Switch to content tab" :class="tab === 'content' ? 'text-primary' : ''"
@click="switchTab('content')"
>
<div class="i-carbon:account" />
</IconButton>
<IconButton
title="Switch to notes tab" :class="tab === 'note' ? 'text-primary' : ''"
@click="switchTab('note')"
>
<div class="i-carbon:align-box-bottom-right" />
</IconButton>
</div>
<span class="text-2xl pt-1">
{{ tab === 'content' ? 'Slide' : 'Notes' }}
</span>
<div class="flex-auto" />
<template v-if="resize">
<IconButton v-if="vertical" title="Dock to right" @click="vertical = false">
<div class="i-carbon:open-panel-right" />
</IconButton>
<IconButton v-else title="Dock to bottom" @click="vertical = true">
<div class="i-carbon:open-panel-bottom" />
</IconButton>
</template>
<IconButton title="Open in editor" @click="openInEditor()">
<div class="i-carbon:launch" />
</IconButton>
<IconButton title="Close" @click="close">
<div class="i-carbon:close" />
</IconButton>
</div>
<div class="relative overflow-hidden rounded" style="background-color: var(--slidev-code-background)">
<ShikiEditor v-show="tab === 'content'" v-model="contentRef" placeholder="Create slide content..." />
<ShikiEditor v-show="tab === 'note'" v-model="noteRef" placeholder="Write some notes..." />
</div>
</div>
</template>
+104
View File
@@ -0,0 +1,104 @@
<script setup lang="ts">
import { provideLocal, useElementSize, useStyleTag } from '@vueuse/core'
import { computed, onMounted, ref } from 'vue'
import { useNav } from '../composables/useNav'
import { injectionSlideElement, injectionSlideScale } from '../constants'
import { slideAspect, slideHeight, slideWidth } from '../env'
import { snapshotManager } from '../logic/snapshot'
import { slideScale } from '../state'
const props = defineProps({
width: {
type: Number,
},
meta: {
default: () => ({}) as any,
},
isMain: {
type: Boolean,
default: false,
},
no: {
type: Number,
required: false,
},
useSnapshot: {
type: Boolean,
default: false,
},
})
const { isPrintMode } = useNav()
const container = ref<HTMLDivElement | null>(null)
const containerSize = useElementSize(container)
const slideElement = ref<HTMLElement | null>(null)
const width = computed(() => props.width ?? containerSize.width.value)
const height = computed(() => props.width ? props.width / slideAspect.value : containerSize.height.value)
const scale = computed(() => {
if (slideScale.value && !isPrintMode.value)
return +slideScale.value
return Math.min(width.value / slideWidth.value, height.value / slideHeight.value)
})
const contentStyle = computed(() => ({
'height': `${slideHeight.value}px`,
'width': `${slideWidth.value}px`,
'transform': `translate(-50%, -50%) scale(${scale.value})`,
'--slidev-slide-scale': scale.value,
}))
const containerStyle = computed(() => props.width
? {
width: `${props.width}px`,
height: `${props.width / slideAspect.value}px`,
}
: {},
)
if (props.isMain)
useStyleTag(computed(() => `:root { --slidev-slide-scale: ${scale.value}; }`))
provideLocal(injectionSlideScale, scale)
provideLocal(injectionSlideElement, slideElement)
const snapshot = computed(() => {
if (!props.useSnapshot || props.no == null)
return undefined
return snapshotManager.getSnapshot(props.no)
})
onMounted(() => {
if (container.value && props.useSnapshot && props.no != null) {
snapshotManager.captureSnapshot(props.no, container.value)
}
})
</script>
<template>
<div v-if="!snapshot" :id="isMain ? 'slide-container' : undefined" ref="container" class="slidev-slide-container" :style="containerStyle">
<div :id="isMain ? 'slide-content' : undefined" ref="slideElement" class="slidev-slide-content" :style="contentStyle">
<slot />
</div>
<slot name="controls" />
</div>
<!-- Image preview -->
<img
v-else
:src="snapshot"
class="w-full object-cover"
:style="containerStyle"
>
</template>
<style scoped lang="postcss">
.slidev-slide-container {
@apply relative w-full h-full overflow-hidden;
}
.slidev-slide-content {
@apply absolute left-1/2 top-1/2 overflow-hidden bg-main;
}
</style>
+19
View File
@@ -0,0 +1,19 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
const timeout = ref(false)
onMounted(() => {
setTimeout(() => {
timeout.value = true
}, 200)
})
</script>
<template>
<div class="h-full w-full flex items-center justify-center gap-2 slidev-slide-loading">
<template v-if="timeout">
<div class="i-svg-spinners-90-ring-with-bg text-xl" />
<div>Loading slide...</div>
</template>
</div>
</template>
+73
View File
@@ -0,0 +1,73 @@
<script setup lang="ts">
import type { ClicksContext, RenderContext, SlideRoute } from '@slidev/types'
import type { CSSProperties, PropType } from 'vue'
import { SlideBottom, SlideTop } from '#slidev/global-layers'
import { provideLocal } from '@vueuse/core'
import { computed, ref, toRef } from 'vue'
import { injectionClicksContext, injectionCurrentPage, injectionFrontmatter, injectionRenderContext, injectionRoute, injectionSlideZoom } from '../constants'
import { configs } from '../env'
import { getSlideClass } from '../utils'
const props = defineProps({
clicksContext: {
type: Object as PropType<ClicksContext>,
required: true,
},
renderContext: {
type: String as PropType<RenderContext>,
default: 'slide',
},
route: {
type: Object as PropType<SlideRoute>,
required: true,
},
})
const zoom = computed(() => props.route.meta?.slide?.frontmatter.zoom ?? 1)
provideLocal(injectionRoute, props.route)
provideLocal(injectionFrontmatter, props.route.meta.slide.frontmatter)
provideLocal(injectionCurrentPage, ref(props.route.no))
provideLocal(injectionRenderContext, ref(props.renderContext))
provideLocal(injectionClicksContext, toRef(props, 'clicksContext'))
provideLocal(injectionSlideZoom, zoom)
const zoomStyle = computed(() => {
return zoom.value === 1
? undefined
: {
width: `${100 / zoom.value}%`,
height: `${100 / zoom.value}%`,
transformOrigin: 'top left',
transform: `scale(${zoom.value})`,
}
})
const style = computed<CSSProperties>(() => ({
...zoomStyle.value,
'user-select': configs.selectable ? undefined : 'none',
}))
</script>
<template>
<div
:data-slidev-no="props.route.no"
:class="getSlideClass(route, ['slide', 'presenter'].includes(props.renderContext) ? '' : 'disable-view-transition')"
:style="style"
:lang="props.route.meta.slide.frontmatter.lang"
>
<SlideBottom />
<component :is="props.route.component" />
<SlideTop />
</div>
</template>
<style scoped>
.disable-view-transition:deep(*) {
view-transition-name: none !important;
}
.slidev-page {
position: absolute;
inset: 0;
}
</style>
+114
View File
@@ -0,0 +1,114 @@
<script setup lang="ts">
import type { SlideRoute } from '@slidev/types'
import { GlobalBottom, GlobalTop } from '#slidev/global-layers'
import { recomputeAllPoppers } from 'floating-vue'
import { computed, shallowRef, TransitionGroup, watchEffect } from 'vue'
import { createFixedClicks } from '../composables/useClicks'
import { useNav } from '../composables/useNav'
import { useViewTransition } from '../composables/useViewTransition'
import { CLICKS_MAX } from '../constants'
import { skipTransition } from '../logic/hmr'
import { activeDragElement } from '../state'
import DragControl from './DragControl.vue'
import SlideWrapper from './SlideWrapper.vue'
defineProps<{
renderContext: 'slide' | 'presenter'
}>()
const {
currentSlideRoute,
currentTransition,
getPrimaryClicks,
prevRoute,
nextRoute,
slides,
isPrintMode,
isPrintWithClicks,
clicksDirection,
printRange,
} = useNav()
function preloadRoute(route: SlideRoute) {
if (route.meta.preload !== false) {
route.meta.__preloaded = true
route.load()
}
}
// preload current, prev and next slides
watchEffect(() => {
preloadRoute(currentSlideRoute.value)
preloadRoute(prevRoute.value)
preloadRoute(nextRoute.value)
})
// preload all slides after 3s
watchEffect((onCleanup) => {
const routes = slides.value
const timeout = setTimeout(() => {
routes.forEach(preloadRoute)
}, 3000)
onCleanup(() => clearTimeout(timeout))
})
const hasViewTransition = useViewTransition()
const DrawingLayer = shallowRef<any>()
if (__SLIDEV_FEATURE_DRAWINGS__ || __SLIDEV_FEATURE_DRAWINGS_PERSIST__)
import('./DrawingLayer.vue').then(v => DrawingLayer.value = v.default)
const loadedRoutes = computed(() => isPrintMode.value
? printRange.value.map(no => slides.value[no - 1])
: slides.value.filter(r => r.meta?.__preloaded || r === currentSlideRoute.value),
)
function onAfterLeave() {
// After transition, we disable it so HMR won't trigger it again
// We will turn it back on `nav.go` so the normal navigation would still work
skipTransition.value = true
// recompute poppers after transition
recomputeAllPoppers()
}
</script>
<template>
<!-- Global Bottom -->
<GlobalBottom />
<!-- Slides -->
<component
:is="hasViewTransition && !isPrintMode ? 'div' : TransitionGroup"
v-bind="skipTransition || isPrintMode ? {} : currentTransition"
id="slideshow"
tag="div"
:class="{
'slidev-nav-go-forward': clicksDirection > 0,
'slidev-nav-go-backward': clicksDirection < 0,
}"
@after-leave="onAfterLeave"
>
<template v-for="route of loadedRoutes" :key="route.no">
<SlideWrapper
v-if="Math.abs(route.no - currentSlideRoute.no) <= 20"
v-show="route === currentSlideRoute"
:clicks-context="isPrintMode && !isPrintWithClicks ? createFixedClicks(route, CLICKS_MAX) : getPrimaryClicks(route)"
:route="route"
:render-context="renderContext"
/>
</template>
</component>
<DragControl v-if="activeDragElement" :data="activeDragElement" />
<!-- Global Top -->
<GlobalTop />
<template v-if="(__SLIDEV_FEATURE_DRAWINGS__ || __SLIDEV_FEATURE_DRAWINGS_PERSIST__) && DrawingLayer">
<DrawingLayer />
</template>
</template>
<style scoped>
#slideshow {
height: 100%;
}
</style>
@@ -0,0 +1,3 @@
<template>
<div class="w-1px opacity-10 bg-current m-1 lg:m-2" />
</template>
+91
View File
@@ -0,0 +1,91 @@
<script setup lang="ts">
import { useDraggable, useEventListener, useLocalStorage } from '@vueuse/core'
import { computed, onMounted, ref, watchEffect } from 'vue'
import { recorder } from '../logic/recording'
import { currentCamera } from '../state'
const size = useLocalStorage('slidev-webcam-size', Math.round(Math.min(window.innerHeight, (window.innerWidth) / 8)))
const position = useLocalStorage('slidev-webcam-pos', {
x: window.innerWidth - size.value - 30,
y: window.innerHeight - size.value - 30,
}, { deep: true })
const frame = ref<HTMLDivElement | undefined>()
const handler = ref<HTMLDivElement | undefined>()
const video = ref<HTMLVideoElement | undefined>()
const { streamCamera, showAvatar } = recorder
const { style: containerStyle } = useDraggable(frame, {
initialValue: position,
onMove({ x, y }) {
position.value.x = x
position.value.y = y
},
})
const { isDragging: handlerDown } = useDraggable(handler, {
onMove({ x, y }) {
size.value = Math.max(10, Math.min(x - position.value.x, y - position.value.y) / 0.8536)
},
})
watchEffect(() => {
if (video.value)
video.value.srcObject = streamCamera.value!
}, { flush: 'post' })
const frameStyle = computed(() => ({
width: `${size.value}px`,
height: `${size.value}px`,
}))
const handleStyle = computed(() => ({
width: '14px',
height: '14px',
// 0.5 + 0.5 / sqrt(2)
top: `${size.value * 0.8536 - 7}px`,
left: `${size.value * 0.8536 - 7}px`,
cursor: 'nwse-resize',
}))
function fixPosition() {
// move back if the camera is outside of the canvas
if (position.value.x >= window.innerWidth)
position.value.x = window.innerWidth - size.value - 30
if (position.value.y >= window.innerHeight)
position.value.y = window.innerHeight - size.value - 30
}
useEventListener('resize', fixPosition)
onMounted(fixPosition)
</script>
<template>
<div
v-if="streamCamera && showAvatar && currentCamera !== 'none'"
class="fixed z-camera"
:style="containerStyle"
>
<div
ref="frame"
class="rounded-full shadow bg-gray-400 bg-opacity-10 overflow-hidden object-cover"
:style="frameStyle"
>
<video
ref="video"
autoplay
muted
volume="0"
class="object-cover min-w-full min-h-full rounded-full"
style="transform: rotateY(180deg);"
/>
</div>
<div
ref="handler"
class="absolute bottom-0 right-0 rounded-full bg-main shadow opacity-0 shadow z-dragging hover:opacity-100 dark:border dark:border-true-gray-700"
:style="handleStyle"
:class="handlerDown ? '!opacity-100' : ''"
/>
</div>
</template>
+5
View File
@@ -0,0 +1,5 @@
export interface SelectionItem<T> {
value: T
display: string
onClick?: () => void
}
+38
View File
@@ -0,0 +1,38 @@
import type { CSSProperties } from 'vue'
/**
* Resolve urls from frontmatter and append with the base url
*/
export function resolveAssetUrl(url: string) {
if (url.startsWith('/'))
return import.meta.env.BASE_URL + url.slice(1)
return url
}
export function handleBackground(background?: string, dim = false, backgroundSize = 'cover'): CSSProperties {
const isColor = background && (background[0] === '#' || background.startsWith('rgb'))
const style = {
background: isColor
? background
: undefined,
color: (background && !isColor)
? 'white'
: undefined,
backgroundImage: isColor
? undefined
: background
? dim
? `linear-gradient(#0005, #0008), url(${resolveAssetUrl(background)})`
: `url("${resolveAssetUrl(background)}")`
: undefined,
backgroundRepeat: 'no-repeat',
backgroundPosition: 'center',
backgroundSize,
}
if (!style.background)
delete style.background
return style
}
+5
View File
@@ -0,0 +1,5 @@
<template>
<div class="px-4 py-10 text-center text-teal-700 dark:text-gray-200">
404
</div>
</template>
+7
View File
@@ -0,0 +1,7 @@
<template>
<div class="slidev-layout center h-full grid place-content-center">
<div class="my-auto">
<slot />
</div>
</div>
</template>
+5
View File
@@ -0,0 +1,5 @@
<template>
<div class="slidev-layout cover">
<slot />
</div>
</template>

Some files were not shown because too many files have changed in this diff Show More