返回 slidev
overview.vue
根目录 / packages / client / pages / overview.vue
1 <script setup lang="ts">
2 import type { ClicksContext, SlideRoute } from '@slidev/types'
3 import { useHead } from '@unhead/vue'
4 import { computed, nextTick, onMounted, onUnmounted, reactive, ref, shallowRef } from 'vue'
5 import { useRoute, useRouter } from 'vue-router'
6 import { createFixedClicks } from '../composables/useClicks'
7 import { useNav } from '../composables/useNav'
8 import { CLICKS_MAX } from '../constants'
9 import { pathPrefix, slideAspect, slidesTitle } from '../env'
10 import ClicksSlider from '../internals/ClicksSlider.vue'
11 import DrawingPreview from '../internals/DrawingPreview.vue'
12 import IconButton from '../internals/IconButton.vue'
13 import NoteEditable from '../internals/NoteEditable.vue'
14 import SlideContainer from '../internals/SlideContainer.vue'
15 import SlideWrapper from '../internals/SlideWrapper.vue'
16 import { isColorSchemaConfigured, isDark, toggleDark } from '../logic/dark'
17 import { getSlidePath } from '../logic/slides'
18 import { windowSize } from '../state'
19
20 const cardWidth = 450
21
22 useHead({ title: `Overview - ${slidesTitle}` })
23
24 const currentRoute = useRoute()
25 const router = useRouter()
26 const { openInEditor, slides, isEmbedded } = useNav()
27 const isPreviewMode = computed(() => currentRoute.query.mode === 'preview')
28 const isEmbeddedPreviewMode = computed(() => isPreviewMode.value && isEmbedded.value)
29 const overviewCardWidth = computed(() => {
30 if (!isPreviewMode.value)
31 return cardWidth
32 if (isEmbeddedPreviewMode.value)
33 return Math.max(0, windowSize.width.value - 16)
34 return Math.min(900, Math.max(320, windowSize.width.value - 160))
35 })
36 const overviewSlideHeight = computed(() => overviewCardWidth.value / slideAspect.value)
37
38 const blocks: Map<number, HTMLElement> = reactive(new Map())
39 const slidePreviews: Map<number, HTMLElement> = reactive(new Map())
40 const activeBlocks = ref<number[]>([])
41 const scroller = ref<HTMLElement>()
42 const edittingNote = ref<number | null>(null)
43 let ignoreOverviewScrollUntil = 0
44 let pendingOverviewScrollNo: number | undefined
45 let overviewScrollTimer: ReturnType<typeof setTimeout> | undefined
46 const wordCounts = computed(() => slides.value.map(route => wordCount(route.meta?.slide?.note || '')))
47 const totalWords = computed(() => wordCounts.value.reduce((a, b) => a + b, 0))
48 const totalClicks = computed(() => slides.value.map(route => getSlideClicks(route)).reduce((a, b) => a + b, 0))
49 const slideNoDigits = computed(() => String(Math.max(1, slides.value.length)).length)
50
51 const activeSlide = shallowRef<SlideRoute>()
52 const clicksContextMap = new WeakMap<SlideRoute, ClicksContext>()
53 function getClicksContext(route: SlideRoute) {
54 // We create a local clicks context to calculate the total clicks of the slide
55 if (!clicksContextMap.has(route))
56 clicksContextMap.set(route, createFixedClicks(route, CLICKS_MAX))
57 return clicksContextMap.get(route)!
58 }
59
60 function getSlideClicks(route: SlideRoute) {
61 return route.meta?.clicks || getClicksContext(route)?.total
62 }
63
64 function toggleRoute(route: SlideRoute) {
65 if (activeSlide.value === route)
66 activeSlide.value = undefined
67 else
68 activeSlide.value = route
69 }
70
71 function wordCount(str: string) {
72 const pattern = /[\w`'\-\u0392-\u03C9\u00C0-\u00FF\u0600-\u06FF\u0400-\u04FF]+|[\u4E00-\u9FFF\u3400-\u4DBF\uF900-\uFAFF\u3040-\u309F\uAC00-\uD7AF]+/g
73 const m = str.match(pattern)
74 let count = 0
75 if (!m)
76 return 0
77 for (let i = 0; i < m.length; i++) {
78 if (m[i].charCodeAt(0) >= 0x4E00) {
79 count += m[i].length
80 }
81 else {
82 count += 1
83 }
84 }
85 return count
86 }
87
88 function checkActiveBlocks() {
89 const viewportHeight = window.innerHeight || document.documentElement.clientHeight
90 let active: { idx: number, visibleHeight: number } | undefined
91 const fullyVisible: number[] = []
92
93 for (const [idx, el] of blocks.entries()) {
94 const rect = el.getBoundingClientRect()
95 const visibleHeight = Math.max(0, Math.min(rect.bottom, viewportHeight) - Math.max(rect.top, 0))
96 if (visibleHeight === 0)
97 continue
98 if (visibleHeight >= rect.height)
99 fullyVisible.push(idx)
100 if (!active || visibleHeight > active.visibleHeight)
101 active = { idx, visibleHeight }
102 }
103
104 activeBlocks.value = fullyVisible.length ? fullyVisible : active ? [active.idx] : []
105 }
106
107 function openSlideInNewTab(path: string) {
108 const a = document.createElement('a')
109 a.target = '_blank'
110 a.href = pathPrefix + path.slice(1)
111 a.click()
112 }
113
114 function openSlideInBrowser(path: string) {
115 const url = new URL(pathPrefix + path.slice(1), location.href).href
116 if (isEmbedded.value) {
117 window.parent.postMessage({
118 target: 'slidev',
119 sender: 'slidev',
120 type: 'open-external',
121 url,
122 }, '*')
123 return
124 }
125 openSlideInNewTab(path)
126 }
127
128 function scrollToSlide(idx: number) {
129 const el = blocks.get(idx)
130 if (el)
131 el.scrollIntoView({ behavior: 'smooth', block: 'start' })
132 }
133
134 function getSlidePreviewTop(idx: number) {
135 const el = slidePreviews.get(idx) || blocks.get(idx)
136 if (!el || !scroller.value)
137 return null
138 const scrollerRect = scroller.value.getBoundingClientRect()
139 const elRect = el.getBoundingClientRect()
140 return elRect.top - scrollerRect.top + scroller.value.scrollTop
141 }
142
143 function scrollSlideNoIntoCenter(no: number) {
144 if (!scroller.value || slides.value.length === 0)
145 return
146 const clamped = Math.min(Math.max(no, 1), slides.value.length)
147 const idx = Math.floor(clamped) - 1
148 const progress = clamped - (idx + 1)
149 const start = getSlidePreviewTop(idx)
150 const end = getSlidePreviewTop(idx + 1)
151 if (start == null)
152 return
153 const top = start
154 + (end == null ? 0 : (end - start) * progress)
155 - scroller.value.clientHeight * 0.5
156 if (Math.abs(scroller.value.scrollTop - top) < 1)
157 return
158 scroller.value.scrollTo({ top })
159 }
160
161 function getInitialSlideNo() {
162 const value = currentRoute.query.slideNo
163 const no = Number(Array.isArray(value) ? value[0] : value)
164 return Number.isFinite(no) && no > 0 ? no : undefined
165 }
166
167 function updateSlideNoQuery(no: number) {
168 if (!isEmbeddedPreviewMode.value)
169 return
170 const slideNo = Number(no.toFixed(3)).toString()
171 if (currentRoute.query.slideNo === slideNo)
172 return
173 router.replace({
174 query: {
175 ...currentRoute.query,
176 slideNo,
177 },
178 })
179 }
180
181 function getCenteredSlideNo() {
182 if (!scroller.value || slides.value.length === 0)
183 return null
184 const center = scroller.value.scrollTop + scroller.value.clientHeight * 0.5
185 const tops = slides.value
186 .map((_, idx) => getSlidePreviewTop(idx))
187 .filter((top): top is number => top != null)
188 if (tops.length === 0)
189 return null
190 if (tops.length === 1 || center <= tops[0])
191 return 1
192 for (let i = 1; i < tops.length; i++) {
193 if (center <= tops[i]) {
194 const span = Math.max(1, tops[i] - tops[i - 1])
195 return i + (center - tops[i - 1]) / span
196 }
197 }
198 return slides.value.length
199 }
200
201 function postOverviewScroll(no: number) {
202 pendingOverviewScrollNo = no
203 if (overviewScrollTimer)
204 return
205 overviewScrollTimer = setTimeout(() => {
206 overviewScrollTimer = undefined
207 const no = pendingOverviewScrollNo
208 pendingOverviewScrollNo = undefined
209 if (no == null)
210 return
211 updateSlideNoQuery(no)
212 if (Date.now() < ignoreOverviewScrollUntil)
213 return
214 window.parent.postMessage({
215 target: 'slidev',
216 sender: 'slidev',
217 type: 'overview-scroll',
218 no,
219 }, '*')
220 }, 50)
221 }
222
223 function onOverviewScroll() {
224 checkActiveBlocks()
225 if (!isEmbeddedPreviewMode.value || Date.now() < ignoreOverviewScrollUntil)
226 return
227 const no = getCenteredSlideNo()
228 if (no != null)
229 postOverviewScroll(no)
230 }
231
232 function onOverviewMessage({ data }: MessageEvent) {
233 if (
234 !isEmbeddedPreviewMode.value
235 || data?.target !== 'slidev'
236 || data.sender !== 'vscode'
237 || data.type !== 'overview-scroll'
238 ) {
239 return
240 }
241 const no = Number(data.no)
242 if (no > 0) {
243 ignoreOverviewScrollUntil = Date.now() + 300
244 pendingOverviewScrollNo = undefined
245 updateSlideNoQuery(no)
246 scrollSlideNoIntoCenter(no)
247 }
248 }
249
250 function onMarkerClick(e: MouseEvent, clicks: number, route: SlideRoute) {
251 const ctx = getClicksContext(route)
252 if (ctx.current === clicks)
253 ctx.current = CLICKS_MAX
254 else
255 ctx.current = clicks
256 e.preventDefault()
257 }
258
259 function openOverviewSlideSource(e: MouseEvent, route: SlideRoute) {
260 if (e.ctrlKey || e.metaKey) {
261 e.preventDefault()
262 openSlideInBrowser(getSlidePath(route, false))
263 return
264 }
265 const slide = route.meta?.slide
266 if (!slide)
267 return
268 window.parent.postMessage({
269 target: 'slidev',
270 type: 'command',
271 command: 'goto',
272 args: [slide.filepath, slide.sourceIndex],
273 }, '*')
274 }
275
276 onMounted(() => {
277 window.addEventListener('message', onOverviewMessage)
278 const initialSlideNo = isEmbeddedPreviewMode.value ? getInitialSlideNo() : undefined
279 if (initialSlideNo != null) {
280 ignoreOverviewScrollUntil = Date.now() + 300
281 scrollSlideNoIntoCenter(initialSlideNo)
282 }
283 nextTick(() => {
284 if (initialSlideNo != null)
285 scrollSlideNoIntoCenter(initialSlideNo)
286 checkActiveBlocks()
287 })
288 })
289
290 onUnmounted(() => {
291 window.removeEventListener('message', onOverviewMessage)
292 if (overviewScrollTimer)
293 clearTimeout(overviewScrollTimer)
294 })
295 </script>
296
297 <template>
298 <div class="h-screen w-screen of-hidden flex">
299 <nav
300 v-if="!isEmbedded"
301 class="grid grid-rows-[auto_max-content] border-r border-main select-none max-h-full h-full"
302 >
303 <div class="relative">
304 <div class="absolute left-0 top-0 bottom-0 w-200 flex flex-col flex-auto items-end group p-6px md:p-10px gap-1 max-h-full of-x-visible of-y-auto" style="direction:rtl">
305 <div
306 v-for="(route, idx) of slides"
307 :key="route.no"
308 class="relative"
309 style="direction:ltr"
310 >
311 <button
312 class="relative transition duration-300 w-8 h-8 rounded hover:bg-active hover:op100"
313 :class="activeBlocks.includes(idx) ? 'op100 text-primary bg-gray:5' : 'op20'"
314 @click="scrollToSlide(idx)"
315 >
316 <div>{{ idx + 1 }}</div>
317 </button>
318 <div
319 v-if="route.meta?.slide?.title"
320 class="pointer-events-none select-none absolute left-110% top-50% translate-y--50% ws-nowrap z-label px2 slidev-glass-effect transition duration-400 op0 group-hover:op100"
321 :class="activeBlocks.includes(idx) ? 'text-primary' : 'text-main important-text-op-50'"
322 >
323 {{ route.meta?.slide?.title }}
324 </div>
325 </div>
326 </div>
327 </div>
328 <div p2 border="t main">
329 <IconButton
330 v-if="!isColorSchemaConfigured"
331 :title="isDark ? 'Switch to light mode theme' : 'Switch to dark mode theme'"
332 @click="toggleDark()"
333 >
334 <carbon-moon v-if="isDark" />
335 <carbon-sun v-else />
336 </IconButton>
337 <IconButton
338 v-else
339 :title="isDark ? 'Dark mode' : 'Light mode'"
340 pointer-events-none op50
341 >
342 <carbon-moon v-if="isDark" />
343 <carbon-sun v-else />
344 </IconButton>
345 </div>
346 </nav>
347 <main
348 ref="scroller"
349 class="flex-1 h-full of-auto"
350 :style="`grid-template-columns: repeat(auto-fit,minmax(${cardWidth}px,1fr))`"
351 @scroll="onOverviewScroll"
352 >
353 <div
354 v-for="(route, idx) of slides"
355 :key="route.no"
356 :ref="el => blocks.set(idx, el as any)"
357 class="overview-slide-block relative of-hidden flex gap-4 min-h-50"
358 :class="[idx === 0 && !isEmbeddedPreviewMode ? 'pt2' : '', isEmbeddedPreviewMode ? 'justify-center' : 'border-t border-main']"
359 >
360 <div
361 v-if="!isEmbeddedPreviewMode"
362 class="select-none text-right my5 flex flex-col justify-between items-end"
363 :class="isPreviewMode ? 'w-9' : 'w-13'"
364 :style="{ height: `${overviewSlideHeight}px` }"
365 >
366 <div class="self-center text-3xl op20 mb2 text-center mr--14px tabular-nums" :style="{ width: `${slideNoDigits}ch` }">
367 {{ idx + 1 }}
368 </div>
369 <div class="flex flex-col gap-1 mx-1 items-end">
370 <IconButton
371 class="overview-slide-action mr--4 op0"
372 :class="isPreviewMode ? 'text-lg' : ''"
373 title="Play in new tab"
374 @click="openSlideInNewTab(getSlidePath(route, false))"
375 >
376 <div class="i-carbon:presentation-file" />
377 </IconButton>
378 <IconButton
379 v-if="__DEV__ && route.meta?.slide"
380 class="overview-slide-action mr--4 op0"
381 :class="isPreviewMode ? 'text-lg' : ''"
382 title="Open in editor"
383 @click="openInEditor(`${route.meta.slide.filepath}:${route.meta.slide.start}`)"
384 >
385 <div class="i-carbon:edit" />
386 </IconButton>
387 </div>
388 </div>
389 <div
390 class="flex flex-col"
391 :class="isEmbeddedPreviewMode ? 'my1 gap-0' : 'my5 gap-2'"
392 :style="{ width: `${overviewCardWidth}px` }"
393 >
394 <div
395 v-if="isEmbeddedPreviewMode"
396 class="flex items-end gap-2"
397 >
398 <button
399 type="button"
400 class="select-none pl-1 text-lg leading-tight op60 tabular-nums hover:op90 hover:underline underline-offset-2"
401 @click="openOverviewSlideSource($event, route)"
402 >
403 {{ idx + 1 }}
404 </button>
405 <ClicksSlider
406 v-if="getSlideClicks(route)"
407 :active="activeSlide === route"
408 :clicks-context="getClicksContext(route)"
409 resettable
410 compact
411 attached
412 class="ml-auto w-88 min-w-[70%] max-w-[calc(100%-3rem)]"
413 @dblclick="toggleRoute(route)"
414 @activate="activeSlide = route"
415 @reset="activeSlide = undefined"
416 />
417 </div>
418 <div
419 :ref="el => slidePreviews.set(idx, el as any)"
420 class="border rounded border-main overflow-hidden bg-main h-max"
421 :class="[isEmbeddedPreviewMode && getSlideClicks(route) ? 'rounded-tr-0' : '', isEmbeddedPreviewMode ? '' : 'select-none']"
422 @dblclick="!isEmbeddedPreviewMode && openSlideInNewTab(getSlidePath(route, false))"
423 >
424 <SlideContainer
425 :key="route.no"
426 :width="overviewCardWidth"
427 :class="isEmbeddedPreviewMode ? '' : 'pointer-events-none important:[&_*]:select-none'"
428 >
429 <SlideWrapper
430 :clicks-context="getClicksContext(route)"
431 :route="route"
432 render-context="overview"
433 />
434 <DrawingPreview :page="route.no" />
435 </SlideContainer>
436 </div>
437 <ClicksSlider
438 v-if="getSlideClicks(route) && !isEmbeddedPreviewMode"
439 :active="activeSlide === route"
440 :clicks-context="getClicksContext(route)"
441 resettable
442 class="ml-1 w-[calc(100%-0.25rem)]"
443 :class="isPreviewMode ? '' : 'mt-2'"
444 @dblclick="toggleRoute(route)"
445 @activate="activeSlide = route"
446 @reset="activeSlide = undefined"
447 />
448 </div>
449 <NoteEditable
450 v-if="!isPreviewMode"
451 :no="route.no"
452 class="relative z-1 max-w-250 w-250 text-lg rounded p3"
453 :auto-height="true"
454 :highlight="activeSlide === route"
455 :editing="edittingNote === route.no"
456 :clicks-context="getClicksContext(route)"
457 @dblclick="edittingNote !== route.no ? edittingNote = route.no : null"
458 @update:editing="edittingNote = null"
459 @marker-click="(e, clicks) => onMarkerClick(e, clicks, route)"
460 />
461 <div
462 v-if="!isPreviewMode && wordCounts[idx] > 0"
463 class="select-none absolute bottom-0 right-0 bg-main rounded-tl p2 op35 text-xs"
464 >
465 {{ wordCounts[idx] }} words
466 </div>
467 </div>
468 </main>
469 <div
470 v-if="!isEmbedded"
471 class="absolute z-2 top-0 right-0 px3 py1.5 border-b border-l rounded-lb bg-main/80 backdrop-blur border-main select-none"
472 >
473 <div class="text-xs op50">
474 {{ slides.length }} slides ·
475 {{ totalClicks + slides.length - 1 }} clicks ·
476 {{ totalWords }} words
477 </div>
478 </div>
479 </div>
480 </template>
481
482 <style scoped>
483 .overview-slide-block:hover .overview-slide-action {
484 opacity: 0.6;
485 }
486
487 .overview-slide-action:hover {
488 opacity: 0.8;
489 }
490 </style>
491
491 lines Plain Text