diff --git a/apps/files/src/components/VirtualList.vue b/apps/files/src/components/VirtualList.vue index d2b436344a5cf..d66e7b808ab15 100644 --- a/apps/files/src/components/VirtualList.vue +++ b/apps/files/src/components/VirtualList.vue @@ -55,9 +55,10 @@ import type { File, Folder, Node } from '@nextcloud/files' import type { PropType } from 'vue' -import { useFileListWidth } from '../composables/useFileListWidth.ts' import { defineComponent } from 'vue' import debounce from 'debounce' + +import { useFileListWidth } from '../composables/useFileListWidth.ts' import logger from '../logger.ts' interface RecycledPoolItem { @@ -66,7 +67,6 @@ interface RecycledPoolItem { } type DataSource = File | Folder - type DataSourceKey = keyof DataSource export default defineComponent({ @@ -121,6 +121,13 @@ export default defineComponent({ headerHeight: 0, tableHeight: 0, resizeObserver: null as ResizeObserver | null, + + $_scrollRAF: null as number | null, + $_recycledPool: {} as Record, + $_renderCache: { + items: null as RecycledPoolItem[] | null, + cacheKey: null as string | null, + }, } }, @@ -133,20 +140,22 @@ export default defineComponent({ // Items to render before and after the visible area bufferItems() { if (this.gridMode) { + // 1 row before and after in grid mode return this.columnCount } + // 3 rows before and after return 3 }, itemHeight() { // Align with css in FilesListVirtual - // 166px + 32px (name) + 16px (mtime) + 16px (padding) - return this.gridMode ? (166 + 32 + 16 + 16) : 55 + // 166px + 32px (name) + 16px (mtime) + 16px (padding top and bottom) + return this.gridMode ? (166 + 32 + 16 + 16 + 16) : 55 }, // Grid mode only itemWidth() { - // 166px + 16px padding - return 166 + 16 + // 166px + 16px x 2 (padding left and right) + return 166 + 16 + 16 }, rowCount() { @@ -161,9 +170,13 @@ export default defineComponent({ /** * Index of the first item to be rendered + * The index can be any file, not just the first one + * But the start index is the first item to be rendered, + * which needs to align with the column count */ startIndex() { - return Math.max(0, this.index - this.bufferItems) + const firstColumnIndex = this.index - (this.index % this.columnCount) + return Math.max(0, firstColumnIndex - this.bufferItems) }, /** @@ -179,32 +192,26 @@ export default defineComponent({ return this.rowCount }, - renderedItems(): RecycledPoolItem[] { - if (!this.isReady) { - return [] - } + renderedItems() { + // Generate a cache key based on critical rendering parameters + const cacheKey = `${this.startIndex}-${this.shownItems}-${this.dataSources.length}` - const items = this.dataSources.slice(this.startIndex, this.startIndex + this.shownItems) as Node[] + // Check if we can use cached result + if (this.$_renderCache?.cacheKey === cacheKey && this.$_renderCache?.items) { + return this.$_renderCache.items + } - const oldItems = items.filter(item => Object.values(this.$_recycledPool).includes(item[this.dataKey])) - const oldItemsKeys = oldItems.map(item => item[this.dataKey] as string) - const unusedKeys = Object.keys(this.$_recycledPool).filter(key => !oldItemsKeys.includes(this.$_recycledPool[key])) + // Compute and cache the result + const items = this.computeRenderedItems() - return items.map(item => { - const index = Object.values(this.$_recycledPool).indexOf(item[this.dataKey]) - // If defined, let's keep the key - if (index !== -1) { - return { - key: Object.keys(this.$_recycledPool)[index], - item, - } - } + // Store in cache + // eslint-disable-next-line vue/no-side-effects-in-computed-properties + this.$_renderCache = { + items, + cacheKey, + } - // Get and consume reusable key or generate a new one - const key = unusedKeys.pop() || Math.random().toString(36).substr(2) - this.$_recycledPool[key] = item[this.dataKey] - return { key, item } - }) + return items }, /** @@ -254,13 +261,15 @@ export default defineComponent({ const root = this.$el as HTMLElement const thead = this.$refs?.thead as HTMLElement - this.resizeObserver = new ResizeObserver(debounce(() => { - this.beforeHeight = before?.clientHeight ?? 0 - this.headerHeight = thead?.clientHeight ?? 0 - this.tableHeight = root?.clientHeight ?? 0 - logger.debug('VirtualList: resizeObserver updated') - this.onScroll() - }, 100, false)) + this.resizeObserver = new ResizeObserver(() => { + requestAnimationFrame(() => { + this.beforeHeight = before?.clientHeight ?? 0 + this.headerHeight = thead?.clientHeight ?? 0 + this.tableHeight = root?.clientHeight ?? 0 + logger.debug('VirtualList: resizeObserver updated') + this.onScroll() + }) + }) this.resizeObserver.observe(before) this.resizeObserver.observe(root) @@ -273,7 +282,11 @@ export default defineComponent({ // Adding scroll listener AFTER the initial scroll to index this.$el.addEventListener('scroll', this.onScroll, { passive: true }) - this.$_recycledPool = {} as Record + this.$_recycledPool = {} + this.$_renderCache = { + items: null, + cacheKey: null, + } }, beforeDestroy() { @@ -284,28 +297,83 @@ export default defineComponent({ methods: { scrollTo(index: number) { + if (!this.$el) { + return + } + + // Check if the content is smaller than the viewport, meaning no scrollbar const targetRow = Math.ceil(this.dataSources.length / this.columnCount) if (targetRow < this.rowCount) { - logger.debug('VirtualList: Skip scrolling. nothing to scroll', { index, targetRow, rowCount: this.rowCount }) + logger.debug('VirtualList: Skip scrolling, nothing to scroll', { index, targetRow, rowCount: this.rowCount }) return } - this.index = index + // Scroll to one row and a half before the index - const scrollTop = (Math.floor(index / this.columnCount) - 0.5) * this.itemHeight + this.beforeHeight - logger.debug('VirtualList: scrolling to index ' + index, { scrollTop, columnCount: this.columnCount }) + const scrollTop = this.indexToScrollPos(index) + logger.debug('VirtualList: scrolling to index ' + index, { scrollTop, columnCount: this.columnCount, beforeHeight: this.beforeHeight }) this.$el.scrollTop = scrollTop }, onScroll() { - this._onScrollHandle ??= requestAnimationFrame(() => { - this._onScrollHandle = null - const topScroll = this.$el.scrollTop - this.beforeHeight - const index = Math.floor(topScroll / this.itemHeight) * this.columnCount + // Use requestAnimationFrame more efficiently + if (this.$_scrollRAF) { + cancelAnimationFrame(this.$_scrollRAF) + } + + this.$_scrollRAF = requestAnimationFrame(() => { + const index = this.scrollPosToIndex(this.$el.scrollTop) + if (index === this.index) { + return + } + // Max 0 to prevent negative index - this.index = Math.max(0, index) + this.index = Math.max(0, Math.floor(index)) this.$emit('scroll') }) }, + + // Convert scroll position to index + // It should be the opposite of `indexToScrollPos` + scrollPosToIndex(scrollPos: number): number { + const topScroll = scrollPos - this.beforeHeight + // Max 0 to prevent negative index + return Math.max(0, Math.floor(topScroll / this.itemHeight)) * this.columnCount + }, + + // Convert index to scroll position + // It should be the opposite of `scrollPosToIndex` + indexToScrollPos(index: number): number { + return (Math.floor(index / this.columnCount) - 0.5) * this.itemHeight + this.beforeHeight + }, + + computeRenderedItems(): RecycledPoolItem[] { + // Extract the original complex rendering logic + if (!this.isReady) { + return [] + } + + const items = this.dataSources.slice(this.startIndex, this.startIndex + this.shownItems) as Node[] + + const oldItems = items.filter(item => Object.values(this.$_recycledPool).includes(item[this.dataKey])) + const oldItemsKeys = oldItems.map(item => item[this.dataKey] as string) + const unusedKeys = Object.keys(this.$_recycledPool).filter(key => !oldItemsKeys.includes(this.$_recycledPool[key])) + + return items.map(item => { + const index = Object.values(this.$_recycledPool).indexOf(item[this.dataKey]) + // If defined, let's keep the key + if (index !== -1) { + return { + key: Object.keys(this.$_recycledPool)[index], + item, + } + } + + // Get and consume reusable key or generate a new one + const key = unusedKeys.pop() || Math.random().toString(36).substr(2) + this.$_recycledPool[key] = item[this.dataKey] + return { key, item } + }) + }, }, })