返回 slidev
useNav.ts
根目录 / packages / client / composables / useNav.ts
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
423 lines TYPESCRIPT