| 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 |