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
+172
View File
@@ -0,0 +1,172 @@
import type { ClicksContext, NormalizedRangeClickValue, NormalizedSingleClickValue, RawAtValue, RawSingleAtValue, SlideRoute } from '@slidev/types'
import type { MaybeRefOrGetter, Ref } from 'vue'
import { clamp, sum } from '@antfu/utils'
import { computed, isReadonly, onMounted, onUnmounted, ref, shallowReactive, toValue } from 'vue'
export function normalizeSingleAtValue(at: RawSingleAtValue): NormalizedSingleClickValue {
if (at === false || at === 'false')
return null
if (at == null || at === true || at === 'true')
return '+1'
if (typeof at === 'string' && '+-'.includes(at[0]))
return at
const v = +at
if (Number.isNaN(v)) {
console.error(`Invalid "at" prop value: ${at}`)
return null
}
return v
}
export function normalizeRangeAtValue(at: RawAtValue): NormalizedRangeClickValue {
if (Array.isArray(at))
return [normalizeSingleAtValue(at[0])!, normalizeSingleAtValue(at[1])!]
return null
}
export function createClicksContextBase(
current: Ref<number>,
clicksStart = 0,
clicksTotalOverrides?: number,
): ClicksContext {
const isMounted = ref(false)
let relativeSizeMap: ClicksContext['relativeSizeMap'] = new Map()
let maxMap: ClicksContext['maxMap'] = new Map()
const context: ClicksContext = {
get current() {
return clamp(+current.value, clicksStart, context.total)
},
set current(value) {
current.value = isMounted.value
? clamp(value, clicksStart, context.total)
: value /* context.total is not available yet */
},
clicksStart,
get relativeSizeMap() {
if (__DEV__ && isMounted.value)
console.warn('[slidev] ClicksContext: Unexpected access to relativeSizeMap after mounted')
return relativeSizeMap
},
get maxMap() {
return maxMap
},
get isMounted() {
return isMounted.value
},
setup() {
onMounted(() => {
isMounted.value = true
// Convert maxMap to reactive
maxMap = shallowReactive(maxMap)
// Make sure the query is not greater than the total
if (!isReadonly(current))
context.current = current.value
})
onUnmounted(() => {
isMounted.value = false
relativeSizeMap = new Map()
maxMap = new Map()
})
},
calculateSince(rawAt, size = 1) {
const at = normalizeSingleAtValue(rawAt)
if (at == null)
return null
let start: number, max: number, delta: number
if (typeof at === 'string') {
const offset = context.currentOffset
const value = +at
start = offset + value
max = offset + value + size - 1
delta = value + size - 1
}
else {
start = at
max = at + size - 1
delta = 0
}
return {
start,
end: +Number.POSITIVE_INFINITY,
max,
delta,
currentOffset: computed(() => context.current - start),
isCurrent: computed(() => context.current === start),
isActive: computed(() => context.current >= start),
}
},
calculateRange(rawAt) {
const at = normalizeRangeAtValue(rawAt)
if (at == null)
return null
const [a, b] = at
let start: number, end: number, delta: number
if (typeof a === 'string') {
const offset = context.currentOffset
start = offset + +a
delta = +a
}
else {
start = a
delta = 0
}
if (typeof b === 'string') {
end = start + +b
delta += +b
}
else {
end = b
}
return {
start,
end,
max: end,
delta,
currentOffset: computed(() => context.current - start),
isCurrent: computed(() => context.current === start),
isActive: computed(() => start <= context.current && context.current < end),
}
},
calculate(at) {
if (Array.isArray(at))
return context.calculateRange(at)
return context.calculateSince(at)
},
register(el, info) {
if (!info)
return
if (__DEV__ && isMounted.value)
console.warn('[slidev] ClicksContext: Unexpected register after mounted')
const { delta, max } = info
relativeSizeMap.set(el, delta)
maxMap.set(el, max)
},
unregister(el) {
relativeSizeMap.delete(el)
maxMap.delete(el)
},
get currentOffset() {
return sum(...relativeSizeMap.values())
},
get total() {
return clicksTotalOverrides
?? (isMounted.value
? Math.max(0, ...maxMap.values())
: 0 /* fallback value */
)
},
}
return context
}
export function createFixedClicks(
route?: SlideRoute | undefined,
currentInit: MaybeRefOrGetter<number> = 0,
): ClicksContext {
const clicksStart = route?.meta.slide?.frontmatter.clicksStart ?? 0
return createClicksContextBase(
computed(() => Math.max(toValue(currentInit), clicksStart)),
clicksStart,
route?.meta?.clicks,
)
}
+9
View File
@@ -0,0 +1,9 @@
import { isColorSchemaConfigured, isDark, toggleDark } from '../logic/dark'
export function useDarkMode() {
return {
isColorSchemaConfigured,
isDark,
toggleDark,
}
}
@@ -0,0 +1,300 @@
import type { SlidePatch } from '@slidev/types'
import type { CSSProperties, DirectiveBinding, InjectionKey, WatchStopHandle } from 'vue'
import { debounce, ensureSuffix } from '@antfu/utils'
import { injectLocal, onClickOutside, useWindowFocus } from '@vueuse/core'
import { computed, ref, watch } from 'vue'
import { injectionCurrentPage, injectionFrontmatter, injectionRenderContext, injectionSlideElement, injectionSlideScale, injectionSlideZoom } from '../constants'
import { makeId } from '../logic/utils'
import { activeDragElement } from '../state'
import { directiveInject } from '../utils'
import { useNav } from './useNav'
import { useSlideBounds } from './useSlideBounds'
import { useDynamicSlideInfo } from './useSlideInfo'
export type DragElementDataSource = 'frontmatter' | 'prop' | 'directive'
/**
* Markdown source position, injected by markdown-it plugin
*/
export type DragElementMarkdownSource = [startLine: number, endLine: number, index: number]
export type DragElementsUpdater = (id: string, posStr: string, type: DragElementDataSource, markdownSource?: DragElementMarkdownSource) => void
const map: Record<number, DragElementsUpdater> = {}
export function useDragElementsUpdater(no: number) {
if (!(__DEV__ && __SLIDEV_FEATURE_EDITOR__))
return () => {}
if (map[no])
return map[no]
const { info, update } = useDynamicSlideInfo(no)
let newPatch: SlidePatch | null = null
async function save() {
if (newPatch) {
await update({
...newPatch,
skipHmr: true,
})
newPatch = null
}
}
const debouncedSave = debounce(500, save)
return map[no] = (id, posStr, type, markdownSource) => {
if (!info.value)
return
if (type === 'frontmatter') {
const frontmatter = info.value.frontmatter
frontmatter.dragPos ||= {}
if (frontmatter.dragPos[id] === posStr)
return
frontmatter.dragPos[id] = posStr
newPatch = {
frontmatter: {
dragPos: frontmatter.dragPos,
},
}
}
else {
if (!markdownSource)
throw new Error(`[Slidev] VDrag Element ${id} is missing markdown source`)
const [startLine, endLine, idx] = markdownSource
const lines = info.value.content.split(/\r?\n/g)
let section = lines.slice(startLine, endLine).join('\n')
let replaced = false
section = type === 'prop'
// eslint-disable-next-line regexp/no-super-linear-backtracking
? section.replace(/<(v-?drag-?\w*)(.*?)(\/)?>/gi, (full, tag, attrs, selfClose = '', index) => {
if (index === idx) {
replaced = true
const posMatch = attrs.match(/pos=".*?"/)
if (!posMatch)
return `<${tag}${ensureSuffix(' ', attrs)}pos="${posStr}"${selfClose}>`
const start = posMatch.index
const end = start + posMatch[0].length
return `<${tag}${attrs.slice(0, start)}pos="${posStr}"${attrs.slice(end)}${selfClose}>`
}
return full
})
: section.replace(/(?<![</\w])v-drag(?:=".*?")?/gi, (full, index) => {
if (index === idx) {
replaced = true
return `v-drag="${posStr}"`
}
return full
})
if (!replaced)
throw new Error(`[Slidev] VDrag Element ${id} is not found in the markdown source`)
lines.splice(
startLine,
endLine - startLine,
section,
)
const newContent = lines.join('\n')
if (info.value.content === newContent)
return
newPatch = {
content: newContent,
}
info.value = {
...info.value,
content: newContent,
}
}
debouncedSave()
}
}
export function useDragElement(directive: DirectiveBinding | null, posRaw?: string | number | number[], markdownSource?: DragElementMarkdownSource, isArrow = false) {
function inject<T>(key: InjectionKey<T> | string): T | undefined {
return directive
? directiveInject(directive, key)
: injectLocal(key)
}
const renderContext = inject(injectionRenderContext)!
const frontmatter = inject(injectionFrontmatter) ?? {}
const page = inject(injectionCurrentPage)!
const updater = computed(() => useDragElementsUpdater(page.value))
const scale = inject(injectionSlideScale) ?? ref(1)
const zoom = inject(injectionSlideZoom) ?? ref(1)
const { left: slideLeft, top: slideTop, stop: stopWatchBounds } = useSlideBounds(inject(injectionSlideElement) ?? ref())
const { isPrintMode } = useNav()
const enabled = ['slide', 'presenter'].includes(renderContext.value) && !isPrintMode.value
let dataSource: DragElementDataSource = directive ? 'directive' : 'prop'
let dragId: string = makeId()
let pos: number[] | undefined
if (Array.isArray(posRaw)) {
pos = posRaw
}
else if (typeof posRaw === 'string' && posRaw.includes(',')) {
pos = posRaw.split(',').map(Number)
}
else if (posRaw != null) {
dataSource = 'frontmatter'
dragId = `${posRaw}`
posRaw = frontmatter?.dragPos?.[dragId]
pos = (posRaw as string)?.split(',').map(Number)
}
if (dataSource !== 'frontmatter' && !markdownSource)
throw new Error('[Slidev] Can not identify the source position of the v-drag element, please provide an explicit `id` prop.')
const watchStopHandles: WatchStopHandle[] = [stopWatchBounds]
const autoHeight = !isArrow && posRaw != null && !Number.isFinite(pos?.[3])
pos ??= [Number.NaN, Number.NaN, 0]
const width = ref(pos[2])
const x0 = ref(pos[0] + pos[2] / 2)
const rotate = ref(isArrow ? 0 : (pos[4] ?? 0))
const rotateRad = computed(() => rotate.value * Math.PI / 180)
const rotateSin = computed(() => Math.sin(rotateRad.value))
const rotateCos = computed(() => Math.cos(rotateRad.value))
const container = ref<HTMLElement>()
const bounds = ref({ left: 0, top: 0, width: 0, height: 0 })
const actualHeight = ref(0)
function updateBounds() {
if (!container.value)
return
const rect = container.value.getBoundingClientRect()
bounds.value = {
left: rect.left / zoom.value,
top: rect.top / zoom.value,
width: rect.width / zoom.value,
height: rect.height / zoom.value,
}
actualHeight.value = ((bounds.value.width + bounds.value.height) / scale.value / (Math.abs(rotateSin.value) + Math.abs(rotateCos.value)) - width.value)
}
watchStopHandles.push(watch(width, updateBounds, { flush: 'post' }))
const configuredHeight = ref(pos[3] ?? 0)
const height = autoHeight
? computed({
get: () => (autoHeight ? actualHeight.value : configuredHeight.value) || 0,
set: v => !autoHeight && (configuredHeight.value = v),
})
: configuredHeight
const configuredY0 = autoHeight ? ref(pos[1]) : ref(pos[1] + pos[3] / 2)
const y0 = autoHeight
? computed({
get: () => configuredY0.value + height.value / 2,
set: v => configuredY0.value = v - height.value / 2,
})
: configuredY0
const containerStyle = computed(() => {
return Number.isFinite(x0.value)
? {
position: 'absolute',
zIndex: 100,
left: `${x0.value - width.value / 2}px`,
top: `${y0.value - height.value / 2}px`,
width: `${width.value}px`,
height: autoHeight ? undefined : `${height.value}px`,
transformOrigin: 'center center',
transform: `rotate(${rotate.value}deg)`,
} satisfies CSSProperties
: {
position: 'absolute',
zIndex: 100,
} satisfies CSSProperties
})
watchStopHandles.push(
watch(
[x0, y0, width, height, rotate],
([x0, y0, w, h, r]) => {
let posStr = [x0 - w / 2, y0 - h / 2, w].map(Math.round).join()
if (autoHeight)
posStr += dataSource === 'directive' ? ',NaN' : ',_'
else
posStr += `,${Math.round(h)}`
if (Math.round(r) !== 0)
posStr += `,${Math.round(r)}`
if (dataSource === 'directive')
posStr = `[${posStr}]`
updater.value(dragId, posStr, dataSource, markdownSource)
},
),
)
const state = {
dragId,
dataSource,
markdownSource,
isArrow,
zoom,
autoHeight,
x0,
y0,
width,
height,
rotate,
container,
containerStyle,
watchStopHandles,
dragging: computed((): boolean => activeDragElement.value === state),
mounted() {
if (!enabled)
return
updateBounds()
if (!posRaw) {
setTimeout(() => {
updateBounds()
x0.value = (bounds.value.left + bounds.value.width / 2 - slideLeft.value) / scale.value
y0.value = (bounds.value.top - slideTop.value) / scale.value
width.value = bounds.value.width / scale.value
height.value = bounds.value.height / scale.value
}, 100)
}
},
unmounted() {
if (!enabled)
return
state.stopDragging()
},
startDragging(): void {
if (!enabled)
return
updateBounds()
activeDragElement.value = state
},
stopDragging(): void {
if (!enabled)
return
if (activeDragElement.value === state)
activeDragElement.value = null
},
}
watchStopHandles.push(
onClickOutside(container, (ev) => {
const container = document.querySelector('#drag-control-container')
if (container && ev.target && container.contains(ev.target as HTMLElement))
return
state.stopDragging()
}),
watch(useWindowFocus(), (focused) => {
if (!focused)
state.stopDragging()
}),
)
return state
}
export type DragElementState = ReturnType<typeof useDragElement>
+181
View File
@@ -0,0 +1,181 @@
import type { Brush, Options as DrauuOptions, DrawingMode } from 'drauu'
import { createSharedComposable, toReactive, useLocalStorage } from '@vueuse/core'
import { createDrauu } from 'drauu'
import { computed, markRaw, nextTick, reactive, ref, watch } from 'vue'
import { configs } from '../env'
import { isInputting } from '../state'
import { drawingState, onPatchDrawingState, patchDrawingState } from '../state/drawings'
import { useNav } from './useNav'
export const useDrawings = createSharedComposable(() => {
const { currentSlideNo, isPresenter } = useNav()
const brushColors = [
'#ff595e',
'#ffca3a',
'#8ac926',
'#1982c4',
'#6a4c93',
'#ffffff',
'#000000',
]
const drawingEnabled = useLocalStorage('slidev-drawing-enabled', false)
const drawingPinned = useLocalStorage('slidev-drawing-pinned', false)
const brush = toReactive(useLocalStorage<Brush>('slidev-drawing-brush', {
color: brushColors[0],
size: 4,
mode: 'stylus',
}))
const isDrawing = ref(false)
const canUndo = ref(false)
const canRedo = ref(false)
const canClear = ref(false)
const _mode = ref<DrawingMode | 'arrow'>('stylus')
const syncUp = computed(() => configs.drawings.syncAll || isPresenter.value)
let disableDump = false
const drawingMode = computed({
get() {
return _mode.value
},
set(v: DrawingMode | 'arrow') {
_mode.value = v
if (v === 'arrow') {
// eslint-disable-next-line ts/no-use-before-define
drauu.mode = 'line'
brush.arrowEnd = true
}
else {
// eslint-disable-next-line ts/no-use-before-define
drauu.mode = v
brush.arrowEnd = false
}
},
})
const drauuOptions: DrauuOptions = reactive({
brush,
acceptsInputTypes: computed(() => (drawingEnabled.value && (!configs.drawings.presenterOnly || isPresenter.value)) ? undefined : ['pen' as const]),
coordinateTransform: false,
})
const drauu = markRaw(createDrauu(drauuOptions))
function clearDrauu() {
drauu.clear()
if (syncUp.value)
patchDrawingState(currentSlideNo.value, '')
}
function updateState() {
canRedo.value = drauu.canRedo()
canUndo.value = drauu.canUndo()
canClear.value = !!drauu.el?.children.length
}
function loadCanvas(page?: number) {
disableDump = true
const data = drawingState[page || currentSlideNo.value]
if (data != null)
drauu.load(data)
else
drauu.clear()
updateState()
disableDump = false
}
drauu.on('changed', () => {
updateState()
if (!disableDump) {
const dump = drauu.dump()
const key = currentSlideNo.value
if ((drawingState[key] || '') !== dump && syncUp.value)
patchDrawingState(key, drauu.dump())
}
})
onPatchDrawingState((state) => {
disableDump = true
if (state[currentSlideNo.value] != null)
drauu.load(state[currentSlideNo.value] || '')
disableDump = false
updateState()
})
nextTick(() => {
watch(currentSlideNo, () => {
if (!drauu.mounted)
return
loadCanvas()
}, { immediate: true })
})
drauu.on('start', () => isDrawing.value = true)
drauu.on('end', () => isDrawing.value = false)
window.addEventListener('keydown', (e) => {
if (!drawingEnabled.value || isInputting.value)
return
const noModifier = !e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey
let handled = true
if (e.code === 'KeyZ' && (e.ctrlKey || e.metaKey)) {
if (e.shiftKey)
drauu.redo()
else
drauu.undo()
}
else if (e.code === 'Escape') {
drawingEnabled.value = false
}
else if (e.code === 'KeyL' && noModifier) {
drawingMode.value = 'line'
}
else if (e.code === 'KeyA' && noModifier) {
drawingMode.value = 'arrow'
}
else if (e.code === 'KeyS' && noModifier) {
drawingMode.value = 'stylus'
}
else if (e.code === 'KeyR' && noModifier) {
drawingMode.value = 'rectangle'
}
else if (e.code === 'KeyE' && noModifier) {
drawingMode.value = 'ellipse'
}
else if (e.code === 'KeyC' && noModifier) {
clearDrauu()
}
else if (e.code.startsWith('Digit') && noModifier && +e.code[5] <= brushColors.length) {
brush.color = brushColors[+e.code[5] - 1]
}
else {
handled = false
}
if (handled) {
e.preventDefault()
e.stopPropagation()
}
}, false)
return {
brush,
brushColors,
canClear,
canRedo,
canUndo,
clear: clearDrauu,
drauu,
drauuOptions,
drawingEnabled,
drawingMode,
drawingPinned,
drawingState,
isDrawing,
loadCanvas,
}
})
@@ -0,0 +1,56 @@
import { throttledWatch } from '@vueuse/core'
import { useNav } from '../composables/useNav'
import { isDark } from '../logic/dark'
export function useEmbeddedControl() {
const nav = useNav()
const clientId = `${Date.now()}`
window.addEventListener('message', ({ data }) => {
if (data && data.target === 'slidev') {
if (data.type === 'navigate') {
if (data.no || data.clicks) {
nav.go(+data.no, +data.clicks || 0)
}
else if (typeof data.operation === 'string') {
const fn = nav[data.operation as keyof typeof nav]
if (typeof fn === 'function')
(fn as any)(...(data.args ?? []))
}
}
else if (data.type === 'css-vars') {
const root = document.documentElement
for (const [key, value] of Object.entries(data.vars || {}))
root.style.setProperty(key, value as any)
}
else if (data.type === 'color-schema') {
isDark.value = data.color === 'dark'
}
}
})
if (nav.isEmbedded.value) {
throttledWatch(
[nav.currentSlideNo, nav.clicks, nav.hasNext, nav.hasPrev],
([no, clicks, hasNext, hasPrev]) => {
window.parent.postMessage(
{
target: 'slidev',
clientId,
navState: {
no,
clicks,
hasNext,
hasPrev,
},
},
'*',
)
},
{
throttle: 300,
immediate: true,
},
)
}
}
+400
View File
@@ -0,0 +1,400 @@
import type { ClicksContext, SlideRoute, TocItem } from '@slidev/types'
import type { ComputedRef, Ref, TransitionGroupProps, WritableComputedRef } from 'vue'
import type { RouteLocationNormalized, Router } from 'vue-router'
import { slides } from '#slidev/slides'
import { clamp } from '@antfu/utils'
import { parseRangeString } from '@slidev/parser/utils'
import { createSharedComposable } from '@vueuse/core'
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { CLICKS_MAX } from '../constants'
import { configs } from '../env'
import { skipTransition } from '../logic/hmr'
import { useRouteQuery } from '../logic/route'
import { getSlide, getSlidePath } from '../logic/slides'
import { getCurrentTransition } from '../logic/transition'
import { createClicksContextBase } from './useClicks'
import { useTocTree } from './useTocTree'
export interface SlidevContextNav {
slides: Ref<SlideRoute[]>
total: ComputedRef<number>
currentPath: ComputedRef<string>
currentPage: ComputedRef<number>
currentSlideNo: ComputedRef<number>
currentSlideRoute: ComputedRef<SlideRoute>
currentTransition: ComputedRef<TransitionGroupProps | undefined>
currentLayout: ComputedRef<string>
nextRoute: ComputedRef<SlideRoute>
prevRoute: ComputedRef<SlideRoute>
hasNext: ComputedRef<boolean>
hasPrev: ComputedRef<boolean>
clicksContext: ComputedRef<ClicksContext>
clicks: ComputedRef<number>
clicksStart: ComputedRef<number>
clicksTotal: ComputedRef<number>
/** The table of content tree */
tocTree: ComputedRef<TocItem[]>
/** The direction of the navigation, 1 for forward, -1 for backward */
navDirection: Ref<number>
/** The direction of the clicks, 1 for forward, -1 for backward */
clicksDirection: Ref<number>
/** Utility function for open file in editor, only avaible in dev mode */
openInEditor: (url?: string) => Promise<boolean>
/** Go to next click */
next: () => Promise<void>
/** Go to previous click */
prev: () => Promise<void>
/** Go to next slide */
nextSlide: (lastClicks?: boolean) => Promise<void>
/** Go to previous slide */
prevSlide: (lastClicks?: boolean) => Promise<void>
/** Go to slide */
go: (no: number | string, clicks?: number, force?: boolean) => Promise<void>
/** Go to the first slide */
goFirst: () => Promise<void>
/** Go to the last slide */
goLast: () => Promise<void>
/** Enter presenter mode */
enterPresenter: () => void
/** Exit presenter mode */
exitPresenter: () => void
}
export interface SlidevContextNavState {
router: Router
currentRoute: ComputedRef<RouteLocationNormalized>
isPrintMode: ComputedRef<boolean>
isPrintWithClicks: Ref<boolean>
isEmbedded: ComputedRef<boolean>
isPlaying: ComputedRef<boolean>
isPresenter: ComputedRef<boolean>
isNotesViewer: ComputedRef<boolean>
isPresenterAvailable: ComputedRef<boolean>
hasPrimarySlide: ComputedRef<boolean>
currentSlideNo: ComputedRef<number>
currentSlideRoute: ComputedRef<SlideRoute>
clicksContext: ComputedRef<ClicksContext>
queryClicksRaw: Ref<string>
queryClicks: WritableComputedRef<number>
printRange: Ref<number[]>
getPrimaryClicks: (route: SlideRoute) => ClicksContext
}
export interface SlidevContextNavFull extends SlidevContextNav, SlidevContextNavState { }
export function useNavBase(
currentSlideRoute: ComputedRef<SlideRoute>,
clicksContext: ComputedRef<ClicksContext>,
queryClicks: Ref<number> = ref(0),
isPresenter: Ref<boolean>,
isPrint: Ref<boolean>,
router?: Router,
): SlidevContextNav {
const total = computed(() => slides.value.length)
const navDirection = ref(0)
const clicksDirection = ref(0)
const currentPath = computed(() => getSlidePath(currentSlideRoute.value, isPresenter.value))
const currentSlideNo = computed(() => currentSlideRoute.value.no)
const currentLayout = computed(() => currentSlideRoute.value.meta?.layout || (currentSlideNo.value === 1 ? 'cover' : 'default'))
const clicks = computed(() => clicksContext.value.current)
const clicksStart = computed(() => clicksContext.value.clicksStart)
const clicksTotal = computed(() => clicksContext.value.total)
const nextRoute = computed(() => slides.value[Math.min(slides.value.length, currentSlideNo.value + 1) - 1])
const prevRoute = computed(() => slides.value[Math.max(1, currentSlideNo.value - 1) - 1])
const hasNext = computed(() => currentSlideNo.value < slides.value.length || clicks.value < clicksTotal.value)
const hasPrev = computed(() => currentSlideNo.value > 1 || clicks.value > 0)
const currentTransition = computed(() => isPrint.value ? undefined : getCurrentTransition(navDirection.value, currentSlideRoute.value, prevRoute.value))
watch(currentSlideRoute, (next, prev) => {
navDirection.value = next.no - prev.no
})
async function openInEditor(url?: string) {
if (!__DEV__)
return false
if (url == null) {
const slide = currentSlideRoute.value?.meta?.slide
if (!slide)
return false
url = `${slide.filepath}:${slide.start}`
}
await fetch(`/__open-in-editor?file=${encodeURIComponent(url)}`)
return true
}
const tocTree = useTocTree(
slides,
currentSlideNo,
currentSlideRoute,
)
async function next() {
clicksDirection.value = 1
if (clicksTotal.value <= queryClicks.value)
await nextSlide()
else
queryClicks.value += 1
}
async function prev() {
clicksDirection.value = -1
if (queryClicks.value <= clicksStart.value)
await prevSlide(true)
else
queryClicks.value -= 1
}
async function nextSlide(lastClicks = false) {
clicksDirection.value = 1
if (currentSlideNo.value < slides.value.length) {
await go(
currentSlideNo.value + 1,
lastClicks && !isPrint.value ? CLICKS_MAX : undefined,
)
}
}
async function prevSlide(lastClicks = false) {
clicksDirection.value = -1
if (currentSlideNo.value > 1) {
await go(
currentSlideNo.value - 1,
lastClicks && !isPrint.value ? CLICKS_MAX : undefined,
)
}
}
function goFirst() {
return go(1)
}
function goLast() {
return go(total.value)
}
async function go(no: number | string, clicks: number = 0, force = false) {
skipTransition.value = false
const pageChanged = currentSlideNo.value !== no
const clicksChanged = clicks !== queryClicks.value
const meta = getSlide(no)?.meta
const clicksStart = meta?.slide?.frontmatter.clicksStart ?? 0
clicks = clamp(clicks, clicksStart, meta?.__clicksContext?.total ?? CLICKS_MAX)
if (force || pageChanged || clicksChanged) {
await router?.push({
path: getSlidePath(no, isPresenter.value, router.currentRoute.value.name === 'export'),
query: {
...router.currentRoute.value.query,
clicks: clicks === 0 ? undefined : clicks.toString(),
embedded: location.search.includes('embedded') ? 'true' : undefined,
},
})
}
}
function enterPresenter() {
router?.push({
path: getSlidePath(currentSlideNo.value, true),
query: { ...router.currentRoute.value.query },
})
}
function exitPresenter() {
router?.push({
path: getSlidePath(currentSlideNo.value, false),
query: { ...router.currentRoute.value.query },
})
}
return {
slides,
total,
currentPath,
currentSlideNo,
currentPage: currentSlideNo,
currentSlideRoute,
currentLayout,
currentTransition,
clicksDirection,
nextRoute,
prevRoute,
clicksContext,
clicks,
clicksStart,
clicksTotal,
hasNext,
hasPrev,
tocTree,
navDirection,
openInEditor,
next,
prev,
go,
goLast,
goFirst,
nextSlide,
prevSlide,
enterPresenter,
exitPresenter,
}
}
export function useFixedNav(
currentSlideRoute: SlideRoute,
clicksContext: ClicksContext,
): SlidevContextNav {
const noop = async () => { }
return {
...useNavBase(
computed(() => currentSlideRoute),
computed(() => clicksContext),
ref(CLICKS_MAX),
ref(false),
ref(false),
),
next: noop,
prev: noop,
nextSlide: noop,
prevSlide: noop,
goFirst: noop,
goLast: noop,
go: noop,
}
}
const useNavState = createSharedComposable((): SlidevContextNavState => {
const router = useRouter()
const currentRoute = useRoute()
const query = computed(() => {
// eslint-disable-next-line ts/no-unused-expressions
router.currentRoute.value.query
return new URLSearchParams(location.search)
})
const isPrintMode = computed(() => query.value.has('print') || currentRoute.name === 'export')
const isPrintWithClicks = ref(query.value.get('print') === 'clicks')
const isEmbedded = computed(() => query.value.has('embedded'))
const isPlaying = computed(() => currentRoute.name === 'play')
const isPresenter = computed(() => currentRoute.name === 'presenter')
const isNotesViewer = computed(() => currentRoute.name === 'notes')
const isPresenterAvailable = computed(() => !isPresenter.value && (!configs.remote || query.value.get('password') === configs.remote))
const hasPrimarySlide = computed(() => !!currentRoute.params.no)
const currentSlideNo = computed(() => hasPrimarySlide.value ? getSlide(currentRoute.params.no as string)?.no ?? 1 : 1)
const currentSlideRoute = computed(() => slides.value[currentSlideNo.value - 1])
const printRange = ref(parseRangeString(slides.value.length, currentRoute.query.range as string | undefined))
const queryClicksRaw = useRouteQuery<string>('clicks', '0')
const clicksContext = computed(() => getPrimaryClicks(currentSlideRoute.value))
const queryClicks = computed({
get() {
let v = +(queryClicksRaw.value || 0)
if (Number.isNaN(v))
v = 0
return v
},
set(v) {
skipTransition.value = false
queryClicksRaw.value = v.toString()
},
})
function getPrimaryClicks(
route: SlideRoute,
): ClicksContext {
if (route?.meta?.__clicksContext)
return route.meta.__clicksContext
const thisNo = route.no
const context = createClicksContextBase(
computed({
get() {
if (currentSlideNo.value === thisNo)
return Math.max(+(queryClicksRaw.value ?? 0), context.clicksStart)
else if (currentSlideNo.value > thisNo)
return CLICKS_MAX
else
return context.clicksStart
},
set(v) {
if (currentSlideNo.value === thisNo)
queryClicksRaw.value = v.toString()
},
}),
route?.meta.slide?.frontmatter.clicksStart ?? 0,
route?.meta.clicks,
)
if (route?.meta)
route.meta.__clicksContext = context
return context
}
return {
router,
currentRoute: computed(() => currentRoute),
isPrintMode,
isPrintWithClicks,
isEmbedded,
isPlaying,
isPresenter,
isNotesViewer,
isPresenterAvailable,
hasPrimarySlide,
currentSlideNo,
currentSlideRoute,
clicksContext,
queryClicksRaw,
queryClicks,
printRange,
getPrimaryClicks,
}
})
export const useNav = createSharedComposable((): SlidevContextNavFull => {
const state = useNavState()
const router = useRouter()
const nav = useNavBase(
state.currentSlideRoute,
state.clicksContext,
state.queryClicks,
state.isPresenter,
state.isPrintMode,
router,
)
watch(
[nav.total, state.currentRoute],
async () => {
const no = state.currentRoute.value.params.no as string
if (state.hasPrimarySlide.value && !getSlide(no)) {
if (no && no !== 'index.html') {
// The current slide may has been removed. Redirect to the last slide.
await nav.go(nav.total.value, 0, true)
}
else {
// Redirect to the first slide
await nav.go(1, 0, true)
}
}
},
{ flush: 'pre', immediate: true },
)
return {
...nav,
...state,
}
})
@@ -0,0 +1,28 @@
import { useStyleTag } from '@vueuse/core'
import { computed } from 'vue'
import { slideHeight, slideWidth } from '../env'
import { useNav } from './useNav'
export function usePrintStyles() {
const { isPrintMode } = useNav()
useStyleTag(computed(() => isPrintMode.value
? `
@page {
size: ${slideWidth.value}px ${slideHeight.value}px;
margin: 0px;
}
* {
transition: none !important;
transition-duration: 0s !important;
}`
: ''))
}
// Monaco uses `<style media="screen" class="monaco-colors">` to apply colors, which will be ignored in print mode.
export function patchMonacoColors() {
document.querySelectorAll<HTMLStyleElement>('style.monaco-colors').forEach((el) => {
el.media = ''
})
}
@@ -0,0 +1,30 @@
import { useElementBounding } from '@vueuse/core'
import { inject, ref, watch } from 'vue'
import { injectionSlideElement } from '../constants'
import { editorHeight, editorWidth, isEditorVertical, showEditor, slideScale, windowSize } from '../state'
export function useSlideBounds(slideElement = inject(injectionSlideElement, ref())) {
const bounding = useElementBounding(slideElement)
const stop = watch(
[
showEditor,
isEditorVertical,
editorWidth,
editorHeight,
slideScale,
windowSize.width,
windowSize.height,
],
() => {
setTimeout(bounding.update, 300)
},
{
flush: 'post',
immediate: true,
},
)
return {
...bounding,
stop,
}
}
+80
View File
@@ -0,0 +1,80 @@
import type { SlideInfo, SlidePatch } from '@slidev/types'
import type { MaybeRef } from '@vueuse/core'
import type { Ref } from 'vue'
import { useFetch } from '@vueuse/core'
import { computed, ref, unref } from 'vue'
import { getSlide } from '../logic/slides'
export interface UseSlideInfo {
info: Ref<SlideInfo | null>
update: (data: SlidePatch) => Promise<SlideInfo | void>
}
export function useSlideInfo(no: number): UseSlideInfo {
if (!__SLIDEV_HAS_SERVER__) {
return {
info: ref(getSlide(no)?.meta.slide ?? null) as Ref<SlideInfo | null>,
update: async () => {},
}
}
const url = `/__slidev/slides/${no}.json`
const { data: info, execute } = useFetch(url).json<SlideInfo>().get()
execute()
const update = async (data: SlidePatch) => {
return await fetch(
url,
{
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
},
).then(r => r.json())
}
if (__DEV__) {
import.meta.hot?.on('slidev:update-slide', (payload) => {
if (payload.no === no)
info.value = payload.data
})
import.meta.hot?.on('slidev:update-note', (payload) => {
if (payload.no === no && info.value && info.value.note?.trim() !== payload.note?.trim())
info.value = { ...info.value, ...payload }
})
}
return {
info,
update,
}
}
const map: Record<number, UseSlideInfo> = {}
export function useDynamicSlideInfo(no: MaybeRef<number>) {
function get(no: number) {
return map[no] ??= useSlideInfo(no)
}
return {
info: computed({
get() {
return get(unref(no)).info.value
},
set(newInfo) {
get(unref(no)).info.value = newInfo
},
}),
update: async (data: SlidePatch, newId?: number) => {
const info = get(newId ?? unref(no))
const newData = await info.update(data)
if (newData)
info.info.value = newData
return newData
},
}
}
@@ -0,0 +1,43 @@
import type { Ref } from 'vue'
import { timestamp, usePointerSwipe } from '@vueuse/core'
import { ref } from 'vue'
import { useNav } from '../composables/useNav'
import { useDrawings } from './useDrawings'
export function useSwipeControls(root: Ref<HTMLElement | undefined>) {
const { next, nextSlide, prev, prevSlide } = useNav()
const { isDrawing } = useDrawings()
const swipeBegin = ref(0)
const { direction, distanceX, distanceY } = usePointerSwipe(root, {
pointerTypes: ['touch'],
onSwipeStart() {
if (isDrawing.value)
return
swipeBegin.value = timestamp()
},
onSwipeEnd() {
if (!swipeBegin.value)
return
if (isDrawing.value)
return
const x = Math.abs(distanceX.value)
const y = Math.abs(distanceY.value)
if (x / window.innerWidth > 0.3 || x > 75) {
if (direction.value === 'left')
next()
else
prev()
}
else if (y / window.innerHeight > 0.4 || y > 200) {
if (direction.value === 'down')
prevSlide()
else
nextSlide()
}
},
})
}
+20
View File
@@ -0,0 +1,20 @@
import { useInterval } from '@vueuse/core'
import { computed } from 'vue'
export function useTimer() {
const { counter, isActive, reset, pause, resume } = useInterval(1000, { controls: true })
const timer = computed(() => {
const passed = counter.value
const sec = Math.floor(passed % 60).toString().padStart(2, '0')
const min = Math.floor(passed / 60).toString().padStart(2, '0')
return `${min}:${sec}`
})
return {
timer,
isTimerAvctive: isActive,
resetTimer: reset,
toggleTimer: () => (isActive.value ? pause() : resume()),
}
}
+82
View File
@@ -0,0 +1,82 @@
import type { SlideRoute, TocItem } from '@slidev/types'
import type { ComputedRef, Ref } from 'vue'
import { computed } from 'vue'
import { getSlidePath } from '../logic/slides'
function addToTree(tree: TocItem[], route: SlideRoute, level = 1) {
const titleLevel = route.meta.slide.level ?? level
if (titleLevel && titleLevel > level && tree.length > 0) {
addToTree(tree[tree.length - 1].children, route, level + 1)
}
else {
tree.push({
no: route.no,
children: [],
level,
titleLevel,
path: getSlidePath(route.meta.slide?.frontmatter?.routeAlias ?? route.no, false),
hideInToc: Boolean(route.meta?.slide?.frontmatter?.hideInToc),
title: route.meta?.slide?.title,
})
}
}
function getTreeWithActiveStatuses(
tree: TocItem[],
currentRoute?: SlideRoute,
hasActiveParent = false,
parent?: TocItem,
currentSlideNo?: Ref<number>,
): TocItem[] {
return tree.map((item: TocItem) => {
const clone = {
...item,
active: item.no === currentSlideNo?.value,
hasActiveParent,
}
if (clone.children.length > 0) {
clone.children = getTreeWithActiveStatuses(
clone.children,
currentRoute,
clone.active || clone.hasActiveParent,
clone,
currentSlideNo,
)
}
if (parent && (clone.active || clone.activeParent))
parent.activeParent = true
return clone
})
}
function filterTree(tree: TocItem[], level = 1): TocItem[] {
return tree
.filter((item: TocItem) => !item.hideInToc)
.map((item: TocItem) => ({
...item,
children: filterTree(item.children, level + 1),
}))
}
export function useTocTree(
slides: Ref<SlideRoute[]>,
currentSlideNo: Ref<number>,
currentSlideRoute: Ref<SlideRoute>,
): ComputedRef<TocItem[]> {
const rawTree = computed(() => slides.value
.filter((route: SlideRoute) => route.meta?.slide?.title)
.reduce((acc: TocItem[], route: SlideRoute) => {
addToTree(acc, route)
return acc
}, []))
const treeWithActiveStatuses = computed(() => getTreeWithActiveStatuses(
rawTree.value,
currentSlideRoute.value,
undefined,
undefined,
currentSlideNo,
))
return computed(() => filterTree(treeWithActiveStatuses.value))
}
@@ -0,0 +1,61 @@
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { configs } from '../env'
import { getSlide } from '../logic/slides'
export function useViewTransition() {
const router = useRouter()
const isViewTransition = ref(false)
let viewTransitionFinish: undefined | (() => void)
let viewTransitionAbort: undefined | (() => void)
const supportViewTransition = typeof document !== 'undefined' && 'startViewTransition' in document
router.beforeResolve((to, from) => {
const fromMeta = getSlide(from.params.no as string)?.meta
const toMeta = getSlide(to.params.no as string)?.meta
const fromNo = fromMeta?.slide?.no
const toNo = toMeta?.slide?.no
const transitionType = fromNo != null && toNo != null && fromNo !== toNo
&& ((fromNo < toNo ? fromMeta?.transition : toMeta?.transition) ?? configs.transition)
if (transitionType !== 'view-transition') {
isViewTransition.value = false
return
}
if (!supportViewTransition) {
isViewTransition.value = false
console.warn('View transition is not supported in your browser, fallback to normal transition.')
return
}
isViewTransition.value = true
const promise = new Promise<void>((resolve, reject) => {
viewTransitionFinish = resolve
viewTransitionAbort = reject
})
let changeRoute: () => void
const ready = new Promise<void>(resolve => (changeRoute = resolve))
// Wait for `TransitionGroup` to become normal `div`
setTimeout(() => {
document.startViewTransition(() => {
changeRoute()
return promise
})
}, 50)
return ready
})
if (supportViewTransition) {
router.afterEach(() => {
viewTransitionFinish?.()
viewTransitionAbort?.()
})
}
return isViewTransition
}
+14
View File
@@ -0,0 +1,14 @@
import { useWakeLock as useVueUseWakeLock } from '@vueuse/core'
import { watch } from 'vue'
import { wakeLockEnabled } from '../state'
export function useWakeLock() {
const { request, release } = useVueUseWakeLock()
watch(wakeLockEnabled, (enabled) => {
if (enabled)
request('screen')
else
release()
}, { immediate: true })
}