Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

perf: virtual scroll for overview and quick overview #1610

Draft
wants to merge 14 commits into
base: main
Choose a base branch
from
30 changes: 30 additions & 0 deletions packages/client/composables/useDynamicVirtualList.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { UseVirtualListOptions } from '@vueuse/core'
import { debouncedWatch, useVirtualList } from '@vueuse/core'
import type { MaybeRef } from 'vue'
import { effectScope, shallowRef } from 'vue'

/**
* `useVirtualList`'s `itemHeight` is not reactive, so we need to re-create the virtual list when the card height changes.
*/
export function useDynamicVirtualList<T>(list: MaybeRef<T[]>, getOptions: () => UseVirtualListOptions) {
type VirtualListReturn = ReturnType<typeof useVirtualList<T>>
const virtualList = shallowRef<VirtualListReturn>()
debouncedWatch(
getOptions,
(options, _oldOptions, onCleanup) => {
const scope = effectScope()
scope.run(() => {
virtualList.value = useVirtualList(
list,
options,
)
})
onCleanup(() => scope.stop())
},
{
immediate: true,
debounce: 50,
},
)
return virtualList
}
129 changes: 76 additions & 53 deletions packages/client/internals/QuickOverview.vue
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import { useElementSize, useEventListener } from '@vueuse/core'
import { computed, ref, watchEffect } from 'vue'
import { breakpoints, showOverview, windowSize } from '../state'
import type { SlideRoute } from '@slidev/types'
import { breakpoints, showOverview } from '../state'
import { currentOverviewPage, overviewRowCount } from '../logic/overview'
import { createFixedClicks } from '../composables/useClicks'
import { CLICKS_MAX } from '../constants'
import { useNav } from '../composables/useNav'
import { slideAspect } from '../env'
import { useDynamicVirtualList } from '../composables/useDynamicVirtualList'
import SlideContainer from './SlideContainer.vue'
import SlideWrapper from './SlideWrapper.vue'
import DrawingPreview from './DrawingPreview.vue'
Expand All @@ -22,29 +25,44 @@ function go(page: number) {
close()
}

function focus(page: number) {
if (page === currentOverviewPage.value)
return true
return false
}

const xs = breakpoints.smaller('xs')
const sm = breakpoints.smaller('sm')

const padding = 4 * 16 * 2
const gap = 2 * 16
const gapX = 2 * 16
const gapY = 4 * 8 // mb-8

const containerEl = ref<HTMLElement>()
const { width: containerWidth } = useElementSize(containerEl)

const cardWidth = computed(() => {
if (xs.value)
return windowSize.width.value - padding
else if (sm.value)
return (windowSize.width.value - padding - gap) / 2
return 300
return xs.value
? containerWidth.value
: Math.min(300, (containerWidth.value - gapX) / 2)
})

const numOfCols = computed(() => {
return xs.value
? 1
: Math.floor((containerWidth.value + gapX) / (cardWidth.value + gapX))
})

const cardHeight = computed(() => cardWidth.value / slideAspect.value)

const numOfRows = computed(() => {
return Math.ceil(slides.value.length / numOfCols.value)
})

const rowCount = computed(() => {
return Math.floor((windowSize.width.value - padding) / (cardWidth.value + gap))
const slideRows = computed(() => {
const cols = numOfCols.value
const rows: SlideRoute[][] = []
for (let i = 0; i < numOfRows.value; i++)
rows.push(slides.value.slice(i * cols, (i + 1) * cols))
return rows
})

const virtualList = useDynamicVirtualList(slideRows, () => ({
itemHeight: cardHeight.value + gapY,
}))

const keyboardBuffer = ref<string>('')

useEventListener('keypress', (e) => {
Expand Down Expand Up @@ -95,7 +113,7 @@ watchEffect(() => {
// we focus on the right page.
currentOverviewPage.value = currentSlideNo.value
// Watch rowCount, make sure up and down shortcut work correctly.
overviewRowCount.value = rowCount.value
overviewRowCount.value = numOfCols.value
})

const activeSlidesLoaded = ref(false)
Expand All @@ -114,47 +132,52 @@ setTimeout(() => {
<div
v-if="showOverview || activeSlidesLoaded"
v-show="showOverview"
class="fixed left-0 right-0 top-0 h-[calc(var(--vh,1vh)*100)] z-20 bg-main !bg-opacity-75 p-16 py-20 overflow-y-auto backdrop-blur-5px"
v-bind="virtualList?.containerProps"
class="fixed left-0 right-0 top-0 h-[calc(var(--vh,1vh)*100)] z-20 bg-main !bg-opacity-75 px-16 py-20 overflow-y-auto backdrop-blur-5px"
@click="close"
>
<div
class="grid gap-y-4 gap-x-8 w-full"
:style="`grid-template-columns: repeat(auto-fit,minmax(${cardWidth}px,1fr))`"
>
<div ref="containerEl" v-bind="virtualList?.wrapperProps.value">
<div
v-for="(route, idx) of slides"
:key="route.no"
class="relative"
v-for="{ index: rowIdx, data: row } of virtualList?.list.value"
:key="rowIdx"
class="grid grid-rows-1 gap-x-8 w-full mb-8"
:style="`grid-template-columns: repeat(auto-fit,minmax(${cardWidth}px,1fr))`"
>
<div
class="inline-block border rounded overflow-hidden bg-main hover:border-primary transition"
:class="(focus(idx + 1) || currentOverviewPage === idx + 1) ? 'border-primary' : 'border-main'"
@click="go(route.no)"
v-for="route of row"
:key="route.no"
class="relative"
>
<SlideContainer
:key="route.no"
:width="cardWidth"
class="pointer-events-none"
<div
class="inline-block border rounded overflow-hidden bg-main hover:border-primary transition"
:class="currentOverviewPage === route.no ? 'border-primary' : 'border-main'"
@click="go(route.no)"
>
<SlideWrapper
:clicks-context="createFixedClicks(route, CLICKS_MAX)"
:route="route"
render-context="overview"
/>
<DrawingPreview :page="route.no" />
</SlideContainer>
</div>
<div
class="absolute top-0"
:style="`left: ${cardWidth + 5}px`"
>
<template v-if="keyboardBuffer && String(idx + 1).startsWith(keyboardBuffer)">
<span class="text-green font-bold">{{ keyboardBuffer }}</span>
<span class="opacity-50">{{ String(idx + 1).slice(keyboardBuffer.length) }}</span>
</template>
<span v-else class="opacity-50">
{{ idx + 1 }}
</span>
<SlideContainer
:key="route.no"
:width="cardWidth"
class="pointer-events-none"
>
<SlideWrapper
:clicks-context="createFixedClicks(route, CLICKS_MAX)"
:route="route"
render-context="overview"
/>
<DrawingPreview :page="route.no" />
</SlideContainer>
</div>
<div
class="absolute top-0"
:style="`left: ${cardWidth + 5}px`"
>
<template v-if="keyboardBuffer && String(route.no).startsWith(keyboardBuffer)">
<span class="text-green font-bold">{{ keyboardBuffer }}</span>
<span class="opacity-50">{{ String(route.no).slice(keyboardBuffer.length) }}</span>
</template>
<span v-else class="opacity-50">
{{ route.no }}
</span>
</div>
</div>
</div>
</div>
Expand Down
Loading
Loading