| 1 | import type { ClicksContext, SlideRoute, TocItem } from '@slidev/types' |
| 2 | import type { ComputedRef, Ref, TransitionGroupProps, WritableComputedRef } from 'vue' |
| 3 | import type { RouteLocationNormalized, Router } from 'vue-router' |
| 4 | import { clamp } from '@antfu/utils' |
| 5 | import { parseRangeString } from '@slidev/parser/utils' |
| 6 | import { createSharedComposable, injectLocal } from '@vueuse/core' |
| 7 | import { computed, hasInjectionContext, ref, toRaw, watch } from 'vue' |
| 8 | import { useRoute, useRouter } from 'vue-router' |
| 9 | import { slides } from '#slidev/slides' |
| 10 | import { CLICKS_MAX, injectionSlidevContext } from '../constants' |
| 11 | import { configs } from '../env' |
| 12 | import { useRouteQuery } from '../logic/route' |
| 13 | import { getSlide, getSlidePath } from '../logic/slides' |
| 14 | import { getCurrentTransition } from '../logic/transition' |
| 15 | import { hmrSkipTransition } from '../state' |
| 16 | import { createClicksContextBase } from './useClicks' |
| 17 | import { useTocTree } from './useTocTree' |
| 18 | |
| 19 | export interface SlidevContextNav { |
| 20 | slides: Ref<SlideRoute[]> |
| 21 | total: ComputedRef<number> |
| 22 | |
| 23 | currentPath: ComputedRef<string> |
| 24 | currentPage: ComputedRef<number> |
| 25 | currentSlideNo: ComputedRef<number> |
| 26 | currentSlideRoute: ComputedRef<SlideRoute> |
| 27 | currentTransition: ComputedRef<TransitionGroupProps | undefined> |
| 28 | currentLayout: ComputedRef<string> |
| 29 | currentFrontmatter: ComputedRef<Record<string, any>> |
| 30 | |
| 31 | nextRoute: ComputedRef<SlideRoute> |
| 32 | prevRoute: ComputedRef<SlideRoute> |
| 33 | hasNext: ComputedRef<boolean> |
| 34 | hasPrev: ComputedRef<boolean> |
| 35 | |
| 36 | clicksContext: ComputedRef<ClicksContext> |
| 37 | clicks: ComputedRef<number> |
| 38 | clicksStart: ComputedRef<number> |
| 39 | clicksTotal: ComputedRef<number> |
| 40 | |
| 41 | /** The table of content tree */ |
| 42 | tocTree: ComputedRef<TocItem[]> |
| 43 | /** The direction of the navigation, 1 for forward, -1 for backward */ |
| 44 | navDirection: Ref<number> |
| 45 | /** The direction of the clicks, 1 for forward, -1 for backward */ |
| 46 | clicksDirection: Ref<number> |
| 47 | /** Utility function for open file in editor, only avaible in dev mode */ |
| 48 | openInEditor: (url?: string) => Promise<boolean> |
| 49 | |
| 50 | /** Go to next click */ |
| 51 | next: () => Promise<void> |
| 52 | /** Go to previous click */ |
| 53 | prev: () => Promise<void> |
| 54 | /** Go to next slide */ |
| 55 | nextSlide: (lastClicks?: boolean) => Promise<void> |
| 56 | /** Go to previous slide */ |
| 57 | prevSlide: (lastClicks?: boolean) => Promise<void> |
| 58 | /** Go to slide */ |
| 59 | go: (no: number | string, clicks?: number, force?: boolean) => Promise<void> |
| 60 | /** Go to the first slide */ |
| 61 | goFirst: () => Promise<void> |
| 62 | /** Go to the last slide */ |
| 63 | goLast: () => Promise<void> |
| 64 | |
| 65 | /** Enter presenter mode */ |
| 66 | enterPresenter: () => void |
| 67 | /** Exit presenter mode */ |
| 68 | exitPresenter: () => void |
| 69 | } |
| 70 | |
| 71 | export interface SlidevContextNavState { |
| 72 | router: Router |
| 73 | currentRoute: ComputedRef<RouteLocationNormalized> |
| 74 | isPrintMode: ComputedRef<boolean> |
| 75 | isPrintWithClicks: Ref<boolean> |
| 76 | isEmbedded: ComputedRef<boolean> |
| 77 | isPlaying: ComputedRef<boolean> |
| 78 | isPresenter: ComputedRef<boolean> |
| 79 | isNotesViewer: ComputedRef<boolean> |
| 80 | isPresenterAvailable: ComputedRef<boolean> |
| 81 | hasPrimarySlide: ComputedRef<boolean> |
| 82 | currentSlideNo: ComputedRef<number> |
| 83 | currentSlideRoute: ComputedRef<SlideRoute> |
| 84 | clicksContext: ComputedRef<ClicksContext> |
| 85 | queryClicksRaw: Ref<string> |
| 86 | queryClicks: WritableComputedRef<number> |
| 87 | printRange: Ref<number[]> |
| 88 | getPrimaryClicks: (route: SlideRoute) => ClicksContext |
| 89 | } |
| 90 | |
| 91 | export interface SlidevContextNavFull extends SlidevContextNav, SlidevContextNavState { } |
| 92 | |
| 93 | export function useNavBase( |
| 94 | currentSlideRoute: ComputedRef<SlideRoute>, |
| 95 | clicksContext: ComputedRef<ClicksContext>, |
| 96 | queryClicks: Ref<number> = ref(0), |
| 97 | isPresenter: Ref<boolean>, |
| 98 | isPrint: Ref<boolean>, |
| 99 | router?: Router, |
| 100 | ): SlidevContextNav { |
| 101 | const total = computed(() => slides.value.length) |
| 102 | |
| 103 | const navDirection = ref(0) |
| 104 | const clicksDirection = ref(0) |
| 105 | |
| 106 | const currentPath = computed(() => getSlidePath(currentSlideRoute.value, isPresenter.value)) |
| 107 | const currentSlideNo = computed(() => currentSlideRoute.value.no) |
| 108 | const currentLayout = computed(() => currentSlideRoute.value.meta?.layout || (currentSlideNo.value === 1 ? 'cover' : 'default')) |
| 109 | const currentFrontmatter = computed(() => currentSlideRoute.value.meta.slide.frontmatter) |
| 110 | |
| 111 | const clicks = computed(() => clicksContext.value.current) |
| 112 | const clicksStart = computed(() => clicksContext.value.clicksStart) |
| 113 | const clicksTotal = computed(() => clicksContext.value.total) |
| 114 | const nextRoute = computed(() => slides.value[Math.min(slides.value.length, currentSlideNo.value + 1) - 1]) |
| 115 | const prevRoute = computed(() => slides.value[Math.max(1, currentSlideNo.value - 1) - 1]) |
| 116 | const hasNext = computed(() => currentSlideNo.value < slides.value.length || clicks.value < clicksTotal.value) |
| 117 | const hasPrev = computed(() => currentSlideNo.value > 1 || clicks.value > 0) |
| 118 | |
| 119 | const currentTransition = computed(() => isPrint.value ? undefined : getCurrentTransition(navDirection.value, currentSlideRoute.value, prevRoute.value)) |
| 120 | |
| 121 | watch(currentSlideRoute, (next, prev) => { |
| 122 | navDirection.value = next.no - prev.no |
| 123 | }) |
| 124 | |
| 125 | async function openInEditor(url?: string) { |
| 126 | if (!__DEV__) |
| 127 | return false |
| 128 | if (url == null) { |
| 129 | const slide = currentSlideRoute.value?.meta?.slide |
| 130 | if (!slide) |
| 131 | return false |
| 132 | url = `${slide.filepath}:${slide.start}` |
| 133 | } |
| 134 | await fetch(`/__open-in-editor?file=${encodeURIComponent(url)}`) |
| 135 | return true |
| 136 | } |
| 137 | |
| 138 | const tocTree = useTocTree( |
| 139 | slides, |
| 140 | currentSlideNo, |
| 141 | currentSlideRoute, |
| 142 | ) |
| 143 | |
| 144 | async function next() { |
| 145 | clicksDirection.value = 1 |
| 146 | if (clicksTotal.value <= queryClicks.value) |
| 147 | await nextSlide() |
| 148 | else |
| 149 | queryClicks.value += 1 |
| 150 | } |
| 151 | |
| 152 | async function prev() { |
| 153 | clicksDirection.value = -1 |
| 154 | if (queryClicks.value <= clicksStart.value) |
| 155 | await prevSlide(true) |
| 156 | else |
| 157 | queryClicks.value -= 1 |
| 158 | } |
| 159 | |
| 160 | async function nextSlide(lastClicks = false) { |
| 161 | clicksDirection.value = 1 |
| 162 | if (currentSlideNo.value < slides.value.length) { |
| 163 | await go( |
| 164 | currentSlideNo.value + 1, |
| 165 | lastClicks && !isPrint.value ? CLICKS_MAX : undefined, |
| 166 | ) |
| 167 | } |
| 168 | } |
| 169 | |
| 170 | async function prevSlide(lastClicks = false) { |
| 171 | clicksDirection.value = -1 |
| 172 | if (currentSlideNo.value > 1) { |
| 173 | await go( |
| 174 | currentSlideNo.value - 1, |
| 175 | lastClicks && !isPrint.value ? CLICKS_MAX : undefined, |
| 176 | ) |
| 177 | } |
| 178 | } |
| 179 | |
| 180 | function goFirst() { |
| 181 | return go(1) |
| 182 | } |
| 183 | |
| 184 | function goLast() { |
| 185 | return go(total.value) |
| 186 | } |
| 187 | |
| 188 | async function go(no: number | string, clicks: number = 0, force = false) { |
| 189 | hmrSkipTransition.value = false |
| 190 | const pageChanged = currentSlideNo.value !== no |
| 191 | const clicksChanged = clicks !== queryClicks.value |
| 192 | const meta = getSlide(no)?.meta |
| 193 | const clicksStart = meta?.slide?.frontmatter.clicksStart ?? 0 |
| 194 | clicks = clamp(clicks, clicksStart, meta?.__clicksContext?.total ?? CLICKS_MAX) |
| 195 | if (force || pageChanged || clicksChanged) { |
| 196 | await router?.push({ |
| 197 | path: getSlidePath(no, isPresenter.value, router.currentRoute.value.name === 'export'), |
| 198 | query: { |
| 199 | ...router.currentRoute.value.query, |
| 200 | clicks: clicks === 0 ? undefined : clicks.toString(), |
| 201 | embedded: location.search.includes('embedded') ? 'true' : undefined, |
| 202 | }, |
| 203 | }) |
| 204 | } |
| 205 | } |
| 206 | |
| 207 | function enterPresenter() { |
| 208 | router?.push({ |
| 209 | path: getSlidePath(currentSlideNo.value, true), |
| 210 | query: { ...router.currentRoute.value.query }, |
| 211 | }) |
| 212 | } |
| 213 | function exitPresenter() { |
| 214 | router?.push({ |
| 215 | path: getSlidePath(currentSlideNo.value, false), |
| 216 | query: { ...router.currentRoute.value.query }, |
| 217 | }) |
| 218 | } |
| 219 | |
| 220 | return { |
| 221 | slides, |
| 222 | total, |
| 223 | currentPath, |
| 224 | currentSlideNo, |
| 225 | currentPage: currentSlideNo, |
| 226 | currentSlideRoute, |
| 227 | currentLayout, |
| 228 | currentFrontmatter, |
| 229 | currentTransition, |
| 230 | clicksDirection, |
| 231 | nextRoute, |
| 232 | prevRoute, |
| 233 | clicksContext, |
| 234 | clicks, |
| 235 | clicksStart, |
| 236 | clicksTotal, |
| 237 | hasNext, |
| 238 | hasPrev, |
| 239 | tocTree, |
| 240 | navDirection, |
| 241 | openInEditor, |
| 242 | next, |
| 243 | prev, |
| 244 | go, |
| 245 | goLast, |
| 246 | goFirst, |
| 247 | nextSlide, |
| 248 | prevSlide, |
| 249 | enterPresenter, |
| 250 | exitPresenter, |
| 251 | } |
| 252 | } |
| 253 | |
| 254 | export function useFixedNav( |
| 255 | currentSlideRoute: SlideRoute, |
| 256 | clicksContext: ClicksContext, |
| 257 | ): SlidevContextNav { |
| 258 | const noop = async () => { } |
| 259 | return { |
| 260 | ...useNavBase( |
| 261 | computed(() => currentSlideRoute), |
| 262 | computed(() => clicksContext), |
| 263 | ref(CLICKS_MAX), |
| 264 | ref(false), |
| 265 | ref(false), |
| 266 | ), |
| 267 | next: noop, |
| 268 | prev: noop, |
| 269 | nextSlide: noop, |
| 270 | prevSlide: noop, |
| 271 | goFirst: noop, |
| 272 | goLast: noop, |
| 273 | go: noop, |
| 274 | } |
| 275 | } |
| 276 | |
| 277 | const useNavState = createSharedComposable((): SlidevContextNavState => { |
| 278 | const router = useRouter() |
| 279 | const currentRoute = useRoute() |
| 280 | |
| 281 | const query = computed(() => { |
| 282 | // eslint-disable-next-line ts/no-unused-expressions |
| 283 | router?.currentRoute?.value?.query |
| 284 | return new URLSearchParams(location.search) |
| 285 | }) |
| 286 | const isPrintMode = computed(() => query.value.has('print') || currentRoute.name === 'export') |
| 287 | const isPrintWithClicks = ref(query.value.get('print') === 'clicks') |
| 288 | const isEmbedded = computed(() => query.value.has('embedded')) |
| 289 | const isPlaying = computed(() => currentRoute.name === 'play') |
| 290 | const isPresenter = computed(() => currentRoute.name === 'presenter') |
| 291 | const isNotesViewer = computed(() => currentRoute.name === 'notes') |
| 292 | const isPresenterAvailable = computed(() => !isPresenter.value && (!configs.remote || query.value.get('password') === configs.remote)) |
| 293 | const hasPrimarySlide = computed(() => !!currentRoute.params.no) |
| 294 | const currentSlideNo = computed(() => hasPrimarySlide.value ? getSlide(currentRoute.params.no as string)?.no ?? 1 : 1) |
| 295 | const currentSlideRoute = computed(() => slides.value[currentSlideNo.value - 1]) |
| 296 | const printRange = ref(parseRangeString(slides.value.length, currentRoute?.query?.range as string | undefined)) |
| 297 | |
| 298 | const queryClicksRaw = useRouteQuery<string>('clicks', '0') |
| 299 | |
| 300 | const clicksContext = computed(() => getPrimaryClicks(currentSlideRoute.value)) |
| 301 | |
| 302 | const queryClicks = computed({ |
| 303 | get() { |
| 304 | let v = +(queryClicksRaw.value || 0) |
| 305 | if (Number.isNaN(v)) |
| 306 | v = 0 |
| 307 | return v |
| 308 | }, |
| 309 | set(v) { |
| 310 | hmrSkipTransition.value = false |
| 311 | queryClicksRaw.value = v.toString() |
| 312 | }, |
| 313 | }) |
| 314 | |
| 315 | function getPrimaryClicks( |
| 316 | route: SlideRoute, |
| 317 | ): ClicksContext { |
| 318 | if (route?.meta?.__clicksContext) |
| 319 | return route.meta.__clicksContext |
| 320 | |
| 321 | const thisNo = route.no |
| 322 | const context = createClicksContextBase( |
| 323 | computed({ |
| 324 | get() { |
| 325 | if (currentSlideNo.value === thisNo) |
| 326 | return Math.max(+(queryClicksRaw.value ?? 0), context.clicksStart) |
| 327 | else if (currentSlideNo.value > thisNo) |
| 328 | return CLICKS_MAX |
| 329 | else |
| 330 | return context.clicksStart |
| 331 | }, |
| 332 | set(v) { |
| 333 | if (currentSlideNo.value === thisNo) |
| 334 | queryClicksRaw.value = v.toString() |
| 335 | }, |
| 336 | }), |
| 337 | route?.meta.slide?.frontmatter.clicksStart ?? 0, |
| 338 | route?.meta.clicks, |
| 339 | ) |
| 340 | |
| 341 | if (route?.meta) |
| 342 | route.meta.__clicksContext = context |
| 343 | |
| 344 | return context |
| 345 | } |
| 346 | |
| 347 | return { |
| 348 | router, |
| 349 | currentRoute: computed(() => currentRoute), |
| 350 | isPrintMode, |
| 351 | isPrintWithClicks, |
| 352 | isEmbedded, |
| 353 | isPlaying, |
| 354 | isPresenter, |
| 355 | isNotesViewer, |
| 356 | isPresenterAvailable, |
| 357 | hasPrimarySlide, |
| 358 | currentSlideNo, |
| 359 | currentSlideRoute, |
| 360 | clicksContext, |
| 361 | queryClicksRaw, |
| 362 | queryClicks, |
| 363 | printRange, |
| 364 | getPrimaryClicks, |
| 365 | } |
| 366 | }) |
| 367 | |
| 368 | const useSharedNav = createSharedComposable((): SlidevContextNavFull => { |
| 369 | const state = useNavState() |
| 370 | const router = useRouter() |
| 371 | |
| 372 | const nav = useNavBase( |
| 373 | state.currentSlideRoute, |
| 374 | state.clicksContext, |
| 375 | state.queryClicks, |
| 376 | state.isPresenter, |
| 377 | state.isPrintMode, |
| 378 | router, |
| 379 | ) |
| 380 | |
| 381 | watch( |
| 382 | [nav.total, state.currentRoute], |
| 383 | async () => { |
| 384 | const no = state.currentRoute.value.params.no as string |
| 385 | if (state.hasPrimarySlide.value && !getSlide(no)) { |
| 386 | if (no && no !== 'index.html') { |
| 387 | // The current slide may has been removed. Redirect to the last slide. |
| 388 | await nav.go(nav.total.value, 0, true) |
| 389 | } |
| 390 | else { |
| 391 | // Redirect to the first slide |
| 392 | await nav.go(1, 0, true) |
| 393 | } |
| 394 | } |
| 395 | }, |
| 396 | { flush: 'pre', immediate: true }, |
| 397 | ) |
| 398 | |
| 399 | return { |
| 400 | ...nav, |
| 401 | ...state, |
| 402 | } |
| 403 | }) |
| 404 | |
| 405 | export function useNav(): SlidevContextNavFull { |
| 406 | const nav = useSharedNav() |
| 407 | // `useNav()` is also called outside of `setup()`, most notably from the |
| 408 | // `mounted`/`created` hooks of the `v-motion` and `v-mark` directives. |
| 409 | // `injectLocal` throws there, and there is no slide-local context to read |
| 410 | // anyway, so fall back to the shared nav. |
| 411 | const context = hasInjectionContext() |
| 412 | ? injectLocal(injectionSlidevContext, undefined) |
| 413 | : undefined |
| 414 | if (!context) |
| 415 | return nav |
| 416 | |
| 417 | const localNav = toRaw(context).nav as unknown as SlidevContextNav |
| 418 | return { |
| 419 | ...nav, |
| 420 | ...localNav, |
| 421 | } |
| 422 | } |
| 423 |