diff options
Diffstat (limited to 'src/components')
-rw-r--r-- | src/components/File.vue | 318 | ||||
-rw-r--r-- | src/components/PhotosHeader.vue | 204 | ||||
-rw-r--r-- | src/components/TiledLayout.vue | 100 | ||||
-rw-r--r-- | src/components/TiledRows.vue | 41 | ||||
-rw-r--r-- | src/components/VirtualScrolling.vue | 283 |
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('"', '').replace('"', '') + return this.item.etag.replace('"', '').replace('"', '') + }, + /** @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> |