diff options
author | John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com> | 2019-12-11 22:38:14 +0300 |
---|---|---|
committer | John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com> | 2019-12-17 16:02:40 +0300 |
commit | b501e72f31bbaf90d89e3c55ef2438b002244d0b (patch) | |
tree | ffe999b2cc4f3d2c31ef7aaff55e95d38825fb54 | |
parent | 42024d21866b5fbb3b3644d0bfb81b17d2e605c9 (diff) |
Grid virtual scroller
Signed-off-by: John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>
-rw-r--r-- | package-lock.json | 80 | ||||
-rw-r--r-- | package.json | 4 | ||||
-rw-r--r-- | src/Photos.vue | 8 | ||||
-rw-r--r-- | src/assets/grid-sizes.js | 15 | ||||
-rw-r--r-- | src/components/File.vue | 5 | ||||
-rw-r--r-- | src/components/Grid.vue | 3 | ||||
-rw-r--r-- | src/components/GridRow.vue | 77 | ||||
-rw-r--r-- | src/components/VirtualGrid.vue | 168 | ||||
-rw-r--r-- | src/mixins/GridConfig.js | 46 | ||||
-rw-r--r-- | src/services/DavRequest.js | 19 | ||||
-rw-r--r-- | src/services/GridConfig.js | 51 | ||||
-rw-r--r-- | src/services/PhotoSearch.js | 26 | ||||
-rw-r--r-- | src/services/TaggedImages.js | 21 | ||||
-rw-r--r-- | src/store/timeline.js | 4 | ||||
-rw-r--r-- | src/utils/ArrayChunk.js | 39 | ||||
-rw-r--r-- | src/utils/fileUtils.js | 2 | ||||
-rw-r--r-- | src/utils/numberUtils.js (renamed from src/utils/numberUtil.js) | 0 | ||||
-rw-r--r-- | src/views/Timeline.vue | 91 |
18 files changed, 567 insertions, 92 deletions
diff --git a/package-lock.json b/package-lock.json index cf0851a8..78af04cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1703,22 +1703,6 @@ "@babel/helper-plugin-utils": "^7.0.0" } }, - "@babel/polyfill": { - "version": "7.7.0", - "resolved": "https://registry.npmjs.org/@babel/polyfill/-/polyfill-7.7.0.tgz", - "integrity": "sha512-/TS23MVvo34dFmf8mwCisCbWGrfhbiWZSwBo6HkADTBhUa2Q/jWltyY/tpofz/b6/RIhqaqQcquptCirqIhOaQ==", - "requires": { - "core-js": "^2.6.5", - "regenerator-runtime": "^0.13.2" - }, - "dependencies": { - "core-js": { - "version": "2.6.11", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.11.tgz", - "integrity": "sha512-5wjnpaT/3dV+XB4borEsnAYQchn00XSgTAWKDkEqv+K8KevjbzmofK6hfJ9TZIlpj2N0xQpazy7PiRQiWHqzWg==" - } - } - }, "@babel/preset-env": { "version": "7.7.6", "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.7.6.tgz", @@ -1856,6 +1840,13 @@ "requires": { "@nextcloud/event-bus": "^0.2.0", "core-js": "3.2.1" + }, + "dependencies": { + "core-js": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.2.1.tgz", + "integrity": "sha512-Qa5XSVefSVPRxy2XfUC13WbvqkxhkwB3ve+pgCQveNgYzbM/UxZeu1dcOX/xr4UmfUd+muuvsaxilQzCyUurMw==" + } } }, "@nextcloud/axios": { @@ -1907,6 +1898,13 @@ "integrity": "sha512-iLdyxluCehsRibR4R/nH3O8T9CcGoAaW3eWEdQW2qPtn6eEiBXASek5nWhXa5hko1GvE7koYia4FoTWuL85/Ng==", "requires": { "core-js": "3.2.1" + }, + "dependencies": { + "core-js": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.2.1.tgz", + "integrity": "sha512-Qa5XSVefSVPRxy2XfUC13WbvqkxhkwB3ve+pgCQveNgYzbM/UxZeu1dcOX/xr4UmfUd+muuvsaxilQzCyUurMw==" + } } }, "@nextcloud/router": { @@ -3012,13 +3010,6 @@ "integrity": "sha512-fpZ81yYfzentuieinmGnphk0pLkOTMm6MZdVqwd77ROvhko6iujLNGrHH5E7utq3ygWklwfmwuG+A7P+NpqT6w==", "dev": true }, - "cdav-library": { - "version": "git+https://github.com/nextcloud/cdav-library.git#964c26c33c348315424d313b4ba9fc3aab58fed6", - "from": "git+https://github.com/nextcloud/cdav-library.git#964c26c33c348315424d313b4ba9fc3aab58fed6", - "requires": { - "@babel/polyfill": "^7.7.0" - } - }, "chalk": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", @@ -3405,9 +3396,9 @@ "integrity": "sha1-Z29us8OZl8LuGsOpJP1hJHSPV40=" }, "core-js": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.2.1.tgz", - "integrity": "sha512-Qa5XSVefSVPRxy2XfUC13WbvqkxhkwB3ve+pgCQveNgYzbM/UxZeu1dcOX/xr4UmfUd+muuvsaxilQzCyUurMw==" + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.5.0.tgz", + "integrity": "sha512-Ifh3kj78gzQ7NAoJXeTu+XwzDld0QRIwjBLRqAMhuLhP3d2Av5wmgE9ycfnvK6NAEjTkQ1sDPeoEZAWO3Hx1Uw==" }, "core-js-compat": { "version": "3.4.8", @@ -3617,6 +3608,11 @@ "integrity": "sha1-sgOOhG3DO6pXlhKNCAS0VbjB4h0=", "dev": true }, + "debounce": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.0.tgz", + "integrity": "sha512-mYtLl1xfZLi1m4RtQYlZgJUNQjl4ZxVnHzIR8nLLgi4q1YT8o/WM+MK/f8yfcc9s5Ir5zRaPZyZU6xs1Syoocg==" + }, "debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -8920,9 +8916,9 @@ "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==" }, "serialize-javascript": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-1.9.1.tgz", - "integrity": "sha512-0Vb/54WJ6k5v8sSWN09S0ora+Hnr+cX40r9F170nT+mSkaxltoE/7R3OrIdBSUv1OoiobH1QoWQbCnAO+e8J1A==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-2.1.2.tgz", + "integrity": "sha512-rs9OggEUF0V4jUSecXazOYsLfu7OGK2qIn3c7IPBiffz32XniEp/TX9Xmc9LQfK2nQ2QKHvZ2oygKUGU0lG4jQ==", "dev": true }, "set-blocking": { @@ -9147,9 +9143,9 @@ } }, "source-map-support": { - "version": "0.5.13", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", - "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.16.tgz", + "integrity": "sha512-efyLRJDr68D9hBBNIPWFjhpFzURh+KJykQwvMyW5UiZzYwoF6l4YMMDIJJEyFWxWCqfyxLzz6tSfUFR+kXXsVQ==", "dev": true, "requires": { "buffer-from": "^1.0.0", @@ -9315,9 +9311,9 @@ } }, "stream-shift": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.0.tgz", - "integrity": "sha1-1cdSgl5TZ+eG944Y5EXqIjoVWVI=", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.1.tgz", + "integrity": "sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ==", "dev": true }, "string-width": { @@ -10044,9 +10040,9 @@ } }, "terser": { - "version": "4.3.9", - "resolved": "https://registry.npmjs.org/terser/-/terser-4.3.9.tgz", - "integrity": "sha512-NFGMpHjlzmyOtPL+fDw3G7+6Ueh/sz4mkaUYa4lJCxOPTNzd0Uj0aZJOmsDYoSQyfuVoWDMSWTPU3huyOm2zdA==", + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/terser/-/terser-4.4.2.tgz", + "integrity": "sha512-Uufrsvhj9O1ikwgITGsZ5EZS6qPokUOkCegS7fYOdGTv+OA90vndUbU6PEjr5ePqHfNUbGyMO7xyIZv2MhsALQ==", "dev": true, "requires": { "commander": "^2.20.0", @@ -10063,16 +10059,16 @@ } }, "terser-webpack-plugin": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-1.4.1.tgz", - "integrity": "sha512-ZXmmfiwtCLfz8WKZyYUuuHf3dMYEjg8NrjHMb0JqHVHVOSkzp3cW2/XG1fP3tRhqEqSzMwzzRQGtAPbs4Cncxg==", + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-1.4.3.tgz", + "integrity": "sha512-QMxecFz/gHQwteWwSo5nTc6UaICqN1bMedC5sMtUc7y3Ha3Q8y6ZO0iCR8pq4RJC8Hjf0FEPEHZqcMB/+DFCrA==", "dev": true, "requires": { "cacache": "^12.0.2", "find-cache-dir": "^2.1.0", "is-wsl": "^1.1.0", "schema-utils": "^1.0.0", - "serialize-javascript": "^1.7.0", + "serialize-javascript": "^2.1.2", "source-map": "^0.6.1", "terser": "^4.1.2", "webpack-sources": "^1.4.0", diff --git a/package.json b/package.json index 9b1b9c9a..5763db91 100644 --- a/package.json +++ b/package.json @@ -38,9 +38,11 @@ "@nextcloud/router": "^0.1.0", "@nextcloud/vue": "^1.2.2", "camelcase": "^5.3.1", - "cdav-library": "git+https://github.com/nextcloud/cdav-library.git", + "core-js": "^3.5.0", + "debounce": "^1.2.0", "path-posix": "^1.0.0", "qs": "^6.9.1", + "regenerator-runtime": "^0.13.3", "url-parse": "^1.4.7", "vue": "^2.6.10", "vue-router": "^3.1.3", diff --git a/src/Photos.vue b/src/Photos.vue index acd5057a..50766d90 100644 --- a/src/Photos.vue +++ b/src/Photos.vue @@ -73,6 +73,14 @@ export default { } </script> <style lang="scss" scoped> +#app-content { + height: 100%; + display: flex; + flex-grow: 1; + flex-direction: column; + align-content: space-between; +} + .app-navigation__photos::v-deep .app-navigation-entry-icon.icon-photos { background-size: 20px; } diff --git a/src/assets/grid-sizes.js b/src/assets/grid-sizes.js index 18f42ece..c17879ba 100644 --- a/src/assets/grid-sizes.js +++ b/src/assets/grid-sizes.js @@ -20,6 +20,9 @@ * */ +// for now we want to keep the same gap everywhere +const gap = 8 + /** * Define the max width proportions * The number (key) indicate the MAX size @@ -31,53 +34,63 @@ module.exports = { sizes: { 400: { marginTop: 66, // same as grid-gap - marginW: 8, // same as grid-gap + marginW: gap, // same as grid-gap count: 3, + gap, }, 700: { marginTop: 66, marginW: 8, // same as grid-gap count: 4, + gap, }, 1024: { marginTop: 66, marginW: 44, count: 5, + gap, }, 1280: { marginTop: 66, marginW: 44, count: 4, + gap, }, 1440: { marginTop: 88, marginW: 66, count: 5, + gap, }, 1600: { marginTop: 88, marginW: 66, count: 6, + gap, }, 2048: { marginTop: 88, marginW: 66, count: 7, + gap, }, 2560: { marginTop: 88, marginW: 88, count: 8, + gap, }, 3440: { marginTop: 88, marginW: 88, count: 9, + gap, }, max: { marginTop: 88, marginW: 88, count: 10, + gap, }, }, } diff --git a/src/components/File.vue b/src/components/File.vue index 60d18a41..0af883cf 100644 --- a/src/components/File.vue +++ b/src/components/File.vue @@ -100,12 +100,15 @@ export default { isImage() { return this.mime.startsWith('image') }, + srcUrl() { + return generateUrl(`/core/preview?fileId=${this.fileid}&x=${256}&y=${256}&a=true&v=${this.etag}`) + }, }, created() { // Allow us to cancel the img loading on destroy // use etag to force cache reload if file changed - this.img.src = generateUrl(`/core/preview?fileId=${this.fileid}&x=${256}&y=${256}&a=true&v=${this.etag}`) + this.img.src = this.srcUrl this.img.addEventListener('load', () => { this.src = this.img.src }) diff --git a/src/components/Grid.vue b/src/components/Grid.vue index 85023f5a..822ecd10 100644 --- a/src/components/Grid.vue +++ b/src/components/Grid.vue @@ -43,7 +43,6 @@ export default { display: grid; align-items: center; justify-content: center; - gap: 8px; grid-template-columns: repeat(10, 1fr); position: relative; @@ -69,6 +68,7 @@ $previous: 0; $count: map-get($config, 'count'); $marginTop: map-get($config, 'marginTop'); $marginW: map-get($config, 'marginW'); + $gap: map-get($config, 'gap'); // if this is the last entry, only use min-width $rule: '(min-width: #{$previous}px) and (max-width: #{$size}px)'; @@ -78,6 +78,7 @@ $previous: 0; @media #{$rule} { .photos-grid { + gap: #{$gap}px; padding: #{$marginTop}px #{$marginW}px #{$marginW}px #{$marginW}px; grid-template-columns: repeat($count, 1fr); } diff --git a/src/components/GridRow.vue b/src/components/GridRow.vue new file mode 100644 index 00000000..6ba385da --- /dev/null +++ b/src/components/GridRow.vue @@ -0,0 +1,77 @@ +<!-- + - @copyright Copyright (c) 2019 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> + <!-- Folder content --> + <transition-group + class="photos-grid-row" + role="grid" + name="list" + tag="div"> + <slot /> + </transition-group> +</template> + +<script> +export default { + name: 'GridRow', +} +</script> + +<style scoped lang="scss"> +.photos-grid-row { + display: grid; + align-items: center; + justify-content: center; + gap: 8px; + margin-bottom: 8px; + grid-template-columns: repeat(10, 1fr); + position: relative; +} + +.list-move { + transition: transform 2s; +} + +// TODO: use mixins/GridSizes as soon as node-sass supports it +// needs node-sass 5.0 (with libsass 3.6) +// https://github.com/sass/node-sass/pull/2312 +$previous: 0; +@each $size, $config in get('sizes') { + $count: map-get($config, 'count'); + $marginTop: map-get($config, 'marginTop'); + $marginW: map-get($config, 'marginW'); + + // if this is the last entry, only use min-width + $rule: '(min-width: #{$previous}px) and (max-width: #{$size}px)'; + @if $size == 'max' { + $rule: '(min-width: #{$previous}px)'; + } + + @media #{$rule} { + .photos-grid-row { + grid-template-columns: repeat($count, 1fr); + } + } + $previous: $size; +} +</style> diff --git a/src/components/VirtualGrid.vue b/src/components/VirtualGrid.vue new file mode 100644 index 00000000..26501a0d --- /dev/null +++ b/src/components/VirtualGrid.vue @@ -0,0 +1,168 @@ + +<template> + <Grid ref="grid"> + <span v-show="shownFirstRow > 0" + ref="filler-top" + key="filler-top" + class="grid-filler grid-filler--top" + role="none" + :style="{paddingBottom: topPadding}" /> + <component :is="component(item)" + v-for="(item, index) in shownList" + :key="item.fileid" + :ref="`item-${index}`" + :class="`row-${getRowNumber(index)}`" + v-bind="props(item)" /> + <span v-show="shownLastRow < lastRow" + ref="filler-bottom" + key="filler-bottom" + class="grid-filler grid-filler--bottom" + role="none" + :style="{paddingBottom: bottomPadding}" /> + </Grid> +</template> + +<script> +import debounce from 'debounce' +import Grid from './Grid' +import GridConfigMixin from '../mixins/GridConfig' + +export default { + name: 'VirtualGrid', + components: { + Grid, + }, + mixins: [GridConfigMixin], + + props: { + list: { + type: Array, + default: () => ([]), + }, + props: { + type: Function, + default: () => ({}), + }, + component: { + type: Function, + required: true, + }, + }, + + data() { + return { + shownFirstRow: 0, + shownLastRow: this.getRowNumber(this.list.length - 1), + } + }, + + computed: { + + shownList() { + return this.list.filter((item, index) => this.isVisible(index)) + }, + + /** + * Calculate the top filler needed padding + * to compensate for the hidden items + * @returns {string} + */ + topPadding() { + return `${this.shownFirstRow * 100}%` + }, + /** + * Calculate the bottom filler needed padding + * to compensate for the hidden items + * Because bottomShift indicate the index of the last visible item, + * we need to calcuta ehow any rows there is to compensate + * between bottomShift and the end of the list + * @returns {string} + */ + bottomPadding() { + return `${(this.lastRow - this.shownLastRow) * 100}%` + }, + + lastRow() { + return this.getRowNumber(this.list.length - 1) + }, + }, + + created() { + window.addEventListener('scroll', this.onDocumentScroll) + }, + mounted() { + this.onDocumentScroll() + }, + beforeDestroy() { + window.removeEventListener('scroll', this.onDocumentScroll) + }, + + methods: { + debounceOnDocumentScroll: debounce(function() { + this.onDocumentScroll() + }, 50), + + /** + * Handle document scroll + * Detect first visible/hidden to implement virtual scrolling + */ + onDocumentScroll() { + + // get the row height + const gridContainer = this.$refs.grid.$el + const gridStyles = getComputedStyle(gridContainer) + const rowHeight = parseFloat(gridStyles.gridTemplateColumns.split(' ')[0], 10) + + // scrolled content + // rounding up to tens to make sure we only detect changes by steps of 10px + const scrolled = this.roundToTen(window.pageYOffset - this.gridConfig.marginTop) + + // adding one above and one under to have a trigger area of one row + const shownFirstRow = Math.floor(scrolled / (rowHeight + this.gridConfig.gap)) - 1 + const shownLastRow = Math.ceil(window.innerHeight / rowHeight) + shownFirstRow + 1 + + this.shownFirstRow = Math.max(shownFirstRow, 0) // the first shown row cannot be negative + this.shownLastRow = Math.min(shownLastRow, this.lastRow) // the last shown row cannot be lower than the last row + + if (this.shownLastRow >= this.lastRow) { + this.$emit('bottomReached') + } + + }, + + isVisible(index) { + const row = this.getRowNumber(index) + return row >= this.shownFirstRow && row < this.shownLastRow + 1 + }, + + /** + * Return the row number of the provided index + * + * @param {number} index the index + * @returns {number} + */ + getRowNumber(index) { + // in case the grid config is not here yet, let's + const count = this.gridConfig ? this.gridConfig.count : this.list.length + return Math.floor(index / count) + }, + + /** + * Round the provided number to a tens of its value + * + * @param {number} number the number to round + * @returns {number} + */ + roundToTen(number) { + return Math.floor(number / 10) * 10 + }, + }, +} +</script> + +<style lang="scss" scoped> +.grid-filler { + // put the filler at the end of the row to put the next one into a new line + grid-column-end: -1; +} +</style> diff --git a/src/mixins/GridConfig.js b/src/mixins/GridConfig.js new file mode 100644 index 00000000..e7b18817 --- /dev/null +++ b/src/mixins/GridConfig.js @@ -0,0 +1,46 @@ +/** + * @copyright Copyright (c) 2018 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/>. + * + */ + +import getGridConfig from '../services/GridConfig' + +/** + * Get the current used grid config + */ +export default { + data() { + return { + gridConfig: {}, + } + }, + + created() { + getGridConfig.$on('changed', val => { + this.gridConfig = val + }) + console.debug('Current grid config', getGridConfig.gridConfig) + this.gridConfig = getGridConfig.gridConfig + }, + + beforeDestroy() { + getGridConfig.$off('changed', this.gridConfig) + }, +} diff --git a/src/services/DavRequest.js b/src/services/DavRequest.js index 9a2ca05a..79c7c586 100644 --- a/src/services/DavRequest.js +++ b/src/services/DavRequest.js @@ -19,20 +19,23 @@ * along with this program. If not, see <http://www.gnu.org/licenses/>. * */ +const props = ` + <oc:fileid /> + <d:getlastmodified /> + <d:getetag /> + <d:getcontenttype /> + <d:getcontentlength /> + <nc:has-preview /> + <oc:favorite /> + <d:resourcetype />` +export { props } export default `<?xml version="1.0"?> <d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns" xmlns:ocs="http://open-collaboration-services.org/ns"> <d:prop> - <d:getlastmodified /> - <d:getetag /> - <d:getcontenttype /> - <oc:fileid /> - <d:getcontentlength /> - <nc:has-preview /> - <oc:favorite /> - <d:resourcetype /> + ${props} </d:prop> </d:propfind>` diff --git a/src/services/GridConfig.js b/src/services/GridConfig.js new file mode 100644 index 00000000..c3ee14b7 --- /dev/null +++ b/src/services/GridConfig.js @@ -0,0 +1,51 @@ +/** + * @copyright Copyright (c) 2019 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/>. + * + */ + +import Vue from 'vue' +import { sizes } from '../assets/grid-sizes' + +export default new Vue({ + data() { + return { + gridConfig: sizes.max, + } + }, + watch: { + gridConfig(val) { + this.$emit('changed', val) + }, + }, + created() { + window.addEventListener('resize', this.handleWindowResize) + this.handleWindowResize() + }, + beforeDestroy() { + window.removeEventListener('resize', this.handleWindowResize) + }, + methods: { + handleWindowResize() { + // find the first grid size that fit the current window width + const currentSize = Object.keys(sizes).find(size => size > document.documentElement.clientWidth) + this.gridConfig = sizes[currentSize] + }, + }, +}) diff --git a/src/services/PhotoSearch.js b/src/services/PhotoSearch.js index 06d8dda3..1050ff8f 100644 --- a/src/services/PhotoSearch.js +++ b/src/services/PhotoSearch.js @@ -24,15 +24,25 @@ import { genFileInfo } from '../utils/fileUtils' import { getCurrentUser } from '@nextcloud/auth' import allowedMimes from './AllowedMimes' import client from './DavClient' +import { props } from './DavRequest' +import { sizes } from '../assets/grid-sizes' /** * List files from a folder and filter out unwanted mimes * * @param {boolean} [onlyFavorites=false] not used * @param {Object} [options] used for the cancellable requests + * @param {number} [options.page=0] which page to start (starts at 0) + * @param {number} [options.perPage] how many to display per page default is 5 times the max number per line from the grid-sizes config file + * @param {boolean} [options.full=false] get full data of the files * @returns {Array} the file list */ export default async function(onlyFavorites = false, options = {}) { + // default function options + options = Object.assign({}, { + page: 0, // start at the first page + perPage: sizes.max.count * 10, // ten rows of the max width + }, options) const prefixPath = `/files/${getCurrentUser().uid}` @@ -65,18 +75,12 @@ export default async function(onlyFavorites = false, options = {}) { <d:searchrequest xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns" + xmlns:ns="https://github.com/icewind1991/SearchDAV/ns" xmlns:ocs="http://open-collaboration-services.org/ns"> <d:basicsearch> <d:select> <d:prop> - <d:getlastmodified /> - <d:getetag /> - <d:getcontenttype /> - <oc:fileid /> - <d:getcontentlength /> - <nc:has-preview /> - <oc:favorite /> - <d:resourcetype /> + ${props} </d:prop> </d:select> <d:from> @@ -104,7 +108,11 @@ export default async function(onlyFavorites = false, options = {}) { <d:prop><d:getlastmodified/></d:prop> <d:descending/> </d:order> - </d:orderby> + </d:orderby> + <d:limit> + <d:nresults>${options.perPage}</d:nresults> + <ns:firstresult>${options.page * options.perPage}</ns:firstresult> + </d:limit> </d:basicsearch> </d:searchrequest>`, deep: true, diff --git a/src/services/TaggedImages.js b/src/services/TaggedImages.js index 5fe4b3ef..4488dc88 100644 --- a/src/services/TaggedImages.js +++ b/src/services/TaggedImages.js @@ -46,6 +46,8 @@ import { genFileInfo } from '../utils/fileUtils' import { getCurrentUser } from '@nextcloud/auth' import client from './DavClient' +import { props } from './DavRequest' + /** * Get tagged files based on provided tag id * @@ -64,24 +66,7 @@ export default async function(id, options = {}) { xmlns:nc="http://nextcloud.org/ns" xmlns:ocs="http://open-collaboration-services.org/ns"> <d:prop> - <d:getlastmodified /> - <d:getetag /> - <d:getcontenttype /> - <d:resourcetype /> - <oc:fileid /> - <oc:permissions /> - <oc:size /> - <d:getcontentlength /> - <nc:has-preview /> - <nc:mount-type /> - <nc:is-encrypted /> - <ocs:share-permissions /> - <oc:tags /> - <oc:favorite /> - <oc:comments-unread /> - <oc:owner-id /> - <oc:owner-display-name /> - <oc:share-types /> + ${props} </d:prop> <oc:filter-rules> <oc:systemtag>${id}</oc:systemtag> diff --git a/src/store/timeline.js b/src/store/timeline.js index c343d764..35d6ff2b 100644 --- a/src/store/timeline.js +++ b/src/store/timeline.js @@ -32,9 +32,9 @@ const mutations = { * @param {Array} files the store mutations */ updateTimeline(state, files) { - state.timeline = files + state.timeline.push(...files .map(file => file.fileid) - .filter(id => id >= 0) + .filter(id => id >= 0)) }, } diff --git a/src/utils/ArrayChunk.js b/src/utils/ArrayChunk.js new file mode 100644 index 00000000..ff212228 --- /dev/null +++ b/src/utils/ArrayChunk.js @@ -0,0 +1,39 @@ +/** + * @copyright Copyright (c) 2019 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/>. + * + */ + +const arrayRange = function(n) { + // Array.range(5) --> [0,1,2,3,4] + return Array.apply(null, Array(n)).map((x, i) => i) +} + +/** + * Split an array into chunks + * + * @param {Array} arr an array to split + * @param {number} count lenght of the chunk + * @returns {Array} + */ +export default function(arr = [], count = 5) { + return arrayRange(Math.ceil(arr.length / count)).map((x, i) => + arr.slice(i * count, i * count + count) + ) +} diff --git a/src/utils/fileUtils.js b/src/utils/fileUtils.js index a04b9945..50e6168d 100644 --- a/src/utils/fileUtils.js +++ b/src/utils/fileUtils.js @@ -20,7 +20,7 @@ * */ import camelcase from 'camelcase' -import { isNumber } from './numberUtil' +import { isNumber } from './numberUtils' /** * Get an url encoded path diff --git a/src/utils/numberUtil.js b/src/utils/numberUtils.js index 0c3a96e5..0c3a96e5 100644 --- a/src/utils/numberUtil.js +++ b/src/utils/numberUtils.js diff --git a/src/views/Timeline.vue b/src/views/Timeline.vue index df47f1ea..51533763 100644 --- a/src/views/Timeline.vue +++ b/src/views/Timeline.vue @@ -33,29 +33,35 @@ </EmptyContent> <!-- Folder content --> - <Grid v-else-if="!loading"> - <File v-for="file in fileList" :key="file.fileid" v-bind="file" /> - </Grid> + <VirtualGrid v-else-if="!loading" + :list="fileList" + :component="getComponent" + :loading-page="loadingPage" + :props="getProps" + @bottomReached="onBottomReached" /> </template> <script> import { mapGetters } from 'vuex' +import debounce from 'debounce' import getPhotos from '../services/PhotoSearch' import EmptyContent from '../components/EmptyContent' import File from '../components/File' -import Grid from '../components/Grid' +import VirtualGrid from '../components/VirtualGrid' import cancelableRequest from '../utils/CancelableRequest' +import arrayToChunk from '../utils/ArrayChunk' +import GridConfigMixin from '../mixins/GridConfig' export default { name: 'Timeline', components: { EmptyContent, - File, - Grid, + VirtualGrid, }, + mixins: [GridConfigMixin], props: { loading: { type: Boolean, @@ -69,8 +75,11 @@ export default { data() { return { - error: null, cancelRequest: () => {}, + done: false, + error: null, + page: 0, + loadingPage: false, } }, @@ -91,6 +100,11 @@ export default { isEmpty() { return this.fileList.length === 0 }, + + // the list chunked in rows for the virtual list + chunkedList() { + return arrayToChunk(this.fileList, this.gridConfig.count) + }, }, watch: { @@ -111,6 +125,11 @@ export default { methods: { async fetchContent() { + // only one simultaneous page load + if (this.loadingPage) { + return + } + // cancel any pending requests this.cancelRequest('Changed view') @@ -122,6 +141,7 @@ export default { this.$emit('update:loading', true) } this.error = null + this.loadingPage = true // init cancellable request const { request, cancel } = cancelableRequest(getPhotos) @@ -129,9 +149,20 @@ export default { try { // get content and current folder info - const files = await request(this.onlyFavorites) + const files = await request(this.onlyFavorites, { + page: this.page, + perPage: this.gridConfig.count * 5, // we load 5 rows, + }) this.$store.dispatch('updateTimeline', files) this.$store.dispatch('appendFiles', files) + + // next time we load this script, we load the next page if the list returned + if (files.length === this.gridConfig.count * 5) { + this.page++ + } else { + console.debug('We loaded the last page') + this.done = true + } } catch (error) { if (error.response && error.response.status) { if (error.response.status === 404) { @@ -148,8 +179,52 @@ export default { } finally { // done loading even with errors this.$emit('update:loading', false) + this.loadingPage = false + } + }, + + /** + * Return the props based on the element + * Here we want to bind the full fileinfo + * object so we stupidly return it whole! + * + * @param {Object} item the scoped item from the VirtualGrid + * @returns {Object} + */ + getProps(item) { + return item + }, + + /** + * Return the component based on the element + * We only have files in the Timeline, + * so we return Files! + * + * @returns {Object} + */ + getComponent() { + return File + }, + + debounceOnBottomReached: debounce(function() { + this.onBottomReached() + }, 1000), + + /** + * When virtual grid reach the bottom, + * we load the next page + */ + onBottomReached() { + // if we're currently loading or if a previous + // request returned the last page, we stop + if (this.loadingPage || this.done) { + return } + + console.debug('Loading next page', this.page) + this.fetchContent() }, + }, } |