]> source.dussan.org Git - nextcloud-server.git/commitdiff
Wrap versions list in virtual scroll
authorLouis Chemineau <louis@chmn.me>
Mon, 22 Jan 2024 16:05:23 +0000 (17:05 +0100)
committerLouis Chemineau <louis@chmn.me>
Tue, 23 Jan 2024 14:57:29 +0000 (15:57 +0100)
Signed-off-by: Louis Chemineau <louis@chmn.me>
apps/files_versions/src/components/VirtualScrolling.vue [new file with mode: 0644]
apps/files_versions/src/views/VersionTab.vue

diff --git a/apps/files_versions/src/components/VirtualScrolling.vue b/apps/files_versions/src/components/VirtualScrolling.vue
new file mode 100644 (file)
index 0000000..fc199ed
--- /dev/null
@@ -0,0 +1,363 @@
+<!--
+ - @copyright Copyright (c) 2022 Louis Chemineau <louis@chmn.me>
+ -
+ - @author Louis Chemineau <louis@chmn.me>
+ -
+ - @license AGPL-3.0-or-later
+ -
+ - This program is free software: you can redistribute it and/or modify
+ - it under the terms of the GNU Affero General Public License as
+ - published by the Free Software Foundation, either version 3 of the
+ - License, or (at your option) any later version.
+ -
+ - This program is distributed in the hope that it will be useful,
+ - but WITHOUT ANY WARRANTY; without even the implied warranty of
+ - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ - GNU Affero General Public License for more details.
+ -
+ - You should have received a copy of the GNU Affero General Public License
+ - along with this program. If not, see <http://www.gnu.org/licenses/>.
+ -
+ -->
+<template>
+       <div v-if="!useWindow && containerElement === null" ref="container" class="vs-container">
+               <div ref="rowsContainer"
+                       class="vs-rows-container"
+                       :style="rowsContainerStyle">
+                       <slot :visible-sections="visibleSections" />
+                       <slot name="loader" />
+               </div>
+       </div>
+       <div v-else
+               ref="rowsContainer"
+               class="vs-rows-container"
+               :style="rowsContainerStyle">
+               <slot :visible-sections="visibleSections" />
+               <slot name="loader" />
+       </div>
+</template>
+
+<script lang="ts">
+import { defineComponent, type PropType } from 'vue'
+
+import logger from '../utils/logger.js'
+
+interface RowItem {
+       id: string // Unique id for the item.
+       key?: string // Unique key for the item.
+}
+
+interface Row {
+       key: string // Unique key for the row.
+       height: number // The height of the row.
+       sectionKey: string // Unique key for the row.
+       items: RowItem[] // List of items in the row.
+}
+
+interface VisibleRow extends Row {
+       distance: number // The distance from the visible viewport
+}
+
+interface Section {
+       key: string, // Unique key for the section.
+       rows: Row[], // The height of the row.
+       height: number, // Height of the section, excluding the header.
+}
+
+interface VisibleSection extends Section {
+       rows: VisibleRow[], // The height of the row.
+}
+
+export default defineComponent({
+       name: 'VirtualScrolling',
+
+       props: {
+               sections: {
+                       type: Array as PropType<Section[]>,
+                       required: true,
+               },
+
+               containerElement: {
+                       type: HTMLElement,
+                       default: null,
+               },
+
+               useWindow: {
+                       type: Boolean,
+                       default: false,
+               },
+
+               headerHeight: {
+                       type: Number,
+                       default: 75,
+               },
+               renderDistance: {
+                       type: Number,
+                       default: 0.5,
+               },
+               bottomBufferRatio: {
+                       type: Number,
+                       default: 2,
+               },
+               scrollToKey: {
+                       type: String,
+                       default: '',
+               },
+       },
+
+       data() {
+               return {
+                       scrollPosition: 0,
+                       containerHeight: 0,
+                       rowsContainerHeight: 0,
+                       resizeObserver: null as ResizeObserver|null,
+               }
+       },
+
+       computed: {
+               visibleSections(): VisibleSection[] {
+                       logger.debug('[VirtualScrolling] Computing visible section', { sections: this.sections })
+
+                       // Optimization: get those computed properties once to not go through vue's internal every time we need them.
+                       const containerHeight = this.containerHeight
+                       const containerTop = this.scrollPosition
+                       const containerBottom = containerTop + containerHeight
+
+                       let currentRowTop = 0
+                       let currentRowBottom = 0
+
+                       // Compute whether a row should be included in the DOM (shouldRender)
+                       // And how visible the row is.
+                       const visibleSections = this.sections
+                               .map(section => {
+                                       currentRowBottom += this.headerHeight
+
+                                       return {
+                                               ...section,
+                                               rows: section.rows.reduce((visibleRows, row) => {
+                                                       currentRowTop = currentRowBottom
+                                                       currentRowBottom += row.height
+
+                                                       let distance = 0
+
+                                                       if (currentRowBottom < containerTop) {
+                                                               distance = (containerTop - currentRowBottom) / containerHeight
+                                                       } else if (currentRowTop > containerBottom) {
+                                                               distance = (currentRowTop - containerBottom) / containerHeight
+                                                       }
+
+                                                       if (distance > this.renderDistance) {
+                                                               return visibleRows
+                                                       }
+
+                                                       return [
+                                                               ...visibleRows,
+                                                               {
+                                                                       ...row,
+                                                                       distance,
+                                                               },
+                                                       ]
+                                               }, [] as VisibleRow[]),
+                                       }
+                               })
+                               .filter(section => section.rows.length > 0)
+
+                       // To allow vue to recycle the DOM elements instead of adding and deleting new ones,
+                       // we assign a random key to each items. When a item removed, we recycle its key for new items,
+                       // so vue can replace the content of removed DOM elements with the content of new items, but keep the other DOM elements untouched.
+                       const visibleItems = visibleSections
+                               .flatMap(({ rows }) => rows)
+                               .flatMap(({ items }) => items)
+
+                       const rowIdToKeyMap = this._rowIdToKeyMap as {[key: string]: string}
+
+                       visibleItems.forEach(item => (item.key = rowIdToKeyMap[item.id]))
+
+                       const usedTokens = visibleItems
+                               .map(({ key }) => key)
+                               .filter(key => key !== undefined)
+
+                       const unusedTokens = Object.values(rowIdToKeyMap).filter(key => !usedTokens.includes(key))
+
+                       visibleItems
+                               .filter(({ key }) => key === undefined)
+                               .forEach(item => (item.key = unusedTokens.pop() ?? Math.random().toString(36).substr(2)))
+
+                       // this._rowIdToKeyMap is created in the beforeCreate hook, so value changes are not tracked.
+                       // Therefore, we wont trigger the computation of visibleSections again if we alter the value of this._rowIdToKeyMap.
+                       // eslint-disable-next-line vue/no-side-effects-in-computed-properties
+                       this._rowIdToKeyMap = visibleItems.reduce((finalMapping, { id, key }) => ({ ...finalMapping, [`${id}`]: key }), {})
+
+                       return visibleSections
+               },
+
+               /**
+                * Total height of all the rows + some room for the loader.
+                */
+               totalHeight(): number {
+                       const loaderHeight = 0
+
+                       return this.sections
+                               .map(section => this.headerHeight + section.height)
+                               .reduce((totalHeight, sectionHeight) => totalHeight + sectionHeight, 0) + loaderHeight
+               },
+
+               paddingTop(): number {
+                       if (this.visibleSections.length === 0) {
+                               return 0
+                       }
+
+                       let paddingTop = 0
+
+                       for (const section of this.sections) {
+                               if (section.key !== this.visibleSections[0].rows[0].sectionKey) {
+                                       paddingTop += this.headerHeight + section.height
+                                       continue
+                               }
+
+                               for (const row of section.rows) {
+                                       if (row.key === this.visibleSections[0].rows[0].key) {
+                                               return paddingTop
+                                       }
+
+                                       paddingTop += row.height
+                               }
+
+                               paddingTop += this.headerHeight
+                       }
+
+                       return paddingTop
+               },
+
+               /**
+                * padding-top is used to replace not included item in the container.
+                */
+               rowsContainerStyle(): { height: string; paddingTop: string } {
+                       return {
+                               height: `${this.totalHeight}px`,
+                               paddingTop: `${this.paddingTop}px`,
+                       }
+               },
+
+               /**
+                * Whether the user is near the bottom.
+                * If true, then the need-content event will be emitted.
+                */
+               isNearBottom(): boolean {
+                       const buffer = this.containerHeight * this.bottomBufferRatio
+                       return this.scrollPosition + this.containerHeight >= this.totalHeight - buffer
+               },
+
+               container() {
+                       logger.debug('[VirtualScrolling] Computing container')
+                       if (this.containerElement !== null) {
+                               return this.containerElement
+                       } else if (this.useWindow) {
+                               return window
+                       } else {
+                               return this.$refs.container as Element
+                       }
+               },
+       },
+
+       watch: {
+               isNearBottom(value) {
+                       logger.debug('[VirtualScrolling] isNearBottom changed', { value })
+                       if (value) {
+                               this.$emit('need-content')
+                       }
+               },
+
+               visibleSections() {
+                       // Re-emit need-content when rows is updated and isNearBottom is still true.
+                       // If the height of added rows is under `bottomBufferRatio`, `isNearBottom` will still be true so we need more content.
+                       if (this.isNearBottom) {
+                               this.$emit('need-content')
+                       }
+               },
+
+               scrollToKey(key) {
+                       let currentRowTopDistanceFromTop = 0
+
+                       for (const section of this.sections) {
+                               if (section.key !== key) {
+                                       currentRowTopDistanceFromTop += this.headerHeight + section.height
+                                       continue
+                               }
+
+                               break
+                       }
+
+                       logger.debug('[VirtualScrolling] Scrolling to', { currentRowTopDistanceFromTop })
+                       this.container.scrollTo({ top: currentRowTopDistanceFromTop, behavior: 'smooth' })
+               },
+       },
+
+       beforeCreate() {
+               this._rowIdToKeyMap = {}
+       },
+
+       mounted() {
+               this.resizeObserver = new ResizeObserver(entries => {
+                       for (const entry of entries) {
+                               const cr = entry.contentRect
+                               if (entry.target === this.container) {
+                                       this.containerHeight = cr.height
+                               }
+                               if (entry.target.classList.contains('vs-rows-container')) {
+                                       this.rowsContainerHeight = cr.height
+                               }
+                       }
+               })
+
+               if (this.useWindow) {
+                       window.addEventListener('resize', this.updateContainerSize, { passive: true })
+                       this.containerHeight = window.innerHeight
+               } else {
+                       this.resizeObserver.observe(this.container as HTMLElement|Element)
+               }
+
+               this.resizeObserver.observe(this.$refs.rowsContainer as Element)
+               this.container.addEventListener('scroll', this.updateScrollPosition, { passive: true })
+       },
+
+       beforeDestroy() {
+               if (this.useWindow) {
+                       window.removeEventListener('resize', this.updateContainerSize)
+               }
+
+               this.resizeObserver?.disconnect()
+               this.container.removeEventListener('scroll', this.updateScrollPosition)
+       },
+
+       methods: {
+               updateScrollPosition() {
+                       this._onScrollHandle ??= requestAnimationFrame(() => {
+                               this._onScrollHandle = null
+                               if (this.useWindow) {
+                                       this.scrollPosition = (this.container as Window).scrollY
+                               } else {
+                                       this.scrollPosition = (this.container as HTMLElement|Element).scrollTop
+                               }
+                       })
+               },
+
+               updateContainerSize() {
+                       this.containerHeight = window.innerHeight
+               },
+       },
+})
+</script>
+
+<style scoped lang="scss">
+.vs-container {
+       overflow-y: scroll;
+       height: 100%;
+}
+
+.vs-rows-container {
+       box-sizing: border-box;
+       will-change: scroll-position, padding;
+       contain: layout paint style;
+}
+</style>
index 932e37e891879822a7e150fff231d10eb05f7023..c039e4a6c3a0473e01a1cbafc4225d329f3dae93 100644 (file)
  - along with this program. If not, see <http://www.gnu.org/licenses/>.
  -->
 <template>
-       <ul data-files-versions-versions-list>
-               <Version v-for="version in orderedVersions"
-                       :key="version.mtime"
-                       :can-view="canView"
-                       :can-compare="canCompare"
-                       :load-preview="isActive"
-                       :version="version"
-                       :file-info="fileInfo"
-                       :is-current="version.mtime === fileInfo.mtime"
-                       :is-first-version="version.mtime === initialVersionMtime"
-                       @click="openVersion"
-                       @compare="compareVersion"
-                       @restore="handleRestore"
-                       @label-update="handleLabelUpdate"
-                       @delete="handleDelete" />
-       </ul>
+       <VirtualScrolling :sections="sections"
+               :header-height="0">
+               <template slot-scope="{visibleSections}">
+                       <ul data-files-versions-versions-list>
+                               <template v-if="visibleSections.length === 1">
+                                       <Version v-for="(row) of visibleSections[0].rows"
+                                               :key="row.items[0].mtime"
+                                               :can-view="canView"
+                                               :can-compare="canCompare"
+                                               :load-preview="isActive"
+                                               :version="row.items[0]"
+                                               :file-info="fileInfo"
+                                               :is-current="row.items[0].mtime === fileInfo.mtime"
+                                               :is-first-version="row.items[0].mtime === initialVersionMtime"
+                                               @click="openVersion"
+                                               @compare="compareVersion"
+                                               @restore="handleRestore"
+                                               @label-update="handleLabelUpdate"
+                                               @delete="handleDelete" />
+                               </template>
+                       </ul>
+               </template>
+               <NcLoadingIcon v-if="loading" slot="loader" class="files-list-viewer__loader" />
+       </VirtualScrolling>
 </template>
 
 <script>
@@ -41,14 +49,18 @@ import { showError, showSuccess } from '@nextcloud/dialogs'
 import isMobile from '@nextcloud/vue/dist/Mixins/isMobile.js'
 import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus'
 import { getCurrentUser } from '@nextcloud/auth'
+import { NcLoadingIcon } from '@nextcloud/vue'
 
 import { fetchVersions, deleteVersion, restoreVersion, setVersionLabel } from '../utils/versions.js'
 import Version from '../components/Version.vue'
+import VirtualScrolling from '../components/VirtualScrolling.vue'
 
 export default {
        name: 'VersionTab',
        components: {
                Version,
+               VirtualScrolling,
+               NcLoadingIcon,
        },
        mixins: [
                isMobile,
@@ -69,6 +81,11 @@ export default {
                unsubscribe('files_versions:restore:restored', this.fetchVersions)
        },
        computed: {
+               sections() {
+                       const rows = this.orderedVersions.map(version => ({ key: version.mtime, height: 68, sectionKey: 'versions', items: [version] }))
+                       return [{ key: 'versions', rows, height: 68 * this.orderedVersions.length }]
+               },
+
                /**
                 * Order versions by mtime.
                 * Put the current version at the top.