Welcome to mirror list, hosted at ThFree Co, Russian Federation.

github.com/nextcloud/photos.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'src/components')
-rw-r--r--src/components/File.vue318
-rw-r--r--src/components/PhotosHeader.vue204
-rw-r--r--src/components/TiledLayout.vue100
-rw-r--r--src/components/TiledRows.vue41
-rw-r--r--src/components/VirtualScrolling.vue283
5 files changed, 872 insertions, 74 deletions
diff --git a/src/components/File.vue b/src/components/File.vue
index c69a6273..87ff8053 100644
--- a/src/components/File.vue
+++ b/src/components/File.vue
@@ -21,49 +21,73 @@
-->
<template>
- <a :class="{
- 'file--cropped': croppedLayout,
- }"
- class="file"
- :href="davPath"
- :aria-label="ariaLabel"
- @click.prevent="openViewer">
- <div v-if="item.injected.mime.includes('video') && item.injected.hasPreview" class="icon-video-white" />
- <!-- image and loading placeholder -->
- <transition-group name="fade" class="transition-group">
- <img v-if="!error"
- ref="img"
- :key="`${item.injected.basename}-img`"
- :src="src"
- :alt="item.injected.basename"
- :aria-describedby="ariaUuid"
- @load="onLoad"
- @error="onError">
-
- <svg v-if="!loaded || error"
- :key="`${item.injected.basename}-svg`"
- xmlns="http://www.w3.org/2000/svg"
- viewBox="0 0 32 32"
- fill="url(#placeholder__gradient)">
- <use v-if="isImage" href="#placeholder--img" />
- <use v-else href="#placeholder--video" />
- </svg>
- </transition-group>
-
- <!-- image name and cover -->
- <p :id="ariaUuid" class="hidden-visually">{{ item.injected.basename }}</p>
- <div class="cover" role="none" />
- </a>
+ <div class="file-container"
+ :class="{selected}">
+ <a class="file"
+ :href="davPath"
+ :aria-label="ariaLabel"
+ @click.prevent="emitClick">
+
+ <div v-if="item.mime.includes('video') && item.hasPreview" class="icon-video-white" />
+
+ <!-- image and loading placeholder -->
+ <div class="images-container">
+ <img v-if="visibility !== 'none' && canLoad && !error"
+ ref="imgNear"
+ :key="`${item.basename}-near`"
+ :src="srcNear"
+ :alt="item.basename"
+ :aria-describedby="ariaDescription"
+ @load="onLoad"
+ @error="onError">
+
+ <img v-if="visibility === 'visible' && canLoad && !error"
+ ref="imgVisible"
+ :key="`${item.basename}-visible`"
+ :src="srcVisible"
+ :alt="item.basename"
+ :aria-describedby="ariaDescription"
+ @load="onLoad"
+ @error="onError">
+
+ <div v-if="visibility === 'none' || !loaded || error"
+ :key="`${item.basename}-placeholder`"
+ class="loading-overlay">
+ <svg xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 32 32"
+ fill="url(#placeholder__gradient)">
+ <use v-if="isImage" href="#placeholder--img" />
+ <use v-else href="#placeholder--video" />
+ </svg>
+ </div>
+ </div>
+
+ <!-- image name -->
+ <p :id="ariaDescription" class="hidden-visually">{{ item.basename }}</p>
+ </a>
+
+ <CheckboxRadioSwitch v-if="allowSelection"
+ class="selection-checkbox"
+ :checked="selected"
+ @update:checked="onToggle">
+ <span class="input-label">{{ t('photos', 'Select image {imageName}', {imageName: item.basename}) }}</span>
+ </CheckboxRadioSwitch>
+ </div>
</template>
<script>
import { generateRemoteUrl, generateUrl } from '@nextcloud/router'
import { getCurrentUser } from '@nextcloud/auth'
+import { CheckboxRadioSwitch } from '@nextcloud/vue'
-import UserConfig from '../mixins/UserConfig'
+import UserConfig from '../mixins/UserConfig.js'
+import SemaphoreWithPriority from '../utils/semaphoreWithPriority.js'
export default {
name: 'File',
+ components: {
+ CheckboxRadioSwitch,
+ },
mixins: [UserConfig],
inheritAttrs: false,
props: {
@@ -71,58 +95,132 @@ export default {
type: Object,
required: true,
},
+ selected: {
+ type: Boolean,
+ required: true,
+ },
+ allowSelection: {
+ type: Boolean,
+ default: true,
+ },
+ visibility: {
+ type: String,
+ required: true,
+ },
+ semaphore: {
+ type: SemaphoreWithPriority,
+ required: true,
+ },
},
data() {
return {
loaded: false,
error: false,
+ canLoad: false,
+ semaphoreSymbol: null,
+ isDestroyed: false,
}
},
computed: {
+ /** @return {string} */
davPath() {
- return generateRemoteUrl(`dav/files/${getCurrentUser().uid}`) + this.item.injected.filename
+ return generateRemoteUrl(`dav/files/${getCurrentUser().uid}`) + this.item.filename
},
- ariaUuid() {
- return `image-${this.item.injected.fileid}`
+ /** @return {string} */
+ ariaDescription() {
+ return `image-description-${this.item.fileid}`
},
+ /** @return {string} */
ariaLabel() {
- return t('photos', 'Open the full size "{name}" image', { name: this.item.injected.basename })
+ return t('photos', 'Open the full size "{name}" image', { name: this.item.basename })
},
+ /** @return {boolean} */
isImage() {
- return this.item.injected.mime.startsWith('image')
+ return this.item.mime.startsWith('image')
},
+ /** @return {string} */
decodedEtag() {
- return this.item.injected.etag.replace('&quot;', '').replace('&quot;', '')
+ return this.item.etag.replace('&quot;', '').replace('&quot;', '')
+ },
+ /** @return {string} */
+ srcVisible() {
+ return this.getItemURL(512)
},
- src() {
- return generateUrl(`/core/preview?fileId=${this.item.injected.fileid}&c=${this.decodedEtag}&x=${250}&y=${250}&forceIcon=0&a=${this.croppedLayout ? '0' : '1'}`)
+ /** @return {string} */
+ srcNear() {
+ return this.getItemURL(64)
},
},
+ async mounted() {
+ // Don't render the component right away as it is useless if the user is only scrolling
+ await new Promise((resolve) => {
+ setTimeout(async () => { resolve() }, 250)
+ })
+
+ this.semaphoreSymbol = await this.semaphore.acquire(() => {
+ switch (this.visibility) {
+ case 'visible':
+ return 1
+ case 'near':
+ return 2
+ default:
+ return 3
+ }
+ }, this.item.fileid)
+
+ this.canLoad = true
+ if (this.visibility === 'none' || this.isDestroyed) {
+ this.releaseSemaphore()
+ }
+ },
+
beforeDestroy() {
+ this.isDestroyed = true
+ this.releaseSemaphore()
+
// cancel any pending load
- this.$refs.src = ''
+ if (this.$refs.imgNear !== undefined) {
+ this.$refs.imgNear.src = ''
+ }
+ if (this.$refs.srcVisible !== undefined) {
+ this.$refs.srcVisible.src = ''
+ }
},
methods: {
- openViewer() {
- OCA.Viewer.open({
- path: this.item.injected.filename,
- list: this.item.injected.list,
- loadMore: this.item.injected.loadMore ? async () => await this.item.injected.loadMore(true) : () => [],
- canLoop: this.item.injected.canLoop,
- })
+ emitClick() {
+ this.$emit('click', this.item.fileid)
},
/** When the image is fully loaded by browser we remove the placeholder */
onLoad() {
this.loaded = true
+ this.releaseSemaphore()
},
onError() {
this.error = true
+ this.releaseSemaphore()
+ },
+
+ onToggle(value) {
+ this.$emit('select-toggled', { id: this.item.fileid, value })
+ },
+
+ getItemURL(size) {
+ return generateUrl(`/core/preview?fileId=${this.item.fileid}&c=${this.decodedEtag}&x=${size}&y=${size}&forceIcon=0&a=1`)
+
+ },
+
+ releaseSemaphore() {
+ if (this.semaphoreSymbol === null) {
+ return
+ }
+ this.semaphore.release(this.semaphoreSymbol)
+ this.semaphoreSymbol = null
},
},
@@ -130,37 +228,109 @@ export default {
</script>
<style lang="scss" scoped>
-@import '../mixins/FileFolder';
+.file-container {
+ background: lightgray;
+ position: relative;
+ border: 2px solid white; // Use border so create a separation between images.
+ height: 100%;
+ width: 100%;
-.transition-group {
- display: contents;
-}
+ // Selection border.
+ &.selected, &:focus-within {
+ &::after {
+ position: absolute;
+ top: 0;
+ left: 0;
+ z-index: 2;
+ width: 100%;
+ height: 100%;
+ content: '';
+ outline: var(--color-primary) solid 4px;
+ outline-offset: -4px;
+ pointer-events: none;
+ }
+ }
-.icon-video-white {
- position: absolute;
- top: 10px;
- right: 10px;
- z-index: 20;
-}
+ // Reveal checkbox on hover.
+ &:hover, &.selected, &:focus-within {
+ .selection-checkbox {
+ display: flex;
+ }
+ }
-img {
- position: absolute;
- width: 100%;
- height: 100%;
- z-index: 10;
+ .selection-checkbox {
+ display: none;
+ position: absolute;
+ top: 8px;
+ // Fancy calculation to render the checkbox in the middle of narrow images.
+ right: min(22px, calc(50% - 7px));
+ z-index: 1;
+ width: fit-content;
+
+ // Make the checkbox background round on hover.
+ ::v-deep .checkbox-radio-switch__label {
+ padding: 10px;
- color: transparent; // should be diplayed on error
+ &::after {
+ content: '';
+ background: var(--color-primary-light);
+ width: 16px;
+ height: 16px;
+ position: absolute;
+ left: 1px;
+ z-index: -1;
+ }
- object-fit: contain;
+ .checkbox-radio-switch__icon {
+ margin: 0;
+ }
+ }
- .file--cropped & {
- object-fit: cover;
+ .input-label {
+ position: fixed;
+ z-index: -1;
+ top: -5000px;
+ left: -5000px;
+ }
}
-}
-svg {
- position: absolute;
- width: 70%;
- height: 70%;
+ .file {
+ width: 100%;
+ height: 100%;
+ box-sizing: border-box;
+
+ .images-container {
+ display: contents;
+
+ .icon-video-white {
+ position: absolute;
+ top: 10px;
+ right: 10px;
+ z-index: 20;
+ }
+
+ img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ position: absolute;
+ }
+
+ .loading-overlay {
+ position: absolute;
+ height: 100%;
+ width: 100%;
+ display: flex;
+ align-content: center;
+ align-items: center;
+ justify-content: center;
+
+ svg {
+ width: 70%;
+ height: 70%;
+ }
+ }
+ }
+ }
}
</style>
diff --git a/src/components/PhotosHeader.vue b/src/components/PhotosHeader.vue
new file mode 100644
index 00000000..eb151851
--- /dev/null
+++ b/src/components/PhotosHeader.vue
@@ -0,0 +1,204 @@
+<!--
+ - @copyright Copyright (c) 2019 Louis Chemineau <louis@chmn.me>
+ -
+ - @author Louis Chemineau <louis@chmn.me>
+ -
+ - @license AGPL-3.0-or-later
+ -
+ - This program is free software: you can redistribute it and/or modify
+ - it under the terms of the GNU Affero General Public License as
+ - published by the Free Software Foundation, either version 3 of the
+ - License, or (at your option) any later version.
+ -
+ - This program is distributed in the hope that it will be useful,
+ - but WITHOUT ANY WARRANTY; without even the implied warranty of
+ - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ - GNU Affero General Public License for more details.
+ -
+ - You should have received a copy of the GNU Affero General Public License
+ - along with this program. If not, see <http://www.gnu.org/licenses/>.
+ -
+ -->
+<template>
+ <div class="photos-header">
+ <Actions v-if="selection.length === 0" :menu-title="t('photos', 'Add')" :primary="true">
+ <Plus slot="icon" />
+ <!-- TODO: uncomment when implementing upload -->
+ <!-- <ActionButton :close-after-click="true" :title="t('photos', 'Upload media')" @click="openUploader">
+ <FileUpload slot="icon" />
+ </ActionButton> -->
+ <!-- TODO: uncomment when implementing albums -->
+ <!-- <ActionButton :close-after-click="true" :title="t('photos', 'Create new album')" @click="createNewAlbum">
+ <PlusBoxMultiple slot="icon" />
+ </ActionButton> -->
+ </Actions>
+
+ <template v-else>
+ <!-- TODO: uncomment when implementing albums -->
+ <!-- <Actions :force-title="true" :menu-title="t('photos', 'Add to album')" :primary="true">
+ <ActionButton :close-after-click="true"
+ :primary="true"
+ :title="t('photos', 'Add to album')"
+ @click="openAlbumPicker">
+ <Plus slot="icon" />
+ </ActionButton>
+ </Actions> -->
+ <Actions :force-menu="true">
+ <ActionButton :close-after-click="true" :title="t('photos', 'Download')" @click="downloadSelection">
+ <DownloadOutline slot="icon" />
+ </ActionButton>
+ <ActionButton v-if="shouldFavorite"
+ :close-after-click="true"
+ :title="t('photos', 'Favorite')"
+ @click="favoriteSelection">
+ <Star slot="icon" />
+ </ActionButton>
+ <ActionButton v-else
+ :close-after-click="true"
+ :title="t('photos', 'Remove from favorites')"
+ @click="unFavoriteSelection">
+ <Star slot="icon" />
+ </ActionButton>
+ <ActionButton :close-after-click="true" :title="t('photos', 'Delete')" @click="deleteSelection">
+ <TrashCan slot="icon" />
+ </ActionButton>
+ </Actions>
+ </template>
+
+ <Loading v-if="loadingCount > 0" class="loading-icon" />
+ </div>
+</template>
+<script>
+
+import { mapActions } from 'vuex'
+
+import Plus from 'vue-material-design-icons/Plus'
+import TrashCan from 'vue-material-design-icons/TrashCan.vue'
+// import PlusBoxMultiple from 'vue-material-design-icons/PlusBoxMultiple'
+// import FileUpload from 'vue-material-design-icons/FileUpload.vue'
+import Star from 'vue-material-design-icons/Star.vue'
+import DownloadOutline from 'vue-material-design-icons/DownloadOutline.vue'
+import Loading from 'vue-material-design-icons/Loading'
+
+import { Actions, ActionButton } from '@nextcloud/vue'
+
+import logger from '../services/logger.js'
+
+export default {
+ name: 'PhotosHeader',
+ components: {
+ Actions,
+ ActionButton,
+ Loading,
+ Plus,
+ TrashCan,
+ // FileUpload,
+ // PlusBoxMultiple,
+ Star,
+ DownloadOutline,
+ },
+ props: {
+ selection: {
+ type: Array,
+ default: () => [],
+ },
+ },
+
+ data() {
+ return {
+ loadingCount: 0,
+ }
+ },
+
+ computed: {
+ /** @type {boolean} */
+ shouldFavorite() {
+ // Favorite all selection if at least one file is not on the favorites.
+ return this.selection.some((fileId) => this.$store.state.files.files[fileId].favorite === 0)
+ },
+ },
+
+ methods: {
+ ...mapActions(['deleteFiles', 'toggleFavoriteForFiles', 'downloadFiles']),
+
+ // TODO: uncomment when implementing upload
+ // openUploader() {
+
+ // },
+
+ // TODO: uncomment when implementing albums
+ // createNewAlbum() {
+
+ // },
+ // openAlbumPicker() {
+
+ // },
+ // async moveSelectionToAlbum() {
+
+ // },
+
+ async favoriteSelection() {
+ try {
+ this.loadingCount++
+ await this.toggleFavoriteForFiles({ fileIds: this.selection, favoriteState: true })
+ } catch (error) {
+ logger.error(error)
+ } finally {
+ this.loadingCount--
+ }
+ },
+
+ async unFavoriteSelection() {
+ try {
+ this.loadingCount++
+ await this.toggleFavoriteForFiles({ fileIds: this.selection, favoriteState: false })
+ } catch (error) {
+ logger.error(error)
+ } finally {
+ this.loadingCount--
+ }
+ },
+
+ async deleteSelection() {
+ try {
+ this.loadingCount++
+ const items = this.selection
+ this.$emit('uncheck-items', items)
+ await this.deleteFiles(items)
+ } catch (error) {
+ logger.error(error)
+ } finally {
+ this.loadingCount--
+ }
+ },
+
+ async downloadSelection() {
+ try {
+ this.loadingCount++
+ await this.downloadFiles(this.selection)
+ } catch (error) {
+ logger.error(error)
+ } finally {
+ this.loadingCount--
+ }
+ },
+ },
+}
+</script>
+
+<style lang="scss" scoped>
+.photos-header {
+ display: flex;
+ margin: 16px 0;
+ padding-left: 32px;
+
+ & > * {
+ margin: 0 16px;
+ }
+
+ .loading-icon::v-deep svg {
+ animation: rotate var(--animation-duration, 0.8s) linear infinite;
+ color: var(--color-loading-dark);
+ }
+}
+</style>
diff --git a/src/components/TiledLayout.vue b/src/components/TiledLayout.vue
new file mode 100644
index 00000000..0bb379c8
--- /dev/null
+++ b/src/components/TiledLayout.vue
@@ -0,0 +1,100 @@
+<!--
+ - @copyright Copyright (c) 2019 Louis Chemineau <louis@chmn.me>
+ -
+ - @author Louis Chemineau <louis@chmn.me>
+ -
+ - @license AGPL-3.0-or-later
+ -
+ - This program is free software: you can redistribute it and/or modify
+ - it under the terms of the GNU Affero General Public License as
+ - published by the Free Software Foundation, either version 3 of the
+ - License, or (at your option) any later version.
+ -
+ - This program is distributed in the hope that it will be useful,
+ - but WITHOUT ANY WARRANTY; without even the implied warranty of
+ - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ - GNU Affero General Public License for more details.
+ -
+ - You should have received a copy of the GNU Affero General Public License
+ - along with this program. If not, see <http://www.gnu.org/licenses/>.
+ -
+ -->
+<template>
+ <div ref="tiledLayoutContainer"
+ class="tiled-container">
+ <!-- Slot to allow changing the rows before passing them to TiledRows -->
+ <!-- Useful for partially rendering rows like in VirtualScrolling -->
+ <slot :rows="rows">
+ <!-- Default rendering -->
+ <TiledRows :rows="rows" />
+ </slot>
+ </div>
+</template>
+
+<script>
+import { splitItemsInRows } from '../services/TiledLayout.js'
+import TiledRows from './TiledRows.vue'
+
+export default {
+ name: 'TiledLayout',
+
+ components: {
+ TiledRows,
+ },
+
+ props: {
+ items: {
+ type: Array,
+ required: true,
+ },
+ },
+
+ data() {
+ return {
+ containerWidth: 0,
+ /** @type {ResizeObserver} */
+ resizeObserver: null,
+ }
+ },
+
+ computed: {
+ /** @return {import('../services/TiledLayout').TiledRow[]} */
+ rows() {
+ return splitItemsInRows(this.items, this.containerWidth)
+ },
+ },
+
+ mounted() {
+ this.resizeObserver = new ResizeObserver(entries => {
+ for (const entry of entries) {
+ const cr = entry.contentRect
+ if (entry.target.classList.contains('tiled-container')) {
+ this.containerWidth = cr.width
+ }
+ }
+ })
+
+ this.resizeObserver.observe(this.$refs.tiledLayoutContainer)
+ },
+
+ beforeDestroy() {
+ this.resizeObserver.disconnect()
+ },
+}
+</script>
+
+<style scoped lang="scss">
+.photos-header {
+ height: 50px;
+}
+
+.tiled-container {
+ margin: 0 24px;
+
+ .tiled-row {
+ display: flex;
+ justify-content: space-around;
+ width: fit-content; // Prevent solitary image to be rendered in the middle because of the flex layout.
+ }
+}
+</style>
diff --git a/src/components/TiledRows.vue b/src/components/TiledRows.vue
new file mode 100644
index 00000000..d5c4a665
--- /dev/null
+++ b/src/components/TiledRows.vue
@@ -0,0 +1,41 @@
+<!--
+ - @copyright Copyright (c) 2022 Louis Chemineau <louis@chmn.me>
+ -
+ - @author Louis Chemineau <louis@chmn.me>
+ -
+ - @license AGPL-3.0-or-later
+ -
+ - This program is free software: you can redistribute it and/or modify
+ - it under the terms of the GNU Affero General Public License as
+ - published by the Free Software Foundation, either version 3 of the
+ - License, or (at your option) any later version.
+ -
+ - This program is distributed in the hope that it will be useful,
+ - but WITHOUT ANY WARRANTY; without even the implied warranty of
+ - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ - GNU Affero General Public License for more details.
+ -
+ - You should have received a copy of the GNU Affero General Public License
+ - along with this program. If not, see <http://www.gnu.org/licenses/>.
+ -
+ -->
+
+<template functional>
+ <ul>
+ <div v-for="row of props.rows"
+ :key="row.key"
+ class="tiled-row"
+ :style="{height: `${row.height}px`}">
+ <li v-for="item of row.items"
+ :key="item.id"
+ :style="{ width: item.ratio ? `${row.height * item.ratio}px` : '100%', height: `${row.height}px`}">
+ <slot :row="row" :item="item" />
+ </li>
+ </div>
+ </ul>
+</template>
+<style lang="scss" scoped>
+.tiled-row {
+ display: flex;
+}
+</style>
diff --git a/src/components/VirtualScrolling.vue b/src/components/VirtualScrolling.vue
new file mode 100644
index 00000000..60b30a3c
--- /dev/null
+++ b/src/components/VirtualScrolling.vue
@@ -0,0 +1,283 @@
+<!--
+ - @copyright Copyright (c) 2022 Louis Chemineau <louis@chmn.me>
+ -
+ - @author Louis Chemineau <louis@chmn.me>
+ -
+ - @license AGPL-3.0-or-later
+ -
+ - This program is free software: you can redistribute it and/or modify
+ - it under the terms of the GNU Affero General Public License as
+ - published by the Free Software Foundation, either version 3 of the
+ - License, or (at your option) any later version.
+ -
+ - This program is distributed in the hope that it will be useful,
+ - but WITHOUT ANY WARRANTY; without even the implied warranty of
+ - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ - GNU Affero General Public License for more details.
+ -
+ - You should have received a copy of the GNU Affero General Public License
+ - along with this program. If not, see <http://www.gnu.org/licenses/>.
+ -
+ -->
+<template>
+ <div v-if="!useWindow && containerElement === null" ref="container" class="vs-container">
+ <div ref="rowsContainer"
+ class="vs-rows-container"
+ :style="rowsContainerStyle">
+ <slot :rendered-rows="visibleRows" />
+ </div>
+ </div>
+ <div v-else
+ ref="rowsContainer"
+ class="vs-rows-container"
+ :style="rowsContainerStyle">
+ <slot :rendered-rows="visibleRows" />
+ </div>
+</template>
+
+<script>
+/**
+ * @typedef {object} Row
+ * @property {number} height - The height of the row.
+ */
+
+/**
+ * @typedef {Row} VisibleRow
+ * @property {'none'|'near'|'visible'} visibility - The visibility state of the row
+ * @property {boolean} shouldRender - Whether the row should be renderer in the DOM
+ */
+
+export default {
+ name: 'VirtualScrolling',
+
+ props: {
+ rows: {
+ type: Array,
+ required: true,
+ },
+
+ containerElement: {
+ type: HTMLElement,
+ default: null,
+ },
+
+ useWindow: {
+ type: Boolean,
+ default: false,
+ },
+
+ renderWindowRatio: {
+ type: Number,
+ default: 6,
+ },
+
+ willBeVisibleWindowRatio: {
+ type: Number,
+ default: 4,
+ },
+
+ visibleWindowRatio: {
+ type: Number,
+ // A little bit more than the container's height to include items at its edges.
+ default: 0,
+ },
+
+ bottomBufferRatio: {
+ type: Number,
+ default: 5,
+ },
+ },
+
+ data() {
+ return {
+ scrollPosition: 0,
+ containerHeight: 0,
+ rowsContainerHeight: 0,
+ /** @type {ResizeObserver} */
+ resizeObserver: null,
+ }
+ },
+
+ computed: {
+ /**
+ * @return {VisibleRow[]}
+ */
+ visibleRows() {
+ // Optimisation: get those computed properties once to not go through vue's internal every time we need them.
+ const scrollPosition = this.scrollPosition
+ const containerHeight = this.containerHeight
+
+ // Optimisation: different windows to hint the items how they should render themselves.
+ // This will be forwarded with the visibility props.
+ const shouldRenderedWindow = containerHeight * this.renderWindowRatio
+ const willBeVisibleWindow = containerHeight * this.willBeVisibleWindowRatio
+ const visibleWindow = containerHeight * this.visibleWindowRatio
+
+ let currentRowTopDistanceFromTop = 0
+ let currentBottomTopDistanceFromTop = 0
+
+ // Compute whether a row should be included in the DOM (shouldRender)
+ // And how visible the row is.
+ return this.rows
+ .reduce((visibleRows, row) => {
+ currentRowTopDistanceFromTop = currentBottomTopDistanceFromTop
+ currentBottomTopDistanceFromTop += row.height
+
+ if (currentRowTopDistanceFromTop < scrollPosition - shouldRenderedWindow || scrollPosition + containerHeight + shouldRenderedWindow < currentRowTopDistanceFromTop) {
+ return visibleRows
+ }
+
+ let visibility = 'none'
+
+ if (scrollPosition - willBeVisibleWindow < currentRowTopDistanceFromTop && currentRowTopDistanceFromTop < scrollPosition + containerHeight + willBeVisibleWindow) {
+ visibility = 'near'
+
+ if (scrollPosition - visibleWindow < currentRowTopDistanceFromTop && currentRowTopDistanceFromTop < scrollPosition + containerHeight + visibleWindow) {
+ visibility = 'visible'
+ }
+
+ if (scrollPosition - visibleWindow < currentBottomTopDistanceFromTop && currentBottomTopDistanceFromTop < scrollPosition + containerHeight + visibleWindow) {
+ visibility = 'visible'
+ }
+ }
+
+ return [
+ ...visibleRows,
+ {
+ ...row,
+ visibility,
+ },
+ ]
+ }, [])
+ },
+
+ /**
+ * Total height of all the rows.
+ *
+ * @return {number}
+ */
+ rowsHeight() {
+ return this.rows
+ .map(row => row.height)
+ .reduce((totalHeight, rowHeight) => totalHeight + rowHeight, 0)
+ },
+
+ /**
+ * @return {number}
+ */
+ paddingTop() {
+ if (this.visibleRows.length === 0) {
+ return 0
+ }
+
+ const firstVisibleRowIndex = this.rows.findIndex(row => row.items === this.visibleRows[0].items)
+
+ return this.rows
+ .map(row => row.height)
+ .slice(0, firstVisibleRowIndex)
+ .reduce((totalHeight, rowHeight) => totalHeight + rowHeight, 0)
+ },
+
+ /**
+ * padding-top is used to replace not included item in the container.
+ *
+ * @return {object}
+ */
+ rowsContainerStyle() {
+ return {
+ height: `${this.rowsHeight}px`,
+ paddingTop: `${this.paddingTop}px`,
+ }
+ },
+
+ /**
+ * Whether the user is near the bottom.
+ * If true, then the need-content event will be emitted.
+ *
+ * @return {boolean}
+ */
+ isNearBottom() {
+ const buffer = this.containerHeight * this.bottomBufferRatio
+ return this.scrollPosition + this.containerHeight >= this.rowsHeight - buffer
+ },
+
+ /**
+ * @return {HTMLElement}
+ */
+ container() {
+ if (this.containerElement !== null) {
+ return this.containerElement
+ } else if (this.useWindow) {
+ return window
+ } else {
+ return this.$refs.container
+ }
+ },
+ },
+
+ watch: {
+ isNearBottom(value) {
+ if (value) {
+ this.$emit('need-content')
+ }
+ },
+
+ rows() {
+ // 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) {
+ this.$emit('need-content')
+ }
+ },
+ },
+
+ mounted() {
+ this.resizeObserver = new ResizeObserver(entries => {
+ for (const entry of entries) {
+ const cr = entry.contentRect
+ if (entry.target.classList.contains('vs-container')) {
+ this.containerHeight = cr.height
+ }
+ if (entry.target.classList.contains('vs-rows-container')) {
+ this.rowsContainerHeight = cr.height
+ }
+ }
+ })
+
+ if (this.useWindow) {
+ window.addEventListener('resize', this.updateContainerSize)
+ this.containerHeight = window.innerHeight
+ } else {
+ this.resizeObserver.observe(this.container)
+ }
+
+ this.resizeObserver.observe(this.$refs.rowsContainer)
+ this.container.addEventListener('scroll', this.updateScrollPosition)
+ },
+
+ beforeDestroy() {
+ if (this.useWindow) {
+ window.removeEventListener('resize', this.updateContainerSize)
+ }
+
+ this.resizeObserver.disconnect()
+ this.container.removeEventListener('scroll', this.updateScrollPosition)
+ },
+
+ methods: {
+ updateScrollPosition() {
+ this.scrollPosition = this.container.scrollY
+ },
+ updateContainerSize() {
+ this.containerHeight = window.innerHeight
+ },
+ },
+}
+</script>
+
+<style scoped lang="scss">
+.vs-container {
+ overflow-y: scroll;
+ height: 100%;
+}
+</style>