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
+39
View File
@@ -0,0 +1,39 @@
import type { ContextMenuItem } from '@slidev/types'
import type { ComputedRef } from 'vue'
import { shallowRef } from 'vue'
import { useNav } from '../composables/useNav'
import { configs, mode } from '../env'
import setupContextMenu from '../setup/context-menu'
export const currentContextMenu = shallowRef<null | {
x: number
y: number
items: ComputedRef<ContextMenuItem[]>
}>(null)
export function openContextMenu(x: number, y: number) {
currentContextMenu.value = {
x,
y,
items: setupContextMenu(),
}
}
export function closeContextMenu() {
currentContextMenu.value = null
}
export function onContextMenu(ev: MouseEvent) {
if (configs.contextMenu !== true && configs.contextMenu != null && configs.contextMenu !== mode)
return
if (ev.shiftKey || ev.defaultPrevented)
return
const { isEmbedded } = useNav()
if (isEmbedded.value)
return
openContextMenu(ev.pageX, ev.pageY)
ev.preventDefault()
ev.stopPropagation()
}
+49
View File
@@ -0,0 +1,49 @@
import { isClient, useLocalStorage, usePreferredDark, useToggle } from '@vueuse/core'
import { computed, watch } from 'vue'
import { configs } from '../env'
const preferredDark = usePreferredDark()
const store = useLocalStorage('slidev-color-schema', 'auto')
export const isColorSchemaConfigured = computed(() => configs.colorSchema !== 'auto')
export const isDark = computed<boolean>({
get() {
if (isColorSchemaConfigured.value)
return configs.colorSchema === 'dark'
return store.value === 'auto'
? preferredDark.value
: store.value === 'dark'
},
set(v) {
if (isColorSchemaConfigured.value)
return
store.value = v === preferredDark.value
? 'auto'
: v ? 'dark' : 'light'
},
})
export const toggleDark = useToggle(isDark)
if (isClient) {
const CSS_DISABLE_TRANS = '*,*::before,*::after{-webkit-transition:none!important;-moz-transition:none!important;-o-transition:none!important;-ms-transition:none!important;transition:none!important}'
watch(
isDark,
(v) => {
const style = window!.document.createElement('style')
style.appendChild(document.createTextNode(CSS_DISABLE_TRANS))
window!.document.head.appendChild(style)
const html = document.querySelector('html')!
html.classList.toggle('dark', v)
html.classList.toggle('light', !v)
// Calling getComputedStyle forces the browser to redraw
// @ts-expect-error unused variable
const _ = window!.getComputedStyle(style!).opacity
document.head.removeChild(style!)
},
{ immediate: true },
)
}
+3
View File
@@ -0,0 +1,3 @@
import { ref } from 'vue'
export const skipTransition = ref(false)
+39
View File
@@ -0,0 +1,39 @@
import { computed, ref } from 'vue'
import { slides } from './slides'
// To have same format(.value) as max, wrap it with ref.
const min = ref(1)
const max = computed(() => slides.value.length)
export const currentOverviewPage = ref(0)
export const overviewRowCount = ref(0)
export function prevOverviewPage() {
if (currentOverviewPage.value > min.value)
currentOverviewPage.value -= 1
}
export function nextOverviewPage() {
if (currentOverviewPage.value < max.value)
currentOverviewPage.value += 1
}
export function upOverviewPage() {
if (currentOverviewPage.value > min.value) {
let current = currentOverviewPage.value - overviewRowCount.value
if (current < min.value)
current = min.value
currentOverviewPage.value = current
}
}
export function downOverviewPage() {
if (currentOverviewPage.value < max.value) {
let current = currentOverviewPage.value + overviewRowCount.value
if (current > max.value)
current = max.value
currentOverviewPage.value = current
}
}
+253
View File
@@ -0,0 +1,253 @@
import type RecorderType from 'recordrtc'
import type { Options as RecorderOptions } from 'recordrtc'
import type { Ref } from 'vue'
import { isTruthy } from '@antfu/utils'
import { useDevicesList, useEventListener, useLocalStorage } from '@vueuse/core'
import { nextTick, ref, shallowRef, watch } from 'vue'
import { currentCamera, currentMic } from '../state'
type Defined<T> = T extends undefined ? never : T
type MimeType = Defined<RecorderOptions['mimeType']>
export const recordingName = ref('')
export const recordCamera = ref(true)
export const mimeType = useLocalStorage<MimeType>('slidev-record-mimetype', 'video/webm')
export const mimeExtMap: Record<string, string> = {
'video/webm': 'webm',
'video/webm;codecs=h264': 'mp4',
'video/x-matroska;codecs=avc1': 'mkv',
}
export function getFilename(media?: string, mimeType?: string) {
const d = new Date()
const pad = (v: number) => `${v}`.padStart(2, '0')
const date = `${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`
const ext = mimeType ? mimeExtMap[mimeType] : 'webm'
return `${[media, recordingName.value, date].filter(isTruthy).join('-')}.${ext}`
}
function getSupportedMimeTypes() {
if (MediaRecorder && typeof MediaRecorder.isTypeSupported === 'function')
return Object.keys(mimeExtMap).filter(mime => MediaRecorder.isTypeSupported(mime))
return []
}
export const supportedMimeTypes = getSupportedMimeTypes()
export const {
devices,
videoInputs: cameras,
audioInputs: microphones,
ensurePermissions: ensureDevicesListPermissions,
} = useDevicesList({
onUpdated() {
if (currentCamera.value !== 'none') {
if (!cameras.value.find(i => i.deviceId === currentCamera.value))
currentCamera.value = cameras.value[0]?.deviceId || 'default'
}
if (currentMic.value !== 'none') {
if (!microphones.value.find(i => i.deviceId === currentMic.value))
currentMic.value = microphones.value[0]?.deviceId || 'default'
}
},
})
export function download(name: string, url: string) {
const a = document.createElement('a')
a.setAttribute('href', url)
a.setAttribute('download', name)
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
export function useRecording() {
const recording = ref(false)
const showAvatar = ref(false)
const recorderCamera: Ref<RecorderType | undefined> = shallowRef()
const recorderSlides: Ref<RecorderType | undefined> = shallowRef()
const streamCamera: Ref<MediaStream | undefined> = shallowRef()
const streamCapture: Ref<MediaStream | undefined> = shallowRef()
const streamSlides: Ref<MediaStream | undefined> = shallowRef()
const config: RecorderOptions = {
type: 'video',
bitsPerSecond: 4 * 256 * 8 * 1024,
// Extending recording limit as default is only 1h (see https://github.com/muaz-khan/RecordRTC/issues/144)
timeSlice: 24 * 60 * 60 * 1000,
}
async function toggleAvatar() {
if (currentCamera.value === 'none')
return
if (showAvatar.value) {
showAvatar.value = false
if (!recording.value)
closeStream(streamCamera)
}
else {
await startCameraStream()
if (streamCamera.value)
showAvatar.value = !!streamCamera.value
}
}
async function startCameraStream() {
await ensureDevicesListPermissions()
await nextTick()
if (!streamCamera.value) {
if (currentCamera.value === 'none' && currentMic.value === 'none')
return
streamCamera.value = await navigator.mediaDevices.getUserMedia({
video: (currentCamera.value === 'none' || recordCamera.value !== true)
? false
: {
deviceId: currentCamera.value,
},
audio: currentMic.value === 'none'
? false
: {
deviceId: currentMic.value,
},
})
}
}
watch(currentCamera, async (v) => {
if (v === 'none') {
closeStream(streamCamera)
}
else {
if (recording.value)
return
// restart camera stream
if (streamCamera.value) {
closeStream(streamCamera)
await startCameraStream()
}
}
})
async function startRecording(customConfig?: RecorderOptions) {
await ensureDevicesListPermissions()
const { default: Recorder } = await import('recordrtc')
await startCameraStream()
streamCapture.value = await navigator.mediaDevices.getDisplayMedia({
video: {
// aspectRatio: 1.6,
frameRate: 15,
width: 3840,
height: 2160,
// @ts-expect-error missing types
cursor: 'motion',
resizeMode: 'crop-and-scale',
},
selfBrowserSurface: 'include',
})
streamCapture.value.addEventListener('inactive', stopRecording)
// We need to create a new Stream to merge video and audio to have the inactive event working on streamCapture
streamSlides.value = new MediaStream()
streamCapture.value!.getVideoTracks().forEach(videoTrack => streamSlides.value!.addTrack(videoTrack))
// merge config
Object.assign(config, customConfig)
if (streamCamera.value) {
const audioTrack = streamCamera.value!.getAudioTracks()?.[0]
if (audioTrack)
streamSlides.value!.addTrack(audioTrack)
recorderCamera.value = new Recorder(
streamCamera.value!,
config,
)
recorderCamera.value.startRecording()
}
recorderSlides.value = new Recorder(
streamSlides.value!,
config,
)
recorderSlides.value.startRecording()
recording.value = true
}
async function stopRecording() {
recording.value = false
recorderCamera.value?.stopRecording(() => {
if (recordCamera.value) {
const blob = recorderCamera.value!.getBlob()
const url = URL.createObjectURL(blob)
download(getFilename('camera', config.mimeType), url)
window.URL.revokeObjectURL(url)
}
recorderCamera.value = undefined
if (!showAvatar.value)
closeStream(streamCamera)
})
recorderSlides.value?.stopRecording(() => {
const blob = recorderSlides.value!.getBlob()
const url = URL.createObjectURL(blob)
download(getFilename('screen', config.mimeType), url)
window.URL.revokeObjectURL(url)
closeStream(streamCapture)
closeStream(streamSlides)
recorderSlides.value = undefined
})
}
function closeStream(stream: Ref<MediaStream | undefined>) {
const s = stream.value
if (!s)
return
s.getTracks().forEach((i) => {
i.stop()
s.removeTrack(i)
})
stream.value = undefined
}
function toggleRecording() {
if (recording.value)
stopRecording()
else
startRecording()
}
useEventListener('beforeunload', (event) => {
if (!recording.value)
return
// eslint-disable-next-line no-alert
if (confirm('Recording is not saved yet, do you want to leave?'))
return
event.preventDefault()
event.returnValue = ''
})
return {
recording,
showAvatar,
toggleRecording,
startRecording,
stopRecording,
toggleAvatar,
recorderCamera,
recorderSlides,
streamCamera,
streamCapture,
streamSlides,
}
}
export const recorder = useRecording()
+37
View File
@@ -0,0 +1,37 @@
import type { WritableComputedRef } from 'vue'
import { computed, nextTick, unref } from 'vue'
import { useRouter } from 'vue-router'
export function useRouteQuery<T extends string | string[]>(
name: string,
defaultValue?: T,
{
mode = 'replace',
} = {},
): WritableComputedRef<T> {
const router = useRouter()
return computed<any>({
get() {
const data = router.currentRoute.value.query[name]
if (data == null)
return defaultValue ?? null
if (Array.isArray(data))
return data.filter(Boolean)
return data
},
set(v) {
nextTick(() => {
const oldValue = router.currentRoute.value.query[name]
if ((oldValue ?? defaultValue?.toString()) === v.toString())
return
router[unref(mode) as 'replace' | 'push']({
query: {
...router.currentRoute.value.query,
[name]: `${v}` === defaultValue ? undefined : v,
},
})
})
},
})
}
+61
View File
@@ -0,0 +1,61 @@
import { computed, ref } from 'vue'
export async function startScreenshotSession(width: number, height: number) {
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const context = canvas.getContext('2d')!
const video = document.createElement('video')
video.width = width
video.height = height
const captureStream = ref<MediaStream | null>(await navigator.mediaDevices.getDisplayMedia({
video: {
// Use a rather small frame rate
frameRate: 10,
// @ts-expect-error missing types
cursor: 'never',
},
selfBrowserSurface: 'include',
preferCurrentTab: true,
}))
captureStream.value!.addEventListener('inactive', dispose)
video.srcObject = captureStream.value!
video.play()
function screenshot(element: HTMLElement) {
if (!captureStream.value)
throw new Error('captureStream inactive')
context.clearRect(0, 0, width, height)
const { left, top, width: elWidth } = element.getBoundingClientRect()
context.drawImage(
video,
left * window.devicePixelRatio,
top * window.devicePixelRatio,
elWidth * window.devicePixelRatio,
elWidth / width * height * window.devicePixelRatio,
0,
0,
width,
height,
)
return canvas.toDataURL('image/png')
}
function dispose() {
captureStream.value?.getTracks().forEach(track => track.stop())
captureStream.value = null
}
return {
isActive: computed(() => !!captureStream.value),
screenshot,
dispose,
}
}
export type ScreenshotSession = Awaited<ReturnType<typeof startScreenshotSession>>
const chromeVersion = window.navigator.userAgent.match(/Chrome\/(\d+)/)?.[1]
export const isScreenshotSupported = chromeVersion ? Number(chromeVersion) >= 94 : false
+58
View File
@@ -0,0 +1,58 @@
import type { ShortcutOptions } from '@slidev/types'
import type { Fn, KeyFilter } from '@vueuse/core'
import type { Ref } from 'vue'
import { onKeyStroke } from '@vueuse/core'
import { and, not } from '@vueuse/math'
import { watch } from 'vue'
import { useNav } from '../composables/useNav'
import setupShortcuts from '../setup/shortcuts'
import { fullscreen, isInputting, isOnFocus, magicKeys, shortcutsEnabled } from '../state'
export function registerShortcuts() {
const { isPrintMode } = useNav()
const enabled = and(not(isInputting), not(isOnFocus), not(isPrintMode), shortcutsEnabled)
const allShortcuts = setupShortcuts()
const shortcuts = new Map<string | Ref<boolean>, ShortcutOptions>(
allShortcuts.map((options: ShortcutOptions) => [options.key, options]),
)
shortcuts.forEach((options) => {
if (options.fn)
shortcut(options.key, options.fn, options.autoRepeat)
})
strokeShortcut('f', () => fullscreen.toggle())
function shortcut(key: string | Ref<boolean>, fn: Fn, autoRepeat = false) {
if (typeof key === 'string')
key = magicKeys[key]
const source = and(key, enabled)
let count = 0
let timer: any
const trigger = () => {
clearTimeout(timer)
if (!source.value) {
count = 0
return
}
if (autoRepeat) {
timer = setTimeout(trigger, Math.max(1000 - count * 250, 150))
count++
}
fn()
}
return watch(source, trigger, { flush: 'sync' })
}
function strokeShortcut(key: KeyFilter, fn: Fn) {
return onKeyStroke(key, (ev) => {
if (!enabled.value)
return
if (!ev.repeat)
fn()
})
}
}
+40
View File
@@ -0,0 +1,40 @@
import type { SlideRoute } from '@slidev/types'
import { slides } from '#slidev/slides'
import { computed, watch, watchEffect } from 'vue'
import { useNav } from '../composables/useNav'
import { useSlideContext } from '../context'
export { slides }
export function getSlide(no: number | string) {
return slides.value.find(
s => (s.no === +no || s.meta.slide?.frontmatter.routeAlias === no),
)
}
export function getSlidePath(
route: SlideRoute | number | string,
presenter: boolean,
exporting: boolean = false,
) {
if (typeof route === 'number' || typeof route === 'string')
route = getSlide(route)!
const no = route.meta.slide?.frontmatter.routeAlias ?? route.no
return exporting ? `/export/${no}` : presenter ? `/presenter/${no}` : `/${no}`
}
export function useIsSlideActive() {
const { $page } = useSlideContext()
const { currentSlideNo } = useNav()
return computed(() => $page.value === currentSlideNo.value)
}
export function onSlideEnter(cb: () => void) {
const isActive = useIsSlideActive()
watchEffect(() => isActive.value && cb())
}
export function onSlideLeave(cb: () => void) {
const isActive = useIsSlideActive()
watch(isActive, () => !isActive.value && cb())
}
+86
View File
@@ -0,0 +1,86 @@
import { snapshotState } from '../state/snapshot'
import { getSlide } from './slides'
export class SlideSnapshotManager {
private _capturePromises = new Map<number, Promise<void>>()
getSnapshot(slideNo: number) {
const data = snapshotState.state[slideNo]
if (!data) {
return
}
const slide = getSlide(slideNo)
if (!slide) {
return
}
if (data?.revision === slide?.meta.slide.revision) {
return data.image
}
}
async captureSnapshot(slideNo: number, el: HTMLElement, delay = 1000) {
if (!__DEV__)
return
if (this.getSnapshot(slideNo)) {
return
}
if (this._capturePromises.has(slideNo)) {
await this._capturePromises.get(slideNo)
}
const promise = this._captureSnapshot(slideNo, el, delay)
.finally(() => {
this._capturePromises.delete(slideNo)
})
this._capturePromises.set(slideNo, promise)
await promise
}
private async _captureSnapshot(slideNo: number, el: HTMLElement, delay: number) {
if (!__DEV__)
return
const slide = getSlide(slideNo)
if (!slide)
return
const revision = slide.meta.slide.revision
// Retry until the slide is loaded
let retries = 100
while (retries-- > 0) {
if (!el.querySelector('.slidev-slide-loading'))
break
await new Promise(r => setTimeout(r, 100))
}
// Artificial delay for the content to be loaded
await new Promise(r => setTimeout(r, delay))
// Capture the snapshot
const toImage = await import('html-to-image')
try {
const dataUrl = await toImage.toPng(el, {
width: el.offsetWidth,
height: el.offsetHeight,
skipFonts: true,
cacheBust: true,
pixelRatio: 1.5,
})
if (revision !== slide.meta.slide.revision) {
// eslint-disable-next-line no-console
console.info('[Slidev] Slide', slideNo, 'changed, discarding the snapshot')
return
}
snapshotState.patch(slideNo, {
revision,
image: dataUrl,
})
// eslint-disable-next-line no-console
console.info('[Slidev] Snapshot captured for slide', slideNo)
}
catch (e) {
console.error('[Slidev] Failed to capture snapshot for slide', slideNo, e)
}
}
}
export const snapshotManager = new SlideSnapshotManager()
+50
View File
@@ -0,0 +1,50 @@
import type { SlideRoute } from '@slidev/types'
import type { TransitionGroupProps } from 'vue'
import { configs } from '../env'
const transitionResolveMap: Record<string, string | undefined> = {
'slide-left': 'slide-left | slide-right',
'slide-right': 'slide-right | slide-left',
'slide-up': 'slide-up | slide-down',
'slide-down': 'slide-down | slide-up',
}
export function resolveTransition(transition?: string | TransitionGroupProps, isBackward = false): TransitionGroupProps | undefined {
if (!transition)
return undefined
if (typeof transition === 'string') {
transition = {
name: transition,
}
}
if (!transition.name)
return undefined
let name = transition.name.includes('|')
? transition.name
: (transitionResolveMap[transition.name] || transition.name)
if (name.includes('|')) {
const [forward, backward] = name.split('|').map(i => i.trim())
name = isBackward ? backward : forward
}
if (!name)
return undefined
return {
...transition,
name,
}
}
export function getCurrentTransition(direction: number, currentRoute?: SlideRoute, prevRoute?: SlideRoute) {
let transition = direction > 0
? prevRoute?.meta?.transition
: currentRoute?.meta?.transition
if (!transition)
transition = configs.transition || undefined
return resolveTransition(transition, direction < 0)
}
+32
View File
@@ -0,0 +1,32 @@
import { parseRangeString } from '@slidev/parser/core'
export function makeId(length = 5) {
const result = []
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
const charactersLength = characters.length
for (let i = 0; i < length; i++)
result.push(characters.charAt(Math.floor(Math.random() * charactersLength)))
return result.join('')
}
export function updateCodeHighlightRange(
rangeStr: string,
linesCount: number,
startLine: number,
getTokenOfLine: (line: number) => Element[],
) {
const highlights: number[] = parseRangeString(linesCount + startLine - 1, rangeStr)
for (let line = 0; line < linesCount; line++) {
const tokens = getTokenOfLine(line)
const isHighlighted = highlights.includes(line + startLine)
for (const token of tokens) {
// token.classList.toggle(CLASS_VCLICK_TARGET, true)
token.classList.toggle('slidev-code-highlighted', isHighlighted)
token.classList.toggle('slidev-code-dishonored', !isHighlighted)
// for backward compatibility
token.classList.toggle('highlighted', isHighlighted)
token.classList.toggle('dishonored', !isHighlighted)
}
}
}