import type { ClicksContext, SlideRoute, TocItem } from '@slidev/types' import type { ComputedRef, Ref, TransitionGroupProps, WritableComputedRef } from 'vue' import type { RouteLocationNormalized, Router } from 'vue-router' 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 { slides } from '#slidev/slides' import { CLICKS_MAX } from '../constants' import { configs } from '../env' import { useRouteQuery } from '../logic/route' import { getSlide, getSlidePath } from '../logic/slides' import { getCurrentTransition } from '../logic/transition' import { hmrSkipTransition } from '../state' import { createClicksContextBase } from './useClicks' import { useTocTree } from './useTocTree' export interface SlidevContextNav { slides: Ref total: ComputedRef currentPath: ComputedRef currentPage: ComputedRef currentSlideNo: ComputedRef currentSlideRoute: ComputedRef currentTransition: ComputedRef currentLayout: ComputedRef nextRoute: ComputedRef prevRoute: ComputedRef hasNext: ComputedRef hasPrev: ComputedRef clicksContext: ComputedRef clicks: ComputedRef clicksStart: ComputedRef clicksTotal: ComputedRef /** The table of content tree */ tocTree: ComputedRef /** The direction of the navigation, 1 for forward, -1 for backward */ navDirection: Ref /** The direction of the clicks, 1 for forward, -1 for backward */ clicksDirection: Ref /** Utility function for open file in editor, only avaible in dev mode */ openInEditor: (url?: string) => Promise /** Go to next click */ next: () => Promise /** Go to previous click */ prev: () => Promise /** Go to next slide */ nextSlide: (lastClicks?: boolean) => Promise /** Go to previous slide */ prevSlide: (lastClicks?: boolean) => Promise /** Go to slide */ go: (no: number | string, clicks?: number, force?: boolean) => Promise /** Go to the first slide */ goFirst: () => Promise /** Go to the last slide */ goLast: () => Promise /** Enter presenter mode */ enterPresenter: () => void /** Exit presenter mode */ exitPresenter: () => void } export interface SlidevContextNavState { router: Router currentRoute: ComputedRef isPrintMode: ComputedRef isPrintWithClicks: Ref isEmbedded: ComputedRef isPlaying: ComputedRef isPresenter: ComputedRef isNotesViewer: ComputedRef isPresenterAvailable: ComputedRef hasPrimarySlide: ComputedRef currentSlideNo: ComputedRef currentSlideRoute: ComputedRef clicksContext: ComputedRef queryClicksRaw: Ref queryClicks: WritableComputedRef printRange: Ref getPrimaryClicks: (route: SlideRoute) => ClicksContext } export interface SlidevContextNavFull extends SlidevContextNav, SlidevContextNavState { } export function useNavBase( currentSlideRoute: ComputedRef, clicksContext: ComputedRef, queryClicks: Ref = ref(0), isPresenter: Ref, isPrint: Ref, 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) { hmrSkipTransition.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('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) { hmrSkipTransition.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, } })