Skip to content

Commit 24d18b1

Browse files
committed
fix: implement virtual scrolling
- copied from server/apps/settings/src/components/Users/VirtualList.vue, omitting some redundant logic Signed-off-by: Maksim Sukharev <[email protected]>
1 parent 1435fe8 commit 24d18b1

File tree

2 files changed

+119
-32
lines changed

2 files changed

+119
-32
lines changed

src/components/table/LogTable.vue

Lines changed: 116 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88
:open.sync="isModalOpen"
99
:current-entry.sync="currentRow"
1010
:log-entries="sortedRows" />
11-
<table class="log-table__table">
12-
<thead>
11+
<table ref="tableRoot" class="log-table__table">
12+
<thead role="rowgroup" class="log-table__header">
1313
<tr>
1414
<LogTableHeader :name="t('logreader', 'Level')"
1515
:sorted.sync="sortedByLevel" />
@@ -23,7 +23,7 @@
2323
<th><span class="hidden-visually">{{ t('logreader', 'Log entry actions') }}</span></th>
2424
</tr>
2525
</thead>
26-
<tbody ref="tableBody">
26+
<tbody ref="tableBody" :style="tbodyStyle" class="log-table__body">
2727
<tr v-if="sortedByTime === 'ascending'">
2828
<td colspan="5" class="log-table__load-more">
2929
<IntersectionObserver v-if="logStore.hasRemainingEntries" @intersection="loadMore">
@@ -35,12 +35,13 @@
3535
</td>
3636
</tr>
3737

38-
<LogTableRow v-for="row in sortedRows"
38+
<LogTableRow v-for="row in renderedItems"
3939
:key="row.id"
4040
:row="row"
41+
class="log-table__row"
4142
@show-details="showDetailsForRow" />
4243
</tbody>
43-
<tfoot>
44+
<tfoot role="rowgroup" class="log-table__footer">
4445
<tr v-if="sortedByTime !== 'ascending'">
4546
<td colspan="5" class="log-table__load-more">
4647
<IntersectionObserver v-if="logStore.hasRemainingEntries" @intersection="loadMore">
@@ -59,17 +60,22 @@
5960
<script setup lang="ts">
6061
import type { ILogEntry, ISortingOptions } from '../../interfaces'
6162
62-
import { computed, nextTick, ref } from 'vue'
63+
import { computed, nextTick, onMounted, onBeforeUnmount, ref } from 'vue'
6364
import { translate as t } from '@nextcloud/l10n'
6465
import { useSettingsStore } from '../../store/settings'
6566
import { useLogStore } from '../../store/logging'
67+
import { debounce } from '../../utils/debounce'
68+
import { logger } from '../../utils/logger'
6669
6770
import IntersectionObserver from '../IntersectionObserver.vue'
6871
import LogDetailsModal from '../LogDetailsModal.vue'
6972
import LogTableHeader from './LogTableHeader.vue'
7073
import LogTableRow from './LogTableRow.vue'
7174
import LogSearch from '../LogSearch.vue'
7275
76+
// Items to render before and after the visible area
77+
const bufferItems = 3
78+
7379
const settingsStore = useSettingsStore()
7480
const logStore = useLogStore()
7581
@@ -110,8 +116,9 @@ const showDetailsForRow = (row: ILogEntry) => {
110116
}
111117
112118
/**
113-
* Reference to the table body, used for keeping scroll position on loading more entries
119+
* Reference to the table elements, used for keeping scroll position on loading more entries
114120
*/
121+
const tableRoot = ref<HTMLElement>()
115122
const tableBody = ref<HTMLElement>()
116123
117124
/**
@@ -146,49 +153,126 @@ const sortedRows = computed(() => {
146153
sorted.sort((a, b) => order(byLevel, sortedByLevel.value, a, b) || order(byApp, sortedByApp.value, a, b) || order(byTime, sortedByTime.value, a, b))
147154
return sorted
148155
})
156+
157+
/**
158+
* Virtual scrolling logic
159+
*/
160+
const resizeObserver = ref<ResizeObserver | null>(null)
161+
162+
const firstVisibleRowIndex = ref(0)
163+
const startIndex = computed(() => Math.max(0, firstVisibleRowIndex.value - bufferItems))
164+
165+
const tableRootHeight = ref(0)
166+
const tableHeadHeight = ref(44)
167+
const tableRowHeight = ref(42)
168+
const itemsInViewport = computed(() => Math.ceil((tableRootHeight.value - tableHeadHeight.value) / tableRowHeight.value) + bufferItems * 2)
169+
170+
const renderedItems = computed(() => sortedRows.value.slice(startIndex.value, startIndex.value + itemsInViewport.value))
171+
172+
const tbodyStyle = computed(() => {
173+
const isOverScrolled = startIndex.value + itemsInViewport.value > sortedRows.value.length
174+
const lastIndex = sortedRows.value.length - startIndex.value - itemsInViewport.value
175+
const hiddenAfterItems = Math.min(sortedRows.value.length - startIndex.value, lastIndex)
176+
177+
return {
178+
paddingTop: `${startIndex.value * tableRowHeight.value}px`,
179+
paddingBottom: isOverScrolled ? 0 : `${hiddenAfterItems * tableRowHeight.value}px`,
180+
}
181+
})
182+
183+
onMounted(() => {
184+
resizeObserver.value = new ResizeObserver(debounce(() => {
185+
tableRootHeight.value = tableRoot.value?.clientHeight ?? 0
186+
tableHeadHeight.value = tableRoot.value?.querySelector('thead.log-table__header')?.clientHeight ?? 44
187+
tableRowHeight.value = tableRoot.value?.querySelector('tr.log-table__row:not(.expanded)')?.clientHeight ?? 42
188+
logger.debug('ResizeObserver for virtual list updated')
189+
onScroll()
190+
}, 100))
191+
192+
resizeObserver.value.observe(tableRoot.value!)
193+
tableRoot.value!.addEventListener('scroll', onScroll)
194+
})
195+
196+
onBeforeUnmount(() => {
197+
if (resizeObserver.value) {
198+
resizeObserver.value.disconnect()
199+
}
200+
})
201+
202+
/**
203+
* Update the first visible row index on scroll (max 0 to prevent negative index)
204+
*/
205+
function onScroll() {
206+
firstVisibleRowIndex.value = Math.max(0, Math.round(tableRoot.value!.scrollTop / tableRowHeight.value))
207+
}
149208
</script>
150209

151210
<style lang="scss" scoped>
152211
.log-table {
153212
width: 100%;
154213
height: 100%;
155-
overflow: scroll;
214+
overflow: hidden;
156215
157216
&__table {
158217
width: calc(100% - 12px);
159218
margin-inline: 6px;
160219
table-layout: fixed;
220+
221+
// Necessary for virtual scroll optimized rendering
222+
display: block;
223+
overflow: auto;
224+
height: 100%;
225+
will-change: scroll-position;
161226
}
162227
163228
&__load-more {
164229
text-align: center;
165230
padding-block: 4px;
166231
}
167232
168-
th, td {
169-
// level column
170-
&:nth-child(1) {
171-
width: 108px;
172-
}
173-
// app column
174-
&:nth-child(2) {
175-
width: 168px;
176-
}
177-
// message column
178-
&:nth-child(3) {
179-
width: 418px;
180-
}
181-
// time column
182-
&:nth-child(4) {
183-
width: 168px;
233+
&__header,
234+
&__body,
235+
&__footer {
236+
display: flex;
237+
flex-direction: column;
238+
width: 100%;
239+
240+
:deep(tr) {
241+
display: flex;
184242
}
185-
// actions column
186-
&:last-child {
187-
width: 62px; // 44px button + 18px padding
243+
244+
:deep(th),
245+
:deep(td) {
246+
flex-shrink: 0;
247+
248+
// level column
249+
&:nth-child(1) {
250+
width: 108px;
251+
}
252+
// app column
253+
&:nth-child(2) {
254+
width: 168px;
255+
}
256+
// message column
257+
&:nth-child(3) {
258+
width: 418px;
259+
flex-grow: 1;
260+
}
261+
// time column
262+
&:nth-child(4) {
263+
width: 25ch; // "Mar 10, 2025, 12:00:00 PM" length
264+
}
265+
// actions column
266+
&:last-child {
267+
width: 62px; // 44px button + 18px padding
268+
}
188269
}
189270
}
190271
191-
thead {
272+
&__header {
273+
position: sticky;
274+
top: 0;
275+
z-index: 1;
192276
min-height: 44px;
193277
194278
:deep(th) {
@@ -200,7 +284,7 @@ const sortedRows = computed(() => {
200284
}
201285
}
202286
203-
tbody {
287+
&__body {
204288
// Some spacing for first row
205289
&:before {
206290
content: '\200c';
@@ -210,5 +294,8 @@ const sortedRows = computed(() => {
210294
}
211295
}
212296
297+
&__row {
298+
min-height: 42px;
299+
}
213300
}
214301
</style>

src/components/table/LogTableRow.vue

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -181,10 +181,10 @@ watch(isExpanded, () => resizeTabeRow)
181181

182182
<style lang="scss" scoped>
183183
td {
184-
display: table-cell;
184+
display: block;
185185
overflow: hidden;
186186
text-overflow: ellipsis;
187-
vertical-align: top;
187+
min-height: 42px;
188188
padding-block-start: 4px;
189189
padding-inline: 18px 0;
190190
}
@@ -221,7 +221,7 @@ td {
221221
}
222222
223223
tr {
224-
display: table-row;
224+
display: flex;
225225
&.expanded {
226226
white-space: normal;
227227

0 commit comments

Comments
 (0)