Split sections in independent lists

Signed-off-by: Louis Chemineau <louis@chmn.me>
This commit is contained in:
Louis Chemineau 2023-06-13 21:52:34 +02:00
parent d7da7987eb
commit 5d111a18cb
8 changed files with 207 additions and 124 deletions

View File

@ -21,36 +21,55 @@
-->
<template>
<div class="files-list-viewer">
<NcEmptyContent v-if="emptyMessage !== '' && items.length === 0 && !loading"
<NcEmptyContent v-if="emptyMessage !== '' && itemsBySections.length === 1 && itemsBySections[0].items.length === 0 && !loading"
key="emptycontent"
:title="emptyMessage">
<PackageVariant slot="icon" />
</NcEmptyContent>
<TiledLayout :base-height="baseHeight" :items="items">
<VirtualScrolling slot-scope="{rows}"
<TiledLayout :base-height="baseHeight" :sections="itemsBySections">
<VirtualScrolling slot-scope="{tiledSections}"
:use-window="useWindow"
:container-element="containerElement"
:rows="rows"
:sections="tiledSections"
:scroll-to-key="scrollToSection"
:header-height="sectionHeaderHeight"
@need-content="needContent">
<ul slot-scope="{renderedRows}">
<template v-for="row of renderedRows">
<!--
We are subtracting 1 from flex-basis to compensate for rounding issues.
The flex algo will then compensate with flex-grow.
-->
<li v-for="item of row.items"
:key="item.id"
:class="{'files-list-viewer__section-header': item.sectionHeader}"
:style="{ 'flex-basis': item.ratio ? `${row.height * item.ratio - 1}px` : '100%', height: `${row.height}px`}">
<template slot-scope="{visibleSections}">
<div v-for="section of visibleSections" :key="section.id">
<template v-if="section.id !== ''">
<!-- Placeholder when initial loading -->
<div v-if="showPlaceholders" class="files-list-viewer__placeholder" />
<div v-if="showPlaceholders"
class="files-list-viewer__placeholder"
:style="{ 'flex-basis': '100%', height: `${sectionHeaderHeight}px`}" />
<!-- Real file. -->
<slot v-else :file="item" :distance="row.distance" />
</li>
</template>
</ul>
<slot v-else
:file="{id: section.id}"
:is-header="true"
class="files-list-viewer__section-header"
:style="{ 'flex-basis': '100%', height: `${sectionHeaderHeight}px`}" />
</template>
<ul>
<template v-for="(row, rowIndex) of section.rows">
<!--
We are subtracting 1 from flex-basis to compensate for rounding issues.
The flex algo will then compensate with flex-grow.
'last-tiled-row' prevents the last row's items from growing.
-->
<li v-for="item of row.items"
:key="item.id"
:class="{ 'last-tiled-rows': rowIndex === section.rows.length - 1 }"
:style="{ 'flex-basis': `${item.width - 1}px`, height: `${item.height}px`}">
<!-- Placeholder when initial loading -->
<div v-if="showPlaceholders" class="files-list-viewer__placeholder" />
<!-- Real file. -->
<slot v-else :file="item" :distance="row.distance" />
</li>
</template>
</ul>
</div>
</template>
<NcLoadingIcon v-if="loading && !showPlaceholders" slot="loader" class="files-list-viewer__loader" />
</VirtualScrolling>
</TiledLayout>
@ -158,37 +177,36 @@ export default {
]),
/**
* @return {object[]} The list of items to pass to TiledLayout.
* @return {{id: string, items: import('../services/TiledLayout.js').TiledItem[][]}[]} The list of items to pass to TiledLayout.
*/
fileIdsToItems() {
if (this.fileIds === undefined) {
return []
}
return this.fileIds
.filter(fileId => this.files[fileId])
.map(this.mapFileToItem)
return [{
id: '',
items: this.fileIds
.filter(fileId => this.files[fileId])
.map(this.mapFileToItem),
}]
},
/**
* @return {object[]} The list of items separated by sections to pass to TiledLayout.
* @return {{id: string, items: import('../services/TiledLayout.js').TiledItem[][]}[]} The list of items separated by sections to pass to TiledLayout.
*/
sectionsToItems() {
if (this.sections === undefined) {
return []
}
return this.sections.flatMap((sectionId) => {
return [
{
id: sectionId,
sectionHeader: true,
height: this.sectionHeaderHeight,
},
...this.fileIdsBySection[sectionId]
return this.sections.map((sectionId) => {
return {
id: sectionId,
items: this.fileIdsBySection[sectionId]
.filter(fileId => this.files[fileId])
.map(this.mapFileToItem),
]
}
})
},
@ -200,13 +218,12 @@ export default {
},
/**
* @return {object[]} The list of items to pass to TiledLayout.
* @return {{id: string, items: import('../services/TiledLayout.js').TiledItem[][]}[]} The list of items to pass to TiledLayout.
*/
items() {
itemsBySections() {
if (this.fileIds !== undefined) {
if (this.showPlaceholders) {
return this.placeholderFiles
return [{ id: '', items: this.placeholderFiles }]
}
return this.fileIdsToItems
@ -214,7 +231,7 @@ export default {
if (this.sections !== undefined) {
if (this.showPlaceholders) {
return [{ height: 75, sectionHeader: true }, ...this.placeholderFiles]
return [{ id: 'placeholder', items: this.placeholderFiles }]
}
return this.sectionsToItems
@ -223,6 +240,7 @@ export default {
return []
},
/** @return {boolean} The list of items to pass to TiledLayout. */
showLoader() {
return this.loading && (this.fileIds?.length !== 0 || this.sections?.length !== 0)
},
@ -246,6 +264,10 @@ export default {
this.$emit('need-content')
},
/**
* @param {string} fileId
* @return {import('../services/TiledLayout.js').TiledItem[]}
*/
mapFileToItem(fileId) {
const file = this.files[fileId]
return {
@ -287,7 +309,7 @@ export default {
display: flex;
flex-wrap: wrap;
li {
li:not(.last-tiled-rows) {
flex-grow: 1;
}
}

View File

@ -43,8 +43,8 @@
:section-header-height="50"
:scroll-to-section="targetMonth"
@need-content="getFiles">
<template slot-scope="{file, height, distance}">
<h3 v-if="file.sectionHeader"
<template slot-scope="{file, height, isHeader, distance}">
<h3 v-if="isHeader"
:id="`file-picker-section-header-${file.id}`"
:style="{ height: `${height}px`}"
class="section-header">

View File

@ -24,9 +24,9 @@
class="tiled-container">
<!-- Slot to allow changing the rows before passing them to TiledRows -->
<!-- Useful for partially rendering rows like with VirtualScrolling -->
<slot :rows="rows">
<slot :tiled-sections="tiledSections">
<!-- Default rendering -->
<TiledRows :rows="rows" />
<TiledRows :rows="tiledSections" />
</slot>
</div>
</template>
@ -44,7 +44,8 @@ export default {
},
props: {
items: {
/** @type {import('vue').PropType<import('../VirtualScrolling.vue').Section[]>} */
sections: {
type: Array,
required: true,
},
@ -63,11 +64,19 @@ export default {
},
computed: {
/** @return {import('../services/TiledLayout.js').TiledRow[]} */
rows() {
logger.debug('[TiledLayout] Computing rows', { items: this.items })
/** @return {import('../../services/TiledLayout.js').TiledSection[]} */
tiledSections() {
logger.debug('[TiledLayout] Computing rows', { items: this.sections })
return splitItemsInRows(this.items, this.containerWidth, this.baseHeight)
return this.sections.map(section => {
const rows = splitItemsInRows(section.items, this.containerWidth, this.baseHeight)
return {
...section,
key: section.id,
rows: rows.map(row => ({ ...row, sectionKey: section.id })),
height: rows.reduce((totalHeight, row) => totalHeight + row.height, 0),
}
})
},
},

View File

@ -24,7 +24,7 @@
<div ref="rowsContainer"
class="vs-rows-container"
:style="rowsContainerStyle">
<slot :rendered-rows="visibleRows" />
<slot :visible-sections="visibleSections" />
<slot name="loader" />
</div>
</div>
@ -32,7 +32,7 @@
ref="rowsContainer"
class="vs-rows-container"
:style="rowsContainerStyle">
<slot :rendered-rows="visibleRows" />
<slot :visible-sections="visibleSections" />
<slot name="loader" />
</div>
</template>
@ -41,9 +41,24 @@
import { debounce } from 'debounce'
import logger from '../services/logger.js'
/**
* @typedef {object} Section
* @property {string} key - Unique key for the section.
* @property {Row[]} rows - The height of the row.
* @property {number} height - Height of the section, excluding the header.
*/
/**
* @typedef {Section} VisibleSection
* @property {VisibleRow[]} rows - The height of the row.
*/
/**
* @typedef {object} Row
* @property {string} key - Unique key for the row.
* @property {number} height - The height of the row.
* @property {string} sectionKey - Unique key for the row.
*/
/**
@ -55,7 +70,8 @@ export default {
name: 'VirtualScrolling',
props: {
rows: {
/** @type {import('vue').PropType<Section[]}>} */
sections: {
type: Array,
required: true,
},
@ -70,6 +86,10 @@ export default {
default: false,
},
headerHeight: {
type: Number,
default: 75,
},
renderDistance: {
type: Number,
default: 10,
@ -95,11 +115,9 @@ export default {
},
computed: {
/**
* @return {VisibleRow[]}
*/
visibleRows() {
logger.debug('[VirtualScrolling] Computing visible rows', this.rows)
/** @return {VisibleSection[]} */
visibleSections() {
logger.debug('[VirtualScrolling] Computing visible section', { sections: this.sections })
// Optimisation: get those computed properties once to not go through vue's internal every time we need them.
const containerHeight = this.containerHeight
@ -111,31 +129,39 @@ export default {
// Compute whether a row should be included in the DOM (shouldRender)
// And how visible the row is.
return this.rows
.reduce((visibleRows, row) => {
currentRowTop = currentRowBottom
currentRowBottom += row.height
return this.sections
.map(section => {
currentRowBottom += this.headerHeight
let distance = 0
return {
...section,
rows: section.rows.reduce((visibleRows, row) => {
currentRowTop = currentRowBottom
currentRowBottom += row.height
if (currentRowBottom < containerTop) {
distance = (containerTop - currentRowBottom) / containerHeight
} else if (currentRowTop > containerBottom) {
distance = (currentRowTop - containerBottom) / containerHeight
let distance = 0
if (currentRowBottom < containerTop) {
distance = (containerTop - currentRowBottom) / containerHeight
} else if (currentRowTop > containerBottom) {
distance = (currentRowTop - containerBottom) / containerHeight
}
if (distance > this.renderDistance) {
return visibleRows
}
return [
...visibleRows,
{
...row,
distance,
},
]
}, []),
}
if (distance > this.renderDistance) {
return visibleRows
}
return [
...visibleRows,
{
...row,
distance,
},
]
}, [])
})
.filter(section => section.rows.length > 0)
},
/**
@ -143,28 +169,42 @@ export default {
*
* @return {number}
*/
rowsHeight() {
totalHeight() {
const loaderHeight = 200
return this.rows
.map(row => row.height)
.reduce((totalHeight, rowHeight) => totalHeight + rowHeight, 0) + loaderHeight
return this.sections
.map(section => this.headerHeight + section.height)
.reduce((totalHeight, sectionHeight) => totalHeight + sectionHeight, 0) + loaderHeight
},
/**
* @return {number}
*/
paddingTop() {
if (this.visibleRows.length === 0) {
if (this.visibleSections.length === 0) {
return 0
}
const firstVisibleRowIndex = this.rows.findIndex(row => row.items === this.visibleRows[0].items)
let paddingTop = 0
return this.rows
.map(row => row.height)
.slice(0, firstVisibleRowIndex)
.reduce((totalHeight, rowHeight) => totalHeight + rowHeight, 0)
for (const section of this.sections) {
if (section.key !== this.visibleSections[0].rows[0].sectionKey) {
paddingTop += this.headerHeight + section.height
continue
}
for (const row of section.rows) {
if (row.key === this.visibleSections[0].rows[0].key) {
return paddingTop
}
paddingTop += row.height
}
paddingTop += this.headerHeight
}
return paddingTop
},
/**
@ -174,7 +214,7 @@ export default {
*/
rowsContainerStyle() {
return {
height: `${this.rowsHeight}px`,
height: `${this.totalHeight}px`,
paddingTop: `${this.paddingTop}px`,
}
},
@ -187,7 +227,7 @@ export default {
*/
isNearBottom() {
const buffer = this.containerHeight * this.bottomBufferRatio
return this.scrollPosition + this.containerHeight >= this.rowsHeight - buffer
return this.scrollPosition + this.containerHeight >= this.totalHeight - buffer
},
/**
@ -207,12 +247,13 @@ export default {
watch: {
isNearBottom(value) {
logger.debug('[VirtualScrolling] isNearBottom changed', { value })
if (value) {
this.$emit('need-content')
}
},
rows() {
visibleSections() {
// Re-emit need-content when rows is updated and isNearBottom is still true.
// If the height of added rows is under `bottomBufferRatio`, `isNearBottom` will still be true so we need more content.
if (this.isNearBottom) {
@ -222,14 +263,18 @@ export default {
scrollToKey(key) {
let currentRowTopDistanceFromTop = 0
for (const row of this.rows) {
if (row.key === key) {
this.$refs.container.scrollTo({ top: currentRowTopDistanceFromTop, behavior: 'smooth' })
return
for (const section of this.sections) {
if (section.key !== key) {
currentRowTopDistanceFromTop += this.headerHeight + section.height
continue
}
currentRowTopDistanceFromTop += row.height
break
}
logger.debug('[VirtualScrolling] Scrolling to', { currentRowTopDistanceFromTop })
this.$refs.container.scrollTo({ top: currentRowTopDistanceFromTop, behavior: 'smooth' })
},
},

View File

@ -84,7 +84,7 @@ export default {
const fileIds = fetchedFiles
.map(file => file.fileid)
.filter(fileId => !this.fetchedFileIds.includes(fileId)) // Filter to prevent duplicate fileIds.
.filter(fileId => !this.fetchedFileIds.includes(fileId.toString())) // Filter to prevent duplicate fileIds.
this.fetchedFileIds.push(
...fileIds

View File

@ -22,18 +22,30 @@
/**
* @typedef {object} TiledItem
* @property {string} id
* @property {number} [width] Real width of the item.
* @property {string} id - Unique id for the item.
* @property {number} width Real width of the item.
* @property {number} height Real height of the item.
* @property {number} [ratio] The aspect ratio of the item.
* @property {boolean} [sectionHeader] Whether this row is a section header.
* @property {number} ratio The aspect ratio of the item.
*/
/**
* @typedef {object} Section
* @property {string} id - Unique id for the section.
* @property {TiledItem[]} items Real width of the item.
*/
/**
* @typedef {object} TiledRow
* @property {TiledItem[]} items -
* @property {number} height -
* @property {string} key -
* @property {TiledItem[]} items - List of item in the row.
* @property {number} height - Height of the row.
* @property {string} key - Unique key for the row.
*/
/**
* @typedef {Section} TiledSection
* @property {string} key - Unique key for the section.
* @property {TiledRow[]} rows Real width of the item.
* @property {number} height - Height of the section.
*/
/**
@ -64,19 +76,20 @@ export function splitItemsInRows(items, containerWidth, baseHeight = 200) {
rowItems.push(items[currentItem++])
} while (
currentItem < items.length
&& !items[currentItem - 1].sectionHeader && !items[currentItem].sectionHeader
&& computeRowWidth([...rowItems, items[currentItem]], baseHeight) <= containerWidth
)
const rowHeight = computeRowHeight(
rowItems,
containerWidth,
items.length === currentItem,
baseHeight
)
rows[rowNumber] = {
items: rowItems,
height: computeRowHeight(
rowItems,
containerWidth,
items.length === currentItem || items[currentItem].sectionHeader === true,
baseHeight
),
items: rowItems.map(item => ({ ...item, width: rowHeight * item.ratio, height: rowHeight })),
// Key to help vue to keep track of the row in VirtualScrolling.
height: rowHeight,
key: rowItems.map(item => item.id).join('-'),
}
@ -123,11 +136,6 @@ function computeRowWidth(items, baseHeight) {
* @return {number} The height of the row
*/
function computeRowHeight(items, containerWidth, isLastRow, baseHeight) {
// Exception 1: there is only one item and its width it is a sectionHeader, meaning take the full width.
if (items.length === 1 && items[0].sectionHeader) {
return items[0].height
}
const sumOfItemsRatio = items
.map(item => item.ratio)
.reduce((sum, itemRatio) => sum + itemRatio
@ -135,14 +143,14 @@ function computeRowHeight(items, containerWidth, isLastRow, baseHeight) {
let rowHeight = containerWidth / sumOfItemsRatio
// Exception 2: there is only one item which is larger than containerWidth.
// Exception 1: there is only one item which is larger than containerWidth.
// Limit its height so that itemWidth === containerWidth
if (items.length === 1 && items[0].width > containerWidth) {
rowHeight = containerWidth / items[0].ratio
}
// Exception 3: we reached the last row.
// Force the items width to match containerWidth, and limit their heigh to baseHeight + 20.
// Exception 2: we reached the last row.
// Force the items width to match containerWidth, and limit their height to baseHeight + 20.
if (isLastRow) {
rowHeight = Math.min(baseHeight + 20, rowHeight)
}

View File

@ -122,7 +122,6 @@
<FilesPicker v-if="album !== undefined"
:destination="album.basename"
:blacklist-ids="albumFileIds"
:loading="loadingAddFilesToAlbum"
@files-picked="handleFilesPicked" />
</NcModal>

View File

@ -89,13 +89,13 @@
:base-height="isMobile ? 120 : 200"
:empty-message="t('photos', 'No photos or videos in here')"
@need-content="getContent">
<template slot-scope="{file, distance}">
<h3 v-if="file.sectionHeader"
<template slot-scope="{file, isHeader, distance}">
<h2 v-if="isHeader"
:id="`file-picker-section-header-${file.id}`"
class="section-header">
<b>{{ file.id | dateMonth }}</b>
{{ file.id | dateYear }}
</h3>
</h2>
<File v-else
:file="files[file.id]"
:allow-selection="true"