diff options
author | Ferdinand Thiessen <opensource@fthiessen.de> | 2025-01-31 00:00:05 +0100 |
---|---|---|
committer | Ferdinand Thiessen <opensource@fthiessen.de> | 2025-02-05 18:40:00 +0100 |
commit | d9996b92dc2e42961b8eb3343ab7c2c88db12a9f (patch) | |
tree | 66aa780b74d2d5c870bde7352872cea2d3f4655b | |
parent | 8fd6d8025ffbf1c0b798babc08d79718afdc25db (diff) | |
download | nextcloud-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.vue | 2 | ||||
-rw-r--r-- | apps/files/src/components/FilesListVirtual.vue | 6 | ||||
-rw-r--r-- | apps/files/src/components/VirtualList.vue | 165 | ||||
-rw-r--r-- | cypress/e2e/files/scrolling.cy.ts | 280 |
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) +} |