]> source.dussan.org Git - nextcloud-server.git/commitdiff
feat: virtual scrolling update
authorJohn Molakvoæ <skjnldsv@protonmail.com>
Fri, 11 Aug 2023 07:29:20 +0000 (09:29 +0200)
committerJohn Molakvoæ <skjnldsv@protonmail.com>
Thu, 17 Aug 2023 16:56:37 +0000 (18:56 +0200)
Signed-off-by: John Molakvoæ <skjnldsv@protonmail.com>
apps/files/src/components/FileEntry.vue
apps/files/src/components/FilesListFooter.vue [new file with mode: 0644]
apps/files/src/components/FilesListHeaderActions.vue [new file with mode: 0644]
apps/files/src/components/FilesListHeaderButton.vue [new file with mode: 0644]
apps/files/src/components/FilesListTableFooter.vue
apps/files/src/components/FilesListVirtual.vue
apps/files/src/components/VirtualList.vue [new file with mode: 0644]
package-lock.json
package.json

index c540cc4e82420f9452c6923fa6b5250ef9e28bf0..c271a6965d7af912b3876fe5b79bc6924002176d 100644 (file)
 <script lang='ts'>
 import { debounce } from 'debounce'
 import { emit } from '@nextcloud/event-bus'
+import { extname } from 'path'
 import { formatFileSize, Permission } from '@nextcloud/files'
 import { Fragment } from 'vue-frag'
-import { extname } from 'path'
+import { generateUrl } from '@nextcloud/router'
 import { showError, showSuccess } from '@nextcloud/dialogs'
 import { translate } from '@nextcloud/l10n'
-import { generateUrl } from '@nextcloud/router'
 import { vOnClickOutside } from '@vueuse/components'
 import axios from '@nextcloud/axios'
 import CancelablePromise from 'cancelable-promise'
 import FileIcon from 'vue-material-design-icons/File.vue'
 import FolderIcon from 'vue-material-design-icons/Folder.vue'
+import moment from '@nextcloud/moment'
 import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
 import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
 import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
 import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
 import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
 import Vue from 'vue'
-import type moment from 'moment'
 
 import { ACTION_DETAILS } from '../actions/sidebarAction.ts'
 import { getFileActions, DefaultType } from '../services/FileAction.ts'
@@ -183,9 +183,9 @@ import { isCachedPreview } from '../services/PreviewService.ts'
 import { useActionsMenuStore } from '../store/actionsmenu.ts'
 import { useFilesStore } from '../store/files.ts'
 import { useKeyboardStore } from '../store/keyboard.ts'
+import { useRenamingStore } from '../store/renaming.ts'
 import { useSelectionStore } from '../store/selection.ts'
 import { useUserConfigStore } from '../store/userconfig.ts'
-import { useRenamingStore } from '../store/renaming.ts'
 import CustomElementRender from './CustomElementRender.vue'
 import CustomSvgIconRender from './CustomSvgIconRender.vue'
 import FavoriteIcon from './FavoriteIcon.vue'
@@ -489,21 +489,6 @@ export default Vue.extend({
        },
 
        watch: {
-               active(active, before) {
-                       if (active === false && before === true) {
-                               this.resetState()
-
-                               // When the row is not active anymore
-                               // remove the display from the row to prevent
-                               // keyboard interaction with it.
-                               this.$el.parentNode.style.display = 'none'
-                               return
-                       }
-
-                       // Restore default tabindex
-                       this.$el.parentNode.style.display = ''
-               },
-
                /**
                 * When the source changes, reset the preview
                 * and fetch the new one.
diff --git a/apps/files/src/components/FilesListFooter.vue b/apps/files/src/components/FilesListFooter.vue
new file mode 100644 (file)
index 0000000..b4a2d7e
--- /dev/null
@@ -0,0 +1,175 @@
+<!--
+  - @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
+  -
+  - @author John Molakvoæ <skjnldsv@protonmail.com>
+  -
+  - @license GNU AGPL version 3 or any later version
+  -
+  - 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>
+       <tr>
+               <th class="files-list__row-checkbox">
+                       <span class="hidden-visually">{{ t('files', 'Total rows summary') }}</span>
+               </th>
+
+               <!-- Link to file -->
+               <td class="files-list__row-name">
+                       <!-- Icon or preview -->
+                       <span class="files-list__row-icon" />
+
+                       <!-- Summary -->
+                       <span>{{ summary }}</span>
+               </td>
+
+               <!-- Actions -->
+               <td class="files-list__row-actions" />
+
+               <!-- Size -->
+               <td v-if="isSizeAvailable"
+                       class="files-list__column files-list__row-size">
+                       <span>{{ totalSize }}</span>
+               </td>
+
+               <!-- Mtime -->
+               <td v-if="isMtimeAvailable"
+                       class="files-list__column files-list__row-mtime" />
+
+               <!-- Custom views columns -->
+               <th v-for="column in columns"
+                       :key="column.id"
+                       :class="classForColumn(column)">
+                       <span>{{ column.summary?.(nodes, currentView) }}</span>
+               </th>
+       </tr>
+</template>
+
+<script lang="ts">
+import { formatFileSize } from '@nextcloud/files'
+import { translate } from '@nextcloud/l10n'
+import Vue from 'vue'
+
+import { useFilesStore } from '../store/files.ts'
+import { usePathsStore } from '../store/paths.ts'
+
+export default Vue.extend({
+       name: 'FilesListFooter',
+
+       components: {
+       },
+
+       props: {
+               isMtimeAvailable: {
+                       type: Boolean,
+                       default: false,
+               },
+               isSizeAvailable: {
+                       type: Boolean,
+                       default: false,
+               },
+               nodes: {
+                       type: Array,
+                       required: true,
+               },
+               summary: {
+                       type: String,
+                       default: '',
+               },
+               filesListWidth: {
+                       type: Number,
+                       default: 0,
+               },
+       },
+
+       setup() {
+               const pathsStore = usePathsStore()
+               const filesStore = useFilesStore()
+               return {
+                       filesStore,
+                       pathsStore,
+               }
+       },
+
+       computed: {
+               currentView() {
+                       return this.$navigation.active
+               },
+
+               dir() {
+                       // Remove any trailing slash but leave root slash
+                       return (this.$route?.query?.dir || '/').replace(/^(.+)\/$/, '$1')
+               },
+
+               currentFolder() {
+                       if (!this.currentView?.id) {
+                               return
+                       }
+
+                       if (this.dir === '/') {
+                               return this.filesStore.getRoot(this.currentView.id)
+                       }
+                       const fileId = this.pathsStore.getPath(this.currentView.id, this.dir)
+                       return this.filesStore.getNode(fileId)
+               },
+
+               columns() {
+                       // Hide columns if the list is too small
+                       if (this.filesListWidth < 512) {
+                               return []
+                       }
+                       return this.currentView?.columns || []
+               },
+
+               totalSize() {
+                       // If we have the size already, let's use it
+                       if (this.currentFolder?.size) {
+                               return formatFileSize(this.currentFolder.size, true)
+                       }
+
+                       // Otherwise let's compute it
+                       return formatFileSize(this.nodes.reduce((total, node) => total + node.size || 0, 0), true)
+               },
+       },
+
+       methods: {
+               classForColumn(column) {
+                       return {
+                               'files-list__row-column-custom': true,
+                               [`files-list__row-${this.currentView.id}-${column.id}`]: true,
+                       }
+               },
+
+               t: translate,
+       },
+})
+</script>
+
+<style scoped lang="scss">
+// Scoped row
+tr {
+       padding-bottom: 300px;
+       border-top: 1px solid var(--color-border);
+       // Prevent hover effect on the whole row
+       background-color: transparent !important;
+       border-bottom: none !important;
+}
+
+td {
+       user-select: none;
+       // Make sure the cell colors don't apply to column headers
+       color: var(--color-text-maxcontrast) !important;
+}
+
+</style>
diff --git a/apps/files/src/components/FilesListHeaderActions.vue b/apps/files/src/components/FilesListHeaderActions.vue
new file mode 100644 (file)
index 0000000..e419c8e
--- /dev/null
@@ -0,0 +1,226 @@
+<!--
+  - @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
+  -
+  - @author John Molakvoæ <skjnldsv@protonmail.com>
+  -
+  - @license GNU AGPL version 3 or any later version
+  -
+  - 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>
+       <th class="files-list__column files-list__row-actions-batch" colspan="2">
+               <NcActions ref="actionsMenu"
+                       :disabled="!!loading || areSomeNodesLoading"
+                       :force-name="true"
+                       :inline="inlineActions"
+                       :menu-name="inlineActions <= 1 ? t('files', 'Actions') : null"
+                       :open.sync="openedMenu">
+                       <NcActionButton v-for="action in enabledActions"
+                               :key="action.id"
+                               :class="'files-list__row-actions-batch-' + action.id"
+                               @click="onActionClick(action)">
+                               <template #icon>
+                                       <NcLoadingIcon v-if="loading === action.id" :size="18" />
+                                       <CustomSvgIconRender v-else :svg="action.iconSvgInline(nodes, currentView)" />
+                               </template>
+                               {{ action.displayName(nodes, currentView) }}
+                       </NcActionButton>
+               </NcActions>
+       </th>
+</template>
+
+<script lang="ts">
+import { showError, showSuccess } from '@nextcloud/dialogs'
+import { translate } from '@nextcloud/l10n'
+import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
+import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
+import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
+import Vue from 'vue'
+
+import { getFileActions } from '../services/FileAction.ts'
+import { useActionsMenuStore } from '../store/actionsmenu.ts'
+import { useFilesStore } from '../store/files.ts'
+import { useSelectionStore } from '../store/selection.ts'
+import filesListWidthMixin from '../mixins/filesListWidth.ts'
+import CustomSvgIconRender from './CustomSvgIconRender.vue'
+import logger from '../logger.js'
+
+// The registered actions list
+const actions = getFileActions()
+
+export default Vue.extend({
+       name: 'FilesListHeaderActions',
+
+       components: {
+               CustomSvgIconRender,
+               NcActions,
+               NcActionButton,
+               NcLoadingIcon,
+       },
+
+       mixins: [
+               filesListWidthMixin,
+       ],
+
+       props: {
+               currentView: {
+                       type: Object,
+                       required: true,
+               },
+               selectedNodes: {
+                       type: Array,
+                       default: () => ([]),
+               },
+       },
+
+       setup() {
+               const actionsMenuStore = useActionsMenuStore()
+               const filesStore = useFilesStore()
+               const selectionStore = useSelectionStore()
+               return {
+                       actionsMenuStore,
+                       filesStore,
+                       selectionStore,
+               }
+       },
+
+       data() {
+               return {
+                       loading: null,
+               }
+       },
+
+       computed: {
+               dir() {
+                       // Remove any trailing slash but leave root slash
+                       return (this.$route?.query?.dir || '/').replace(/^(.+)\/$/, '$1')
+               },
+               enabledActions() {
+                       return actions
+                               .filter(action => action.execBatch)
+                               .filter(action => !action.enabled || action.enabled(this.nodes, this.currentView))
+                               .sort((a, b) => (a.order || 0) - (b.order || 0))
+               },
+
+               nodes() {
+                       return this.selectedNodes
+                               .map(fileid => this.getNode(fileid))
+                               .filter(node => node)
+               },
+
+               areSomeNodesLoading() {
+                       return this.nodes.some(node => node._loading)
+               },
+
+               openedMenu: {
+                       get() {
+                               return this.actionsMenuStore.opened === 'global'
+                       },
+                       set(opened) {
+                               this.actionsMenuStore.opened = opened ? 'global' : null
+                       },
+               },
+
+               inlineActions() {
+                       if (this.filesListWidth < 512) {
+                               return 0
+                       }
+                       if (this.filesListWidth < 768) {
+                               return 1
+                       }
+                       if (this.filesListWidth < 1024) {
+                               return 2
+                       }
+                       return 3
+               },
+       },
+
+       methods: {
+               /**
+                * Get a cached note from the store
+                *
+                * @param {number} fileId the file id to get
+                * @return {Folder|File}
+                */
+               getNode(fileId) {
+                       return this.filesStore.getNode(fileId)
+               },
+
+               async onActionClick(action) {
+                       const displayName = action.displayName(this.nodes, this.currentView)
+                       const selectionIds = this.selectedNodes
+                       try {
+                               // Set loading markers
+                               this.loading = action.id
+                               this.nodes.forEach(node => {
+                                       Vue.set(node, '_loading', true)
+                               })
+
+                               // Dispatch action execution
+                               const results = await action.execBatch(this.nodes, this.currentView, this.dir)
+
+                               // Check if all actions returned null
+                               if (!results.some(result => result !== null)) {
+                                       // If the actions returned null, we stay silent
+                                       this.selectionStore.reset()
+                                       return
+                               }
+
+                               // Handle potential failures
+                               if (results.some(result => result === false)) {
+                                       // Remove the failed ids from the selection
+                                       const failedIds = selectionIds
+                                               .filter((fileid, index) => results[index] === false)
+                                       this.selectionStore.set(failedIds)
+
+                                       showError(this.t('files', '"{displayName}" failed on some elements ', { displayName }))
+                                       return
+                               }
+
+                               // Show success message and clear selection
+                               showSuccess(this.t('files', '"{displayName}" batch action executed successfully', { displayName }))
+                               this.selectionStore.reset()
+                       } catch (e) {
+                               logger.error('Error while executing action', { action, e })
+                               showError(this.t('files', '"{displayName}" action failed', { displayName }))
+                       } finally {
+                               // Remove loading markers
+                               this.loading = null
+                               this.nodes.forEach(node => {
+                                       Vue.set(node, '_loading', false)
+                               })
+                       }
+               },
+
+               t: translate,
+       },
+})
+</script>
+
+<style scoped lang="scss">
+.files-list__row-actions-batch {
+       flex: 1 1 100% !important;
+
+       // Remove when https://github.com/nextcloud/nextcloud-vue/pull/3936 is merged
+       ::v-deep .button-vue__wrapper {
+               width: 100%;
+               span.button-vue__text {
+                       overflow: hidden;
+                       text-overflow: ellipsis;
+                       white-space: nowrap;
+               }
+       }
+}
+</style>
diff --git a/apps/files/src/components/FilesListHeaderButton.vue b/apps/files/src/components/FilesListHeaderButton.vue
new file mode 100644 (file)
index 0000000..9aac83a
--- /dev/null
@@ -0,0 +1,122 @@
+<!--
+  - @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
+  -
+  - @author John Molakvoæ <skjnldsv@protonmail.com>
+  -
+  - @license GNU AGPL version 3 or any later version
+  -
+  - 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>
+       <NcButton :aria-label="sortAriaLabel(name)"
+               :class="{'files-list__column-sort-button--active': sortingMode === mode}"
+               class="files-list__column-sort-button"
+               type="tertiary"
+               @click.stop.prevent="toggleSortBy(mode)">
+               <!-- Sort icon before text as size is align right -->
+               <MenuUp v-if="sortingMode !== mode || isAscSorting" slot="icon" />
+               <MenuDown v-else slot="icon" />
+               {{ name }}
+       </NcButton>
+</template>
+
+<script lang="ts">
+import { translate } from '@nextcloud/l10n'
+import MenuDown from 'vue-material-design-icons/MenuDown.vue'
+import MenuUp from 'vue-material-design-icons/MenuUp.vue'
+import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
+import Vue from 'vue'
+
+import filesSortingMixin from '../mixins/filesSorting.ts'
+
+export default Vue.extend({
+       name: 'FilesListHeaderButton',
+
+       components: {
+               MenuDown,
+               MenuUp,
+               NcButton,
+       },
+
+       mixins: [
+               filesSortingMixin,
+       ],
+
+       props: {
+               name: {
+                       type: String,
+                       required: true,
+               },
+               mode: {
+                       type: String,
+                       required: true,
+               },
+       },
+
+       methods: {
+               sortAriaLabel(column) {
+                       const direction = this.isAscSorting
+                               ? this.t('files', 'ascending')
+                               : this.t('files', 'descending')
+                       return this.t('files', 'Sort list by {column} ({direction})', {
+                               column,
+                               direction,
+                       })
+               },
+
+               t: translate,
+       },
+})
+</script>
+
+<style lang="scss">
+.files-list__column-sort-button {
+       // Compensate for cells margin
+       margin: 0 calc(var(--cell-margin) * -1);
+       // Reverse padding
+       padding: 0 4px 0 16px !important;
+
+       // Icon after text
+       .button-vue__wrapper {
+               flex-direction: row-reverse;
+               // Take max inner width for text overflow ellipsis
+               // Remove when https://github.com/nextcloud/nextcloud-vue/pull/3936 is merged
+               width: 100%;
+       }
+
+       .button-vue__icon {
+               transition-timing-function: linear;
+               transition-duration: .1s;
+               transition-property: opacity;
+               opacity: 0;
+       }
+
+       // Remove when https://github.com/nextcloud/nextcloud-vue/pull/3936 is merged
+       .button-vue__text {
+               overflow: hidden;
+               white-space: nowrap;
+               text-overflow: ellipsis;
+       }
+
+       &--active,
+       &:hover,
+       &:focus,
+       &:active {
+               .button-vue__icon {
+                       opacity: 1 !important;
+               }
+       }
+}
+</style>
index 4bda140770db9ba0a48c2bacad43dba6c4c40083..3e8f49deacef8e2c7417890940977a2b1121d362 100644 (file)
@@ -20,7 +20,7 @@
   -
   -->
 <template>
-       <tr class="files-list__row-footer">
+       <tr>
                <th class="files-list__row-checkbox">
                        <span class="hidden-visually">{{ t('files', 'Total rows summary') }}</span>
                </th>
index 69cab260963cee38dcb54beafe66f08528ea733e..05de0a38750eba5226fed6920036b05e099839a9 100644 (file)
@@ -233,6 +233,7 @@ export default Vue.extend({
                        width: 100%;
                        user-select: none;
                        border-bottom: 1px solid var(--color-border);
+                       user-select: none;
                }
 
                td, th {
diff --git a/apps/files/src/components/VirtualList.vue b/apps/files/src/components/VirtualList.vue
new file mode 100644 (file)
index 0000000..7780665
--- /dev/null
@@ -0,0 +1,161 @@
+<template>
+       <table class="files-list">
+               <!-- Header -->
+               <div ref="before" class="files-list__before">
+                       <slot name="before" />
+               </div>
+
+               <!-- Header -->
+               <thead ref="thead" class="files-list__thead">
+                       <slot name="header" />
+               </thead>
+
+               <!-- Body -->
+               <tbody :style="tbodyStyle" class="files-list__tbody">
+                       <tr v-for="(item, i) in renderedItems"
+                               :key="i"
+                               :class="{'list__row--active': (i >= bufferItems || index <= bufferItems) && (i < shownItems - bufferItems)}"
+                               class="list__row">
+                               <component :is="dataComponent"
+                                       :active="(i >= bufferItems || index <= bufferItems) && (i < shownItems - bufferItems)"
+                                       :source="item"
+                                       :index="i"
+                                       :item-height="itemHeight"
+                                       v-bind="extraProps" />
+                       </tr>
+               </tbody>
+
+               <!-- Footer -->
+               <tfoot ref="tfoot" class="files-list__tfoot">
+                       <slot name="footer" />
+               </tfoot>
+       </table>
+</template>
+
+<script lang="ts">
+import { File, Folder } from '@nextcloud/files'
+import { debounce } from 'debounce'
+import Vue from 'vue'
+import logger from '../logger.js'
+
+// Items to render before and after the visible area
+const bufferItems = 3
+
+export default Vue.extend({
+       name: 'VirtualList',
+
+       props: {
+               dataComponent: {
+                       type: [Object, Function],
+                       required: true,
+               },
+               dataKey: {
+                       type: String,
+                       required: true,
+               },
+               dataSources: {
+                       type: Array as () => (File | Folder)[],
+                       required: true,
+               },
+               itemHeight: {
+                       type: Number,
+                       required: true,
+               },
+               extraProps: {
+                       type: Object,
+                       default: () => ({}),
+               },
+               scrollToIndex: {
+                       type: Number,
+                       default: 0,
+               },
+       },
+
+       data() {
+               return {
+                       bufferItems,
+                       index: this.scrollToIndex,
+                       beforeHeight: 0,
+                       footerHeight: 0,
+                       headerHeight: 0,
+                       tableHeight: 0,
+                       resizeObserver: null as ResizeObserver | null,
+               }
+       },
+
+       computed: {
+               startIndex() {
+                       return Math.max(0, this.index - bufferItems)
+               },
+               shownItems() {
+                       return Math.ceil((this.tableHeight - this.headerHeight) / this.itemHeight) + bufferItems * 2
+               },
+               renderedItems(): (File | Folder)[] {
+                       return this.dataSources.slice(this.startIndex, this.startIndex + this.shownItems)
+               },
+
+               tbodyStyle() {
+                       const isOverScrolled = this.startIndex + this.shownItems > this.dataSources.length
+                       const lastIndex = this.dataSources.length - this.startIndex - this.shownItems
+                       const hiddenAfterItems = Math.min(this.dataSources.length - this.startIndex, lastIndex)
+                       return {
+                               paddingTop: `${this.startIndex * this.itemHeight}px`,
+                               paddingBottom: isOverScrolled ? 0 : `${hiddenAfterItems * this.itemHeight}px`,
+                       }
+               },
+       },
+       watch: {
+               scrollToIndex() {
+                       this.index = this.scrollToIndex
+                       this.$el.scrollTop = this.index * this.itemHeight + this.beforeHeight
+               },
+               index() {
+                       logger.debug('VirtualList index updated to ' + this.index)
+               },
+       },
+
+       mounted() {
+               const before = this.$refs?.before as HTMLElement
+               const root = this.$el as HTMLElement
+               const tfoot = this.$refs?.tfoot as HTMLElement
+               const thead = this.$refs?.thead as HTMLElement
+
+               this.resizeObserver = new ResizeObserver(debounce(() => {
+                       this.beforeHeight = before?.clientHeight ?? 0
+                       this.footerHeight = tfoot?.clientHeight ?? 0
+                       this.headerHeight = thead?.clientHeight ?? 0
+                       this.tableHeight = root?.clientHeight ?? 0
+                       logger.debug('VirtualList resizeObserver updated')
+                       this.onScroll()
+               }, 100, false))
+
+               this.resizeObserver.observe(before)
+               this.resizeObserver.observe(root)
+               this.resizeObserver.observe(tfoot)
+               this.resizeObserver.observe(thead)
+
+               this.$el.addEventListener('scroll', this.onScroll)
+
+               if (this.scrollToIndex) {
+                       this.$el.scrollTop = this.index * this.itemHeight + this.beforeHeight
+               }
+       },
+
+       beforeDestroy() {
+               if (this.resizeObserver) {
+                       this.resizeObserver.disconnect()
+               }
+       },
+
+       methods: {
+               onScroll() {
+                       // Max 0 to prevent negative index
+                       this.index = Math.max(0, Math.round((this.$el.scrollTop - this.beforeHeight) / this.itemHeight))
+               },
+       },
+})
+</script>
+
+<style scoped>
+
+</style>
index 6cc2c9e17c718ab9cb4793b9e3fbf198e182f74f..d7ac37912977c81d5f44859486ecfb2074e6195f 100644 (file)
@@ -85,6 +85,7 @@
         "vue-multiselect": "^2.1.6",
         "vue-observe-visibility": "^1.0.0",
         "vue-router": "^3.6.5",
+        "vue-virtual-scroll-list": "github:skjnldsv/vue-virtual-scroll-list#master",
         "vue-virtual-scroller": "^1.1.2",
         "vuedraggable": "^2.24.3",
         "vuex": "^3.6.2",
       "integrity": "sha512-4gDntzrifFnCEvyoO8PqyJDmguXgVPxKiIxrBKjIowvL9l+N66196+72XVYR8BBf1Uv1Fgt3bGevJ+sEmxfZzw==",
       "dev": true
     },
+    "node_modules/vue-virtual-scroll-list": {
+      "version": "2.3.5",
+      "resolved": "git+ssh://git@github.com/skjnldsv/vue-virtual-scroll-list.git#0f81a0090c3d5f934a7e44c1a90ab8bf36757ea1",
+      "license": "MIT"
+    },
     "node_modules/vue-virtual-scroller": {
       "version": "1.1.2",
       "resolved": "https://registry.npmjs.org/vue-virtual-scroller/-/vue-virtual-scroller-1.1.2.tgz",
index f0b7d870e1ea5d51ce145bae1a8be89c939bfc21..a38948e40afbb9e18df4dee539558098e3f83acd 100644 (file)
     "vue-multiselect": "^2.1.6",
     "vue-observe-visibility": "^1.0.0",
     "vue-router": "^3.6.5",
+    "vue-virtual-scroll-list": "github:skjnldsv/vue-virtual-scroll-list#master",
     "vue-virtual-scroller": "^1.1.2",
     "vuedraggable": "^2.24.3",
     "vuex": "^3.6.2",