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" />
2323 <th ><span class =" hidden-visually" >{{ t('logreader', 'Log entry actions') }}</span ></th >
2424 </tr >
2525 </thead >
26- <tbody ref =" tableBody" >
27- <tr v-if =" sortedByTime === 'ascending'" >
28- <td colspan = " 5 " class = " log-table__load-more " >
26+ <tbody ref =" tableBody" :style = " tbodyStyle " class = " log-table__body " >
27+ <tr v-if =" sortedByTime === 'ascending'" class = " log-table__load-more " >
28+ <td >
2929 <IntersectionObserver v-if =" logStore.hasRemainingEntries" @intersection =" loadMore" >
3030 {{ t('logreader', 'Loading older log entries') }}
3131 </IntersectionObserver >
3535 </td >
3636 </tr >
3737
38- <LogTableRow v-for =" row, rowNumber in sortedRows "
38+ <LogTableRow v-for =" ( row, rowNumber) in renderedItems "
3939 :key =" rowNumber"
4040 :row =" row"
4141 @show-details =" showDetailsForRow" />
4242 </tbody >
43- <tfoot >
44- <tr v-if =" sortedByTime !== 'ascending'" >
45- <td colspan = " 5 " class = " log-table__load-more " >
43+ <tfoot role = " rowgroup " class = " log-table__footer " >
44+ <tr v-if =" sortedByTime !== 'ascending'" class = " log-table__load-more " >
45+ <td >
4646 <IntersectionObserver v-if =" logStore.hasRemainingEntries" @intersection =" loadMore" >
4747 {{ t('logreader', 'Loading older log entries') }}
4848 </IntersectionObserver >
5959<script setup lang="ts">
6060import type { ILogEntry , ISortingOptions } from ' ../../interfaces'
6161
62- import { computed , nextTick , ref } from ' vue'
62+ import { computed , nextTick , onMounted , onBeforeUnmount , ref } from ' vue'
6363import { translate as t } from ' @nextcloud/l10n'
6464import { useSettingsStore } from ' ../../store/settings'
6565import { useLogStore } from ' ../../store/logging'
66+ import { debounce } from ' ../../utils/debounce'
67+ import { logger } from ' ../../utils/logger'
6668
6769import IntersectionObserver from ' ../IntersectionObserver.vue'
6870import LogDetailsModal from ' ../LogDetailsModal.vue'
6971import LogTableHeader from ' ./LogTableHeader.vue'
7072import LogTableRow from ' ./LogTableRow.vue'
7173import LogSearch from ' ../LogSearch.vue'
7274
75+ // Items to render before and after the visible area
76+ const bufferItems = 3
77+ // Fixed height of LogTableRow
78+ const itemHeight = 42
79+ // Fixed height of LogTableHeader
80+ const theadHeight = 44
81+
7382const settingsStore = useSettingsStore ()
7483const logStore = useLogStore ()
7584
@@ -110,8 +119,9 @@ const showDetailsForRow = (row: ILogEntry) => {
110119}
111120
112121/**
113- * Reference to the table body , used for keeping scroll position on loading more entries
122+ * Reference to the table elements , used for keeping scroll position on loading more entries
114123 */
124+ const tableRoot = ref <HTMLElement >()
115125const tableBody = ref <HTMLElement >()
116126
117127/**
@@ -146,6 +156,52 @@ const sortedRows = computed(() => {
146156 sorted .sort ((a , b ) => order (byLevel , sortedByLevel .value , a , b ) || order (byApp , sortedByApp .value , a , b ) || order (byTime , sortedByTime .value , a , b ))
147157 return sorted
148158})
159+
160+ /**
161+ * Virtual scrolling logic
162+ */
163+ const resizeObserver = ref <ResizeObserver | null >(null )
164+
165+ const firstVisibleRowIndex = ref (0 )
166+ const startIndex = computed (() => Math .max (0 , firstVisibleRowIndex .value - bufferItems ))
167+
168+ const tableHeight = ref (0 )
169+ const itemsInViewport = computed (() => Math .ceil ((tableHeight .value - theadHeight ) / itemHeight ) + bufferItems * 2 )
170+
171+ const renderedItems = computed (() => sortedRows .value .slice (startIndex .value , startIndex .value + itemsInViewport .value ))
172+
173+ const tbodyStyle = computed (() => {
174+ const isOverScrolled = startIndex .value + itemsInViewport .value > sortedRows .value .length
175+ const lastIndex = sortedRows .value .length - startIndex .value - itemsInViewport .value
176+ const hiddenAfterItems = Math .min (sortedRows .value .length - startIndex .value , lastIndex )
177+
178+ return {
179+ paddingTop: ` ${startIndex .value * itemHeight }px ` ,
180+ paddingBottom: isOverScrolled ? 0 : ` ${hiddenAfterItems * itemHeight }px ` ,
181+ }
182+ })
183+
184+ onMounted (() => {
185+ resizeObserver .value = new ResizeObserver (debounce (() => {
186+ tableHeight .value = tableRoot .value ?.clientHeight ?? 0
187+ logger .debug (' VirtualList resizeObserver updated' )
188+ onScroll ()
189+ }, 100 ))
190+
191+ resizeObserver .value .observe (tableRoot .value ! )
192+ tableRoot .value ! .addEventListener (' scroll' , onScroll )
193+ })
194+
195+ onBeforeUnmount (() => {
196+ if (resizeObserver .value ) {
197+ resizeObserver .value .disconnect ()
198+ }
199+ })
200+
201+ function onScroll() {
202+ // Max 0 to prevent negative index
203+ firstVisibleRowIndex .value = Math .max (0 , Math .round (tableRoot .value ! .scrollTop / itemHeight ))
204+ }
149205 </script >
150206
151207<style lang="scss" scoped>
@@ -158,37 +214,65 @@ const sortedRows = computed(() => {
158214 width : calc (100% - 12px );
159215 margin-inline : 6px ;
160216 table-layout : fixed ;
217+
218+ // Necessary for virtual scroll optimized rendering
219+ display : block ;
220+ overflow : auto ;
221+ height : 100% ;
222+ will-change : scroll-position ;
161223 }
162224
163225 & __load-more {
164- text-align : center ;
165- padding-block : 4px ;
166- }
226+ display : flex ;
167227
168- th , td {
169- // level column
170- & :nth-child ( 1 ) {
171- width : 108 px ;
228+ : deep ( td ) {
229+ flex-basis : 100 % ;
230+ text-align : center ;
231+ padding-block : 4 px ;
172232 }
173- // app column
174- & :nth-child ( 2 ) {
175- width : 168 px ;
176- }
177- // message column
178- & :nth-child ( 3 ) {
179- width : 418 px ;
180- }
181- // time column
182- & :nth-child ( 4 ) {
183- width : 168 px ;
233+ }
234+
235+ & __header ,
236+ & __body ,
237+ & __footer {
238+ display : flex ;
239+ flex-direction : column ;
240+ width : 100 % ;
241+
242+ : deep ( tr ) {
243+ display : flex ;
184244 }
185- // actions column
186- & :last-child {
187- width : 62px ; // 44px button + 18px padding
245+
246+ :deep(th ),
247+ :deep(td ) {
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 : 168px ;
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 ' ;
0 commit comments