Merge pull request #43029 from nextcloud/artonge/feat/files_versions_virtual_scroll

Wrap versions list in virtual scroll
pull/43005/head
Louis 4 months ago committed by GitHub
commit 2ac1d86051
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

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

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

4
dist/1697-1697.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
dist/core-main.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long
Loading…
Cancel
Save