Skip to content

Commit 5474efb

Browse files
committed
Wrap versions list in virtual scroll
Signed-off-by: Louis Chemineau <louis@chmn.me>
1 parent 4c1452f commit 5474efb

File tree

2 files changed

+396
-16
lines changed

2 files changed

+396
-16
lines changed
Lines changed: 363 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,363 @@
1+
<!--
2+
- @copyright Copyright (c) 2022 Louis Chemineau <louis@chmn.me>
3+
-
4+
- @author Louis Chemineau <louis@chmn.me>
5+
-
6+
- @license AGPL-3.0-or-later
7+
-
8+
- This program is free software: you can redistribute it and/or modify
9+
- it under the terms of the GNU Affero General Public License as
10+
- published by the Free Software Foundation, either version 3 of the
11+
- License, or (at your option) any later version.
12+
-
13+
- This program is distributed in the hope that it will be useful,
14+
- but WITHOUT ANY WARRANTY; without even the implied warranty of
15+
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16+
- GNU Affero General Public License for more details.
17+
-
18+
- You should have received a copy of the GNU Affero General Public License
19+
- along with this program. If not, see <http://www.gnu.org/licenses/>.
20+
-
21+
-->
22+
<template>
23+
<div v-if="!useWindow && containerElement === null" ref="container" class="vs-container">
24+
<div ref="rowsContainer"
25+
class="vs-rows-container"
26+
:style="rowsContainerStyle">
27+
<slot :visible-sections="visibleSections" />
28+
<slot name="loader" />
29+
</div>
30+
</div>
31+
<div v-else
32+
ref="rowsContainer"
33+
class="vs-rows-container"
34+
:style="rowsContainerStyle">
35+
<slot :visible-sections="visibleSections" />
36+
<slot name="loader" />
37+
</div>
38+
</template>
39+
40+
<script lang="ts">
41+
import { defineComponent, type PropType } from 'vue'
42+
43+
import logger from '../utils/logger.js'
44+
45+
interface RowItem {
46+
id: string // Unique id for the item.
47+
key?: string // Unique key for the item.
48+
}
49+
50+
interface Row {
51+
key: string // Unique key for the row.
52+
height: number // The height of the row.
53+
sectionKey: string // Unique key for the row.
54+
items: RowItem[] // List of items in the row.
55+
}
56+
57+
interface VisibleRow extends Row {
58+
distance: number // The distance from the visible viewport
59+
}
60+
61+
interface Section {
62+
key: string, // Unique key for the section.
63+
rows: Row[], // The height of the row.
64+
height: number, // Height of the section, excluding the header.
65+
}
66+
67+
interface VisibleSection extends Section {
68+
rows: VisibleRow[], // The height of the row.
69+
}
70+
71+
export default defineComponent({
72+
name: 'VirtualScrolling',
73+
74+
props: {
75+
sections: {
76+
type: Array as PropType<Section[]>,
77+
required: true,
78+
},
79+
80+
containerElement: {
81+
type: HTMLElement,
82+
default: null,
83+
},
84+
85+
useWindow: {
86+
type: Boolean,
87+
default: false,
88+
},
89+
90+
headerHeight: {
91+
type: Number,
92+
default: 75,
93+
},
94+
renderDistance: {
95+
type: Number,
96+
default: 0.5,
97+
},
98+
bottomBufferRatio: {
99+
type: Number,
100+
default: 2,
101+
},
102+
scrollToKey: {
103+
type: String,
104+
default: '',
105+
},
106+
},
107+
108+
data() {
109+
return {
110+
scrollPosition: 0,
111+
containerHeight: 0,
112+
rowsContainerHeight: 0,
113+
resizeObserver: null as ResizeObserver|null,
114+
}
115+
},
116+
117+
computed: {
118+
visibleSections(): VisibleSection[] {
119+
logger.debug('[VirtualScrolling] Computing visible section', { sections: this.sections })
120+
121+
// Optimisation: get those computed properties once to not go through vue's internal every time we need them.
122+
const containerHeight = this.containerHeight
123+
const containerTop = this.scrollPosition
124+
const containerBottom = containerTop + containerHeight
125+
126+
let currentRowTop = 0
127+
let currentRowBottom = 0
128+
129+
// Compute whether a row should be included in the DOM (shouldRender)
130+
// And how visible the row is.
131+
const visibleSections = this.sections
132+
.map(section => {
133+
currentRowBottom += this.headerHeight
134+
135+
return {
136+
...section,
137+
rows: section.rows.reduce((visibleRows, row) => {
138+
currentRowTop = currentRowBottom
139+
currentRowBottom += row.height
140+
141+
let distance = 0
142+
143+
if (currentRowBottom < containerTop) {
144+
distance = (containerTop - currentRowBottom) / containerHeight
145+
} else if (currentRowTop > containerBottom) {
146+
distance = (currentRowTop - containerBottom) / containerHeight
147+
}
148+
149+
if (distance > this.renderDistance) {
150+
return visibleRows
151+
}
152+
153+
return [
154+
...visibleRows,
155+
{
156+
...row,
157+
distance,
158+
},
159+
]
160+
}, [] as VisibleRow[]),
161+
}
162+
})
163+
.filter(section => section.rows.length > 0)
164+
165+
// To allow vue to recycle the DOM elements instead of adding and deleting new ones,
166+
// we assign a random key to each items. When a item removed, we recycle its key for new items,
167+
// so vue can replace the content of removed DOM elements with the content of new items, but keep the other DOM elements untouched.
168+
const visibleItems = visibleSections
169+
.flatMap(({ rows }) => rows)
170+
.flatMap(({ items }) => items)
171+
172+
const rowIdToKeyMap = this._rowIdToKeyMap as {[key: string]: string}
173+
174+
visibleItems.forEach(item => (item.key = rowIdToKeyMap[item.id]))
175+
176+
const usedTokens = visibleItems
177+
.map(({ key }) => key)
178+
.filter(key => key !== undefined)
179+
180+
const unusedTokens = Object.values(rowIdToKeyMap).filter(key => !usedTokens.includes(key))
181+
182+
visibleItems
183+
.filter(({ key }) => key === undefined)
184+
.forEach(item => (item.key = unusedTokens.pop() ?? Math.random().toString(36).substr(2)))
185+
186+
// this._rowIdToKeyMap is created in the beforeCreate hook, so value changes are not tracked.
187+
// Therefore, we wont trigger the computation of visibleSections again if we alter the value of this._rowIdToKeyMap.
188+
// eslint-disable-next-line vue/no-side-effects-in-computed-properties
189+
this._rowIdToKeyMap = visibleItems.reduce((finalMapping, { id, key }) => ({ ...finalMapping, [`${id}`]: key }), {})
190+
191+
return visibleSections
192+
},
193+
194+
/**
195+
* Total height of all the rows + some room for the loader.
196+
*/
197+
totalHeight(): number {
198+
const loaderHeight = 0
199+
200+
return this.sections
201+
.map(section => this.headerHeight + section.height)
202+
.reduce((totalHeight, sectionHeight) => totalHeight + sectionHeight, 0) + loaderHeight
203+
},
204+
205+
paddingTop(): number {
206+
if (this.visibleSections.length === 0) {
207+
return 0
208+
}
209+
210+
let paddingTop = 0
211+
212+
for (const section of this.sections) {
213+
if (section.key !== this.visibleSections[0].rows[0].sectionKey) {
214+
paddingTop += this.headerHeight + section.height
215+
continue
216+
}
217+
218+
for (const row of section.rows) {
219+
if (row.key === this.visibleSections[0].rows[0].key) {
220+
return paddingTop
221+
}
222+
223+
paddingTop += row.height
224+
}
225+
226+
paddingTop += this.headerHeight
227+
}
228+
229+
return paddingTop
230+
},
231+
232+
/**
233+
* padding-top is used to replace not included item in the container.
234+
*/
235+
rowsContainerStyle(): { height: string; paddingTop: string } {
236+
return {
237+
height: `${this.totalHeight}px`,
238+
paddingTop: `${this.paddingTop}px`,
239+
}
240+
},
241+
242+
/**
243+
* Whether the user is near the bottom.
244+
* If true, then the need-content event will be emitted.
245+
*/
246+
isNearBottom(): boolean {
247+
const buffer = this.containerHeight * this.bottomBufferRatio
248+
return this.scrollPosition + this.containerHeight >= this.totalHeight - buffer
249+
},
250+
251+
container() {
252+
logger.debug('[VirtualScrolling] Computing container')
253+
if (this.containerElement !== null) {
254+
return this.containerElement
255+
} else if (this.useWindow) {
256+
return window
257+
} else {
258+
return this.$refs.container as Element
259+
}
260+
},
261+
},
262+
263+
watch: {
264+
isNearBottom(value) {
265+
logger.debug('[VirtualScrolling] isNearBottom changed', { value })
266+
if (value) {
267+
this.$emit('need-content')
268+
}
269+
},
270+
271+
visibleSections() {
272+
// Re-emit need-content when rows is updated and isNearBottom is still true.
273+
// If the height of added rows is under `bottomBufferRatio`, `isNearBottom` will still be true so we need more content.
274+
if (this.isNearBottom) {
275+
this.$emit('need-content')
276+
}
277+
},
278+
279+
scrollToKey(key) {
280+
let currentRowTopDistanceFromTop = 0
281+
282+
for (const section of this.sections) {
283+
if (section.key !== key) {
284+
currentRowTopDistanceFromTop += this.headerHeight + section.height
285+
continue
286+
}
287+
288+
break
289+
}
290+
291+
logger.debug('[VirtualScrolling] Scrolling to', { currentRowTopDistanceFromTop })
292+
this.container.scrollTo({ top: currentRowTopDistanceFromTop, behavior: 'smooth' })
293+
},
294+
},
295+
296+
beforeCreate() {
297+
this._rowIdToKeyMap = {}
298+
},
299+
300+
mounted() {
301+
this.resizeObserver = new ResizeObserver(entries => {
302+
for (const entry of entries) {
303+
const cr = entry.contentRect
304+
if (entry.target === this.container) {
305+
this.containerHeight = cr.height
306+
}
307+
if (entry.target.classList.contains('vs-rows-container')) {
308+
this.rowsContainerHeight = cr.height
309+
}
310+
}
311+
})
312+
313+
if (this.useWindow) {
314+
window.addEventListener('resize', this.updateContainerSize, { passive: true })
315+
this.containerHeight = window.innerHeight
316+
} else {
317+
this.resizeObserver.observe(this.container as HTMLElement|Element)
318+
}
319+
320+
this.resizeObserver.observe(this.$refs.rowsContainer as Element)
321+
this.container.addEventListener('scroll', this.updateScrollPosition, { passive: true })
322+
},
323+
324+
beforeDestroy() {
325+
if (this.useWindow) {
326+
window.removeEventListener('resize', this.updateContainerSize)
327+
}
328+
329+
this.resizeObserver?.disconnect()
330+
this.container.removeEventListener('scroll', this.updateScrollPosition)
331+
},
332+
333+
methods: {
334+
updateScrollPosition() {
335+
this._onScrollHandle ??= requestAnimationFrame(() => {
336+
this._onScrollHandle = null
337+
if (this.useWindow) {
338+
this.scrollPosition = (this.container as Window).scrollY
339+
} else {
340+
this.scrollPosition = (this.container as HTMLElement|Element).scrollTop
341+
}
342+
})
343+
},
344+
345+
updateContainerSize() {
346+
this.containerHeight = window.innerHeight
347+
},
348+
},
349+
})
350+
</script>
351+
352+
<style scoped lang="scss">
353+
.vs-container {
354+
overflow-y: scroll;
355+
height: 100%;
356+
}
357+
358+
.vs-rows-container {
359+
box-sizing: border-box;
360+
will-change: scroll-position, padding;
361+
contain: layout paint style;
362+
}
363+
</style>

0 commit comments

Comments
 (0)