Skip to content

Commit

Permalink
Merge pull request #50679 from nextcloud/backport/50582/stable31
Browse files Browse the repository at this point in the history
[stable31] fix(files): Correctly scroll selected file into view
  • Loading branch information
AndyScherzinger authored Feb 6, 2025
2 parents 6d850e9 + 3eb864a commit 5bddc7d
Show file tree
Hide file tree
Showing 11 changed files with 568 additions and 84 deletions.
2 changes: 1 addition & 1 deletion apps/files/src/components/FilesListTableFooter.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 2 additions & 4 deletions apps/files/src/components/FilesListVirtual.vue
Original file line number Diff line number Diff line change
Expand Up @@ -536,7 +536,6 @@ export default defineComponent({
flex-direction: column;
width: 100%;
background-color: var(--color-main-background);

}

// Table header
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
165 changes: 119 additions & 46 deletions apps/files/src/components/VirtualList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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>

Expand All @@ -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"
Expand All @@ -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" />
Expand Down Expand Up @@ -118,6 +120,7 @@ export default defineComponent({
return {
index: this.scrollToIndex,
beforeHeight: 0,
footerHeight: 0,
headerHeight: 0,
tableHeight: 0,
resizeObserver: null as ResizeObserver | null,
Expand Down Expand Up @@ -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)
},

/**
* Number of rows that will be rendered.
* This includes only visible + buffer rows.
*/
rowCount(): number {
return this.visibleRows + (this.bufferItems / this.columnCount) * 2 + 1
},
columnCount() {

/**
* Number of columns.
* 1 for list view otherwise depending on the file list width.
*/
columnCount(): number {
if (!this.gridMode) {
return 1
}
Expand Down Expand Up @@ -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`,
}
},
Expand All @@ -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,
Expand All @@ -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 }))

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]>
}, 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.$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() {
Expand All @@ -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() {
Expand Down Expand Up @@ -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
},
},
})
Expand Down
40 changes: 40 additions & 0 deletions cypress/e2e/core-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,43 @@ export enum UnifiedSearchFilter {
export function getUnifiedSearchFilter(filter: UnifiedSearchFilter) {
return getUnifiedSearchModal().find(`[data-cy-unified-search-filters] [data-cy-unified-search-filter="${CSS.escape(filter)}"]`)
}

/**
* Assertion that an element is fully within the current viewport.
* @param $el The element
* @param expected If the element is expected to be fully in viewport or not fully
* @example
* ```js
* cy.get('#my-element')
* .should(beFullyInViewport)
* ```
*/
export function beFullyInViewport($el: JQuery<HTMLElement>, expected = true) {
const { top, left, bottom, right } = $el.get(0)!.getBoundingClientRect()
const innerHeight = Cypress.$('body').innerHeight()!
const innerWidth = Cypress.$('body').innerWidth()!
const fullyVisible = top >= 0 && left >= 0 && bottom <= innerHeight && right <= innerWidth

console.debug(`fullyVisible: ${fullyVisible}, top: ${top >= 0}, left: ${left >= 0}, bottom: ${bottom <= innerHeight}, right: ${right <= innerWidth}`)

if (expected) {
// eslint-disable-next-line no-unused-expressions
expect(fullyVisible, 'Fully within viewport').to.be.true
} else {
// eslint-disable-next-line no-unused-expressions
expect(fullyVisible, 'Not fully within viewport').to.be.false
}
}

/**
* Opposite of `beFullyInViewport` - resolves when element is not or only partially in viewport.
* @param $el The element
* @example
* ```js
* cy.get('#my-element')
* .should(notBeFullyInViewport)
* ```
*/
export function notBeFullyInViewport($el: JQuery<HTMLElement>) {
return beFullyInViewport($el, false)
}
44 changes: 43 additions & 1 deletion cypress/e2e/files/FilesUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import type { User } from "@nextcloud/cypress"
import type { User } from '@nextcloud/cypress'

export const getRowForFileId = (fileid: number) => cy.get(`[data-cy-files-list-row-fileid="${fileid}"]`)
export const getRowForFile = (filename: string) => cy.get(`[data-cy-files-list-row-name="${CSS.escape(filename)}"]`)
Expand Down Expand Up @@ -214,3 +214,45 @@ export const reloadCurrentFolder = () => {
cy.get('[data-cy-files-content-breadcrumbs]').findByRole('button', { description: 'Reload current directory' }).click()
cy.wait('@propfind')
}

/**
* Enable the grid mode for the files list.
* Will fail if already enabled!
*/
export function enableGridMode() {
cy.intercept('**/apps/files/api/v1/config/grid_view').as('setGridMode')
cy.findByRole('button', { name: 'Switch to grid view' })
.should('be.visible')
.click()
cy.wait('@setGridMode')
}

/**
* Calculate the needed viewport height to limit the visible rows of the file list.
* Requires a logged in user.
*
* @param rows The number of rows that should be displayed at the same time
*/
export function calculateViewportHeight(rows: number): Cypress.Chainable<number> {
cy.visit('/apps/files')

return cy.get('[data-cy-files-list]')
.should('be.visible')
.then((filesList) => {
const windowHeight = Cypress.$('body').outerHeight()!
// Size of other page elements
const outerHeight = Math.ceil(windowHeight - filesList.outerHeight()!)
// Size of before and filters
const beforeHeight = Math.ceil(Cypress.$('.files-list__before').outerHeight()!)
const filterHeight = Math.ceil(Cypress.$('.files-list__filters').outerHeight()!)
// Size of the table header
const tableHeaderHeight = Math.ceil(Cypress.$('[data-cy-files-list-thead]').outerHeight()!)
// table row height
const rowHeight = Math.ceil(Cypress.$('[data-cy-files-list-tbody] tr').outerHeight()!)

// sum it up
const viewportHeight = outerHeight + beforeHeight + filterHeight + tableHeaderHeight + rows * rowHeight
cy.log(`Calculated viewport height: ${viewportHeight} (${outerHeight} + ${beforeHeight} + ${filterHeight} + ${tableHeaderHeight} + ${rows} * ${rowHeight})`)
return cy.wrap(viewportHeight)
})
}
Loading

0 comments on commit 5bddc7d

Please sign in to comment.