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