fix(files): Correctly scroll selected file into view

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
This commit is contained in:
Ferdinand Thiessen 2025-01-31 00:00:05 +01:00
parent 8fd6d8025f
commit d9996b92dc
No known key found for this signature in database
GPG key ID: 45FAE7268762B400
4 changed files with 401 additions and 50 deletions

View file

@ -141,7 +141,7 @@ export default defineComponent({
<style scoped lang="scss">
// Scoped row
tr {
margin-bottom: max(25vh, var(--body-container-margin));
margin-bottom: var(--body-container-margin);
border-top: 1px solid var(--color-border);
// Prevent hover effect on the whole row
background-color: transparent !important;

View file

@ -536,7 +536,6 @@ export default defineComponent({
flex-direction: column;
width: 100%;
background-color: var(--color-main-background);
}
// Table header
@ -853,8 +852,7 @@ export default defineComponent({
<style lang="scss">
// Grid mode
tbody.files-list__tbody.files-list__tbody--grid {
--half-clickable-area: calc(var(--clickable-area) / 2);
.files-list--grid tbody.files-list__tbody {
--item-padding: 16px;
--icon-preview-size: 166px;
--name-height: 32px;
@ -945,7 +943,7 @@ tbody.files-list__tbody.files-list__tbody--grid {
.files-list__row-actions {
position: absolute;
inset-inline-end: calc(var(--half-clickable-area) / 2);
inset-inline-end: calc(var(--clickable-area) / 4);
inset-block-end: calc(var(--mtime-height) / 2);
width: var(--clickable-area);
height: var(--clickable-area);

View file

@ -3,13 +3,16 @@
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div class="files-list" data-cy-files-list>
<div class="files-list"
:class="{ 'files-list--grid': gridMode }"
data-cy-files-list
@scroll.passive="onScroll">
<!-- Header -->
<div ref="before" class="files-list__before">
<slot name="before" />
</div>
<div class="files-list__filters">
<div ref="filters" class="files-list__filters">
<slot name="filters" />
</div>
@ -31,7 +34,6 @@
<!-- Body -->
<tbody :style="tbodyStyle"
class="files-list__tbody"
:class="gridMode ? 'files-list__tbody--grid' : 'files-list__tbody--list'"
data-cy-files-list-tbody>
<component :is="dataComponent"
v-for="({key, item}, i) in renderedItems"
@ -42,7 +44,7 @@
</tbody>
<!-- Footer -->
<tfoot v-show="isReady"
<tfoot ref="footer"
class="files-list__tfoot"
data-cy-files-list-tfoot>
<slot name="footer" />
@ -118,6 +120,7 @@ export default defineComponent({
return {
index: this.scrollToIndex,
beforeHeight: 0,
footerHeight: 0,
headerHeight: 0,
tableHeight: 0,
resizeObserver: null as ResizeObserver | null,
@ -145,16 +148,33 @@ export default defineComponent({
// 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 x 2 (padding left and right)
return 166 + 16 + 16
},
rowCount() {
return Math.ceil((this.tableHeight - this.headerHeight) / this.itemHeight) + (this.bufferItems / this.columnCount) * 2 + 1
/**
* The number of rows currently (fully!) visible
*/
visibleRows(): number {
return Math.floor((this.tableHeight - this.headerHeight) / this.itemHeight)
},
columnCount() {
/**
* Number of rows that will be rendered.
* This includes only visible + buffer rows.
*/
rowCount(): number {
return this.visibleRows + (this.bufferItems / this.columnCount) * 2 + 1
},
/**
* Number of columns.
* 1 for list view otherwise depending on the file list width.
*/
columnCount(): number {
if (!this.gridMode) {
return 1
}
@ -217,16 +237,18 @@ export default defineComponent({
* The total number of rows that are available
*/
totalRowCount() {
return Math.floor(this.dataSources.length / this.columnCount)
return Math.ceil(this.dataSources.length / this.columnCount)
},
tbodyStyle() {
const isOverScrolled = this.startIndex + this.rowCount > this.dataSources.length
const lastIndex = this.dataSources.length - this.startIndex - this.shownItems
const hiddenAfterItems = Math.floor(Math.min(this.dataSources.length - this.startIndex, lastIndex) / this.columnCount)
// The number of (virtual) rows above the currently rendered ones.
// start index is aligned so this should always be an integer
const rowsAbove = Math.round(this.startIndex / this.columnCount)
// The number of (virtual) rows below the currently rendered ones.
const rowsBelow = Math.max(0, this.totalRowCount - rowsAbove - this.rowCount)
return {
paddingTop: `${Math.floor(this.startIndex / this.columnCount) * this.itemHeight}px`,
paddingBottom: isOverScrolled ? 0 : `${hiddenAfterItems * this.itemHeight}px`,
paddingBlock: `${rowsAbove * this.itemHeight}px ${rowsBelow * this.itemHeight}px`,
minHeight: `${this.totalRowCount * this.itemHeight}px`,
}
},
@ -238,15 +260,14 @@ export default defineComponent({
totalRowCount() {
if (this.scrollToIndex) {
this.$nextTick(() => this.scrollTo(this.scrollToIndex))
this.scrollTo(this.scrollToIndex)
}
},
columnCount(columnCount, oldColumnCount) {
if (oldColumnCount === 0) {
// We're initializing, the scroll position
// is handled on mounted
console.debug('VirtualList: columnCount is 0, skipping scroll')
// We're initializing, the scroll position is handled on mounted
logger.debug('VirtualList: columnCount is 0, skipping scroll')
return
}
// If the column count changes in grid view,
@ -256,30 +277,28 @@ export default defineComponent({
},
mounted() {
const before = this.$refs?.before as HTMLElement
const root = this.$el as HTMLElement
const thead = this.$refs?.thead as HTMLElement
this.$_recycledPool = {} as Record<string, DataSource[DataSourceKey]>
this.resizeObserver = new ResizeObserver(debounce(() => {
this.beforeHeight = before?.clientHeight ?? 0
this.headerHeight = thead?.clientHeight ?? 0
this.tableHeight = root?.clientHeight ?? 0
this.updateHeightVariables()
logger.debug('VirtualList: resizeObserver updated')
this.onScroll()
}, 100, { immediate: false }))
}, 100))
this.resizeObserver.observe(this.$el)
this.resizeObserver.observe(this.$refs.before as HTMLElement)
this.resizeObserver.observe(this.$refs.filters as HTMLElement)
this.resizeObserver.observe(this.$refs.footer as HTMLElement)
this.resizeObserver.observe(before)
this.resizeObserver.observe(root)
this.resizeObserver.observe(thead)
if (this.scrollToIndex) {
this.scrollTo(this.scrollToIndex)
}
// Adding scroll listener AFTER the initial scroll to index
this.$el.addEventListener('scroll', this.onScroll, { passive: true })
this.$_recycledPool = {} as Record<string, DataSource[DataSourceKey]>
this.$nextTick(() => {
// Make sure height values are initialized
this.updateHeightVariables()
// If we need to scroll to an index we do so in the next tick.
// This is needed to apply updates from the initialization of the height variables
// which will update the tbody styles until next tick.
if (this.scrollToIndex) {
this.scrollTo(this.scrollToIndex)
}
})
},
beforeDestroy() {
@ -294,17 +313,56 @@ export default defineComponent({
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 })
// Check if the content is smaller (not equal! keep the footer in mind) than the viewport
// meaning there is no scrollbar
if (this.totalRowCount < this.visibleRows) {
logger.debug('VirtualList: Skip scrolling, nothing to scroll', {
index,
totalRows: this.totalRowCount,
visibleRows: this.visibleRows,
})
return
}
// Scroll to one row and a half before the index
const scrollTop = this.indexToScrollPos(index)
logger.debug('VirtualList: scrolling to index ' + index, { scrollTop, columnCount: this.columnCount, beforeHeight: this.beforeHeight })
this.$el.scrollTop = scrollTop
// We can not scroll further as the last page of rows
// For the grid view we also need to account for all columns in that row (columnCount - 1)
const clampedIndex = (this.totalRowCount - this.visibleRows) * this.columnCount + (this.columnCount - 1)
// The scroll position
let scrollTop = this.indexToScrollPos(Math.min(index, clampedIndex))
// First we need to update the internal index for rendering.
// This will cause the <tbody> element to be resized allowing us to set the correct scroll position.
this.index = index
// If this is not the first row we can add a half row from above.
// This is to help users understand the table is scrolled and not items did not just disappear.
// But we also can only add a half row if we have enough rows below to scroll (visual rows / end of scrollable area)
if (index >= this.columnCount && index <= clampedIndex) {
scrollTop -= (this.itemHeight / 2)
// As we render one half row more we also need to adjust the internal index
this.index = index - this.columnCount
} else if (index > clampedIndex) {
// If we are on the last page we cannot scroll any further
// but we can at least scroll the footer into view
if (index <= (clampedIndex + this.columnCount)) {
// We only show have of the footer for the first of the last page
// To still show the previous row partly. Same reasoning as above:
// help the user understand that the table is scrolled not "magically trimmed"
scrollTop += this.footerHeight / 2
} else {
// We reached the very end of the files list and we are focussing not the first visible row
// so all we now can do is scroll to the end (footer)
scrollTop += this.footerHeight
}
}
// Now we need to wait for the <tbody> element to get resized so we can correctly apply the scrollTop position
this.$nextTick(() => {
this.$el.scrollTop = scrollTop
logger.debug(`VirtualList: scrolling to index ${index}`, {
clampedIndex, scrollTop, columnCount: this.columnCount, total: this.totalRowCount, visibleRows: this.visibleRows, beforeHeight: this.beforeHeight,
})
})
},
onScroll() {
@ -333,7 +391,22 @@ export default defineComponent({
// 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
return Math.floor(index / this.columnCount) * this.itemHeight + this.beforeHeight
},
/**
* Update the height variables.
* To be called by resize observer and `onMount`
*/
updateHeightVariables(): void {
this.tableHeight = this.$el?.clientHeight ?? 0
this.beforeHeight = (this.$refs.before as HTMLElement)?.clientHeight ?? 0
this.footerHeight = (this.$refs.footer as HTMLElement)?.clientHeight ?? 0
// Get the header height which consists of table header and filters
const theadHeight = (this.$refs.thead as HTMLElement)?.clientHeight ?? 0
const filterHeight = (this.$refs.filters as HTMLElement)?.clientHeight ?? 0
this.headerHeight = theadHeight + filterHeight
},
},
})

View file

@ -0,0 +1,280 @@
/*!
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { User } from '@nextcloud/cypress'
import { getRowForFile } from './FilesUtils'
describe('files: Scrolling to selected file in file list', { testIsolation: true }, () => {
const fileIds = new Map<number, string>()
let user: User
before(() => {
cy.createRandomUser().then(($user) => {
user = $user
cy.rm(user, '/welcome.txt')
for (let i = 1; i <= 10; i++) {
cy.uploadContent(user, new Blob([]), 'text/plain', `/${i}.txt`)
.then((response) => fileIds.set(i, Number.parseInt(response.headers['oc-fileid']).toString()))
}
})
})
beforeEach(() => {
// Adjust height to ensure that those 10 elements can not be rendered in one list
cy.viewport(1200, 6 * 55 /* rows */ + 55 /* table header */ + 50 /* navigation header */ + 50 /* breadcrumbs */ + 46 /* file filters */)
cy.login(user)
})
it('Can see first file in list', () => {
cy.visit(`/apps/files/files/${fileIds.get(1)}`)
// See file is visible
getRowForFile('1.txt')
.should('be.visible')
// we expect also element 6 to be visible
getRowForFile('6.txt')
.should('be.visible')
// but not element 7 - though it should exist (be buffered)
getRowForFile('7.txt')
.should('exist')
.and('not.be.visible')
})
// Same kind of tests for partially visible top and bottom
for (let i = 2; i <= 5; i++) {
it(`correctly scrolls to row ${i}`, () => {
cy.visit(`/apps/files/files/${fileIds.get(i)}`)
// See file is visible
getRowForFile(`${i}.txt`)
.should('be.visible')
.and(notBeOverlappedByTableHeader)
// we expect also element +4 to be visible
// (6 visible rows -> 5 without our scrolled row -> so we only have 4 fully visible others + two 1/2 hidden rows)
getRowForFile(`${i + 4}.txt`)
.should('be.visible')
// but not element -1 or +5 - though it should exist (be buffered)
getRowForFile(`${i - 1}.txt`)
.should('exist')
.and(beOverlappedByTableHeader)
getRowForFile(`${i + 5}.txt`)
.should('exist')
.and('be.visible')
.and(notBeFullyInViewport)
})
}
// this will have half of the footer visible
it(`correctly scrolls to row 6`, () => {
cy.visit(`/apps/files/files/${fileIds.get(6)}`)
// See file is visible
getRowForFile(`6.txt`)
.should('be.visible')
.and(notBeOverlappedByTableHeader)
// we expect also element 7,8,9,10 visible
getRowForFile(`10.txt`)
.should('be.visible')
// but not row 5
getRowForFile(`5.txt`)
.should('exist')
.and(beOverlappedByTableHeader)
// see footer is only shown partly
cy.get('tfoot')
.should('exist')
.and(notBeFullyInViewport)
})
// Same kind of tests for partially visible top and bottom
for (let i = 7; i <= 10; i++) {
it(`correctly scrolls to row ${i}`, () => {
cy.visit(`/apps/files/files/${fileIds.get(i)}`)
// See file is visible
getRowForFile(`${i}.txt`)
.should('be.visible')
.and(notBeOverlappedByTableHeader)
// there are only max. 3 rows left so also row 6+ should be visible
getRowForFile(`6.txt`)
.should('be.visible')
getRowForFile(`10.txt`)
.should('be.visible')
// Also the footer is visible
cy.get('tfoot')
.contains('10 files')
.should('be.visible')
})
}
})
describe('files: Scrolling to selected file in file list (GRID MODE)', { testIsolation: true }, () => {
const fileIds = new Map<number, string>()
let user: User
before(() => {
cy.createRandomUser().then(($user) => {
user = $user
cy.rm(user, '/welcome.txt')
for (let i = 1; i <= 12; i++) {
cy.uploadContent(user, new Blob([]), 'text/plain', `/${i}.txt`)
.then((response) => fileIds.set(i, Number.parseInt(response.headers['oc-fileid']).toString()))
}
// Set grid mode
cy.login(user)
cy.intercept('**/apps/files/api/v1/config/grid_view').as('setGridMode')
cy.visit('/apps/files')
cy.findByRole('button', { name: 'Switch to grid view' })
.should('be.visible')
.click()
cy.wait('@setGridMode')
})
})
beforeEach(() => {
// Adjust height to ensure that those 12 files can not be rendered in one list
// 768px width will limit the columns to 3
cy.viewport(768, 3 * 246 /* rows */ + 55 /* table header */ + 50 /* navigation header */ + 50 /* breadcrumbs */ + 46 /* file filters */)
cy.login(user)
})
// First row
for (let i = 1; i <= 3; i++) {
it(`Can see files in first row (file ${i})`, () => {
cy.visit(`/apps/files/files/${fileIds.get(i)}`)
for (let j = 1; j <= 3; j++) {
// See all files of that row are visible
getRowForFile(`${j}.txt`)
.should('be.visible')
// we expect also the second row to be visible
getRowForFile(`${j+3}.txt`)
.should('be.visible')
// Because there is no half row on top we also see the third row
getRowForFile(`${j+6}.txt`)
.should('be.visible')
// But not the forth row
getRowForFile(`${j+9}.txt`)
.should('exist')
.and(notBeFullyInViewport)
}
})
}
// Second row
// Same kind of tests for partially visible top and bottom
for (let i = 4; i <= 6; i++) {
it(`correctly scrolls to second row (file ${i})`, () => {
cy.visit(`/apps/files/files/${fileIds.get(i)}`)
// See all three files of that row are visible
for (let j = 4; j <= 6; j++) {
getRowForFile(`${j}.txt`)
.should('be.visible')
.and(notBeOverlappedByTableHeader)
// we expect also the next row to be visible
getRowForFile(`${j + 3}.txt`)
.should('be.visible')
// but not the row below (should be half cut)
getRowForFile(`${j + 6}.txt`)
.should('exist')
.and(notBeFullyInViewport)
// Same for the row above
getRowForFile(`${j - 3}.txt`)
.should('exist')
.and(beOverlappedByTableHeader)
}
})
}
// Third row
// this will have half of the footer visible and half of the previous row
for (let i = 7; i <= 9; i++) {
it(`correctly scrolls to third row (file ${i})`, () => {
cy.visit(`/apps/files/files/${fileIds.get(i)}`)
// See all three files of that row are visible
for (let j = 7; j <= 9; j++) {
getRowForFile(`${j}.txt`)
.should('be.visible')
// we expect also the next row to be visible
getRowForFile(`${j + 3}.txt`)
.should('be.visible')
// but not the row above
getRowForFile(`${j - 3}.txt`)
.should('exist')
.and(beOverlappedByTableHeader)
}
// see footer is only shown partly
cy.get('tfoot')
.should('exist')
.and(notBeFullyInViewport)
})
}
// Forth row which only has row 4 and 3 visible and the full footer
for (let i = 10; i <= 12; i++) {
it(`correctly scrolls to forth row (file ${i})`, () => {
cy.visit(`/apps/files/files/${fileIds.get(i)}`)
// See all three files of that row are visible
for (let j = 10; j <= 12; j++) {
getRowForFile(`${j}.txt`)
.should('be.visible')
.and(notBeOverlappedByTableHeader)
// we expect also the row above to be visible
getRowForFile(`${j - 3}.txt`)
.should('be.visible')
}
// see footer is shown
cy.get('tfoot')
.should('be.visible')
})
}
})
/// Some helpers
function notBeOverlappedByTableHeader($el: JQuery<HTMLElement>) {
return beOverlappedByTableHeader($el, false)
}
function beOverlappedByTableHeader($el: JQuery<HTMLElement>, expected = true) {
const headerRect = Cypress.$('thead').get(0)!.getBoundingClientRect()
const elementRect = $el.get(0)!.getBoundingClientRect()
const overlap = !(headerRect.right < elementRect.left ||
headerRect.left > elementRect.right ||
headerRect.bottom < elementRect.top ||
headerRect.top > elementRect.bottom)
if (expected) {
expect(overlap, 'Overlapped by table header').to.be.true
} else {
expect(overlap, 'Not overlapped by table header').to.be.false
}
}
function beFullyInViewport($el: JQuery<HTMLElement>, expected = true) {
const { top, left, bottom, right } = $el.get(0)!.getBoundingClientRect()
const { innerHeight, innerWidth } = window
const fullyVisible = top >= 0 && left >= 0 && bottom <= innerHeight && right <= innerWidth
if (expected) {
expect(fullyVisible, 'Fully within viewport').to.be.true
} else {
expect(fullyVisible, 'Not fully within viewport').to.be.false
}
}
function notBeFullyInViewport($el: JQuery<HTMLElement>) {
return beFullyInViewport($el, false)
}