diff options
author | Louis Chemineau <louis@chmn.me> | 2024-01-22 17:05:23 +0100 |
---|---|---|
committer | Louis Chemineau <louis@chmn.me> | 2024-01-23 15:57:29 +0100 |
commit | 53020241564e04529fd2701010bf682766891ce8 (patch) | |
tree | 97fea7417546ec9dbc9a1a91f9116a2218caffc7 /apps | |
parent | 7f1b980dcfd55ef9e277865cebd6879f69e3e43a (diff) | |
download | nextcloud-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.vue | 363 | ||||
-rw-r--r-- | apps/files_versions/src/views/VersionTab.vue | 49 |
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. |