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
+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>