aboutsummaryrefslogtreecommitdiffstats
path: root/apps
diff options
context:
space:
mode:
authorLouis Chemineau <louis@chmn.me>2024-01-22 17:05:23 +0100
committerLouis Chemineau <louis@chmn.me>2024-01-23 15:57:29 +0100
commit53020241564e04529fd2701010bf682766891ce8 (patch)
tree97fea7417546ec9dbc9a1a91f9116a2218caffc7 /apps
parent7f1b980dcfd55ef9e277865cebd6879f69e3e43a (diff)
downloadnextcloud-server-53020241564e04529fd2701010bf682766891ce8.tar.gz
nextcloud-server-53020241564e04529fd2701010bf682766891ce8.zip
Wrap versions list in virtual scroll
Signed-off-by: Louis Chemineau <louis@chmn.me>
Diffstat (limited to 'apps')
-rw-r--r--apps/files_versions/src/components/VirtualScrolling.vue363
-rw-r--r--apps/files_versions/src/views/VersionTab.vue49
2 files changed, 396 insertions, 16 deletions
diff --git a/apps/files_versions/src/components/VirtualScrolling.vue b/apps/files_versions/src/components/VirtualScrolling.vue
new file mode 100644
index 00000000000..fc199edd2ef
--- /dev/null
+++ b/apps/files_versions/src/components/VirtualScrolling.vue
@@ -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>
diff --git a/apps/files_versions/src/views/VersionTab.vue b/apps/files_versions/src/views/VersionTab.vue
index 932e37e8918..c039e4a6c3a 100644
--- a/apps/files_versions/src/views/VersionTab.vue
+++ b/apps/files_versions/src/views/VersionTab.vue
@@ -16,22 +16,30 @@
- 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.