-
-
Notifications
You must be signed in to change notification settings - Fork 4.7k
Wrap versions list in virtual scroll #43029
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
363 changes: 363 additions & 0 deletions
363
apps/files_versions/src/components/VirtualScrolling.vue
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,363 @@ | ||
| <!-- | ||
| - @copyright Copyright (c) 2022 Louis Chemineau <[email protected]> | ||
| - | ||
| - @author Louis Chemineau <[email protected]> | ||
| - | ||
| - @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 | ||
artonge marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| 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 | ||
artonge marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| 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> | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.