aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorFerdinand Thiessen <opensource@fthiessen.de>2025-01-31 00:00:05 +0100
committerFerdinand Thiessen <opensource@fthiessen.de>2025-02-05 18:40:00 +0100
commitd9996b92dc2e42961b8eb3343ab7c2c88db12a9f (patch)
tree66aa780b74d2d5c870bde7352872cea2d3f4655b
parent8fd6d8025ffbf1c0b798babc08d79718afdc25db (diff)
downloadnextcloud-server-d9996b92dc2e42961b8eb3343ab7c2c88db12a9f.tar.gz
nextcloud-server-d9996b92dc2e42961b8eb3343ab7c2c88db12a9f.zip
fix(files): Correctly scroll selected file into view
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
-rw-r--r--apps/files/src/components/FilesListTableFooter.vue2
-rw-r--r--apps/files/src/components/FilesListVirtual.vue6
-rw-r--r--apps/files/src/components/VirtualList.vue165
-rw-r--r--cypress/e2e/files/scrolling.cy.ts280
4 files changed, 402 insertions, 51 deletions
diff --git a/apps/files/src/components/FilesListTableFooter.vue b/apps/files/src/components/FilesListTableFooter.vue
index bf545aacf4f..63d692c100d 100644
--- a/apps/files/src/components/FilesListTableFooter.vue
+++ b/apps/files/src/components/FilesListTableFooter.vue
@@ -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;
diff --git a/apps/files/src/components/FilesListVirtual.vue b/apps/files/src/components/FilesListVirtual.vue
index 2442dd98190..447639c3fb6 100644
--- a/apps/files/src/components/FilesListVirtual.vue
+++ b/apps/files/src/components/FilesListVirtual.vue
@@ -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);
diff --git a/apps/files/src/components/VirtualList.vue b/apps/files/src/components/VirtualList.vue
index b3d1b1002f4..5ae8220d594 100644
--- a/apps/files/src/components/VirtualList.vue
+++ b/apps/files/src/components/VirtualList.vue
@@ -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)
+ },
+
+ /**
+ * 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
}
@@ -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 }))
-
- 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() {
@@ -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
},
},
})
diff --git a/cypress/e2e/files/scrolling.cy.ts b/cypress/e2e/files/scrolling.cy.ts
new file mode 100644
index 00000000000..c4ca4943a40
--- /dev/null
+++ b/cypress/e2e/files/scrolling.cy.ts
@@ -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)
+}