UX: Pseudo-virtualize mosaic, cards and list view (#2292)
Related / Follow-Up Issues: - #85 - #152 - #307 - #583 - #1582 - #1623
This commit is contained in:
parent
0402b8d397
commit
d776e9cf83
7 changed files with 471 additions and 147 deletions
11
frontend/package-lock.json
generated
11
frontend/package-lock.json
generated
|
@ -58,6 +58,7 @@
|
|||
"karma-webpack": "^5.0.0",
|
||||
"luxon": "^2.4.0",
|
||||
"maplibre-gl": "^2.1.9",
|
||||
"memoize-one": "^6.0.0",
|
||||
"mini-css-extract-plugin": "^2.6.1",
|
||||
"minimist": ">=1.2.5",
|
||||
"mocha": "^10.0.0",
|
||||
|
@ -8109,6 +8110,11 @@
|
|||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/memoize-one": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
|
||||
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw=="
|
||||
},
|
||||
"node_modules/merge-descriptors": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz",
|
||||
|
@ -18761,6 +18767,11 @@
|
|||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="
|
||||
},
|
||||
"memoize-one": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
|
||||
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw=="
|
||||
},
|
||||
"merge-descriptors": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz",
|
||||
|
|
|
@ -73,6 +73,7 @@
|
|||
"karma-webpack": "^5.0.0",
|
||||
"luxon": "^2.4.0",
|
||||
"maplibre-gl": "^2.1.9",
|
||||
"memoize-one": "^6.0.0",
|
||||
"mini-css-extract-plugin": "^2.6.1",
|
||||
"minimist": ">=1.2.5",
|
||||
"mocha": "^10.0.0",
|
||||
|
|
59
frontend/src/common/virtualization-tools.js
Normal file
59
frontend/src/common/virtualization-tools.js
Normal file
|
@ -0,0 +1,59 @@
|
|||
export const virtualizationTools = {
|
||||
updateVisibleElementIndices: (visibleElementIndices, entries, elementIndexFromEntry) => {
|
||||
entries.forEach((entry) => {
|
||||
const inView = entry.isIntersecting && entry.intersectionRatio >= 0;
|
||||
const elementIndex = elementIndexFromEntry(entry);
|
||||
if (elementIndex === undefined || elementIndex < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (inView) {
|
||||
visibleElementIndices.add(elementIndex);
|
||||
} else {
|
||||
/**
|
||||
* If the target has no parent-node, it's no longer in the dom-tree.
|
||||
* If the element is no longer inView because it was removed from the
|
||||
* dom-tree, then this says nothing about the visible indices.
|
||||
* If you remove a picture from the grid, the space where the picture
|
||||
* was is still visible.
|
||||
*
|
||||
* We therefore must ignore entries that became invisible that no longer
|
||||
* exists
|
||||
*/
|
||||
const entryIsStillMounted = entry.target.parentNode !== null;
|
||||
if (entryIsStillMounted) {
|
||||
visibleElementIndices.delete(elementIndex);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* There are many things that can influence what elements are currently
|
||||
* visible on the screen, like scrolling, resizing, menu-opening etc.
|
||||
*
|
||||
* We therefore cannot make assumptions about our new first- and last
|
||||
* visible index, even if it is tempting to initialize these values
|
||||
* with this.firstVisibleElementIndex and this.lastVisibleElementIndex.
|
||||
*
|
||||
* Doing so would break the virtualization though. this.firstVisibleElementIndex
|
||||
* would for example always stay at 0
|
||||
*/
|
||||
let firstVisibleElementIndex, lastVisibileElementIndex;
|
||||
for (const visibleElementIndex of visibleElementIndices.values()) {
|
||||
if (
|
||||
firstVisibleElementIndex === undefined ||
|
||||
visibleElementIndex < firstVisibleElementIndex
|
||||
) {
|
||||
firstVisibleElementIndex = visibleElementIndex;
|
||||
}
|
||||
if (
|
||||
lastVisibileElementIndex === undefined ||
|
||||
visibleElementIndex > lastVisibileElementIndex
|
||||
) {
|
||||
lastVisibileElementIndex = visibleElementIndex;
|
||||
}
|
||||
}
|
||||
|
||||
return [firstVisibleElementIndex, lastVisibileElementIndex];
|
||||
},
|
||||
};
|
|
@ -1,30 +1,76 @@
|
|||
<template>
|
||||
<v-container grid-list-xs fluid class="pa-2 p-photos p-photo-cards">
|
||||
<v-alert
|
||||
:value="photos.length === 0"
|
||||
color="secondary-dark" icon="lightbulb_outline" class="no-results ma-2 opacity-70" outline
|
||||
>
|
||||
<h3 v-if="filter.order === 'edited'" class="body-2 ma-0 pa-0">
|
||||
<translate>No recently edited pictures</translate>
|
||||
</h3>
|
||||
<h3 v-else class="body-2 ma-0 pa-0">
|
||||
<translate>No pictures found</translate>
|
||||
</h3>
|
||||
<p class="body-1 mt-2 mb-0 pa-0">
|
||||
<translate>Try again using other filters or keywords.</translate>
|
||||
<translate>In case pictures you expect are missing, please rescan your library and wait until indexing has been completed.</translate>
|
||||
<template v-if="$config.feature('review')">
|
||||
<translate>Non-photographic and low-quality images require a review before they appear in search results.</translate>
|
||||
</template>
|
||||
</p>
|
||||
</v-alert>
|
||||
<template v-if="photos.length === 0">
|
||||
<v-alert
|
||||
:value="true"
|
||||
color="secondary-dark" icon="lightbulb_outline" class="no-results ma-2 opacity-70" outline
|
||||
>
|
||||
<h3 v-if="filter.order === 'edited'" class="body-2 ma-0 pa-0">
|
||||
<translate>No recently edited pictures</translate>
|
||||
</h3>
|
||||
<h3 v-else class="body-2 ma-0 pa-0">
|
||||
<translate>No pictures found</translate>
|
||||
</h3>
|
||||
<p class="body-1 mt-2 mb-0 pa-0">
|
||||
<translate>Try again using other filters or keywords.</translate>
|
||||
<translate>In case pictures you expect are missing, please rescan your library and wait until indexing has been completed.</translate>
|
||||
<template v-if="$config.feature('review')">
|
||||
<translate>Non-photographic and low-quality images require a review before they appear in search results.</translate>
|
||||
</template>
|
||||
</p>
|
||||
</v-alert>
|
||||
</template>
|
||||
<v-layout row wrap class="search-results photo-results cards-view" :class="{'select-results': selectMode}">
|
||||
<v-flex
|
||||
v-for="(photo, index) in photos"
|
||||
ref="items"
|
||||
:key="photo.ID"
|
||||
:data-index="index"
|
||||
style="width: min-content"
|
||||
xs12 sm6 md4 lg3 xlg2 xxxl1 d-flex
|
||||
>
|
||||
<v-card tile
|
||||
<div v-if="index < firstVisibleElementIndex || index > lastVisibileElementIndex"
|
||||
style="user-select: none"
|
||||
class="accent lighten-3 result"
|
||||
:class="photo.classes()"
|
||||
>
|
||||
<div class="accent lighten-2" style="aspect-ratio: 1" />
|
||||
<div v-if="photo.Quality < 3 && context === 'review'" style="width: 100%; height: 34px"/>
|
||||
<div class="v-card__title pa-3 card-details v-card__title--primary">
|
||||
<div>
|
||||
<h3 class="body-2 mb-2" :title="photo.Title">
|
||||
{{ photo.Title | truncate(80) }}
|
||||
</h3>
|
||||
<div v-if="photo.Description" class="caption mb-2" style="hyphens: auto; word-break: break-word">
|
||||
{{ photo.Description }}
|
||||
</div>
|
||||
<div class="caption" style="hyphens: auto; word-break: break-word">
|
||||
<i style="display: inline-block; width: 14px" />
|
||||
{{ photo.getDateString(true) }}
|
||||
<br>
|
||||
<i style="display: inline-block; width: 14px" />
|
||||
<template v-if="photo.Type === 'video' || photo.Type === 'animated'">
|
||||
{{ photo.getVideoInfo() }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ photo.getPhotoInfo() }}
|
||||
</template>
|
||||
<template v-if="filter.order === 'name' && $config.feature('download')">
|
||||
<br>
|
||||
<i style="display: inline-block; width: 14px" />
|
||||
{{ photo.baseName() }}
|
||||
</template>
|
||||
<template v-if="featPlaces && photo.Country !== 'zz'">
|
||||
<br>
|
||||
<i style="display: inline-block; width: 14px" />
|
||||
{{ photo.locationInfo() }}
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<v-card v-if="index >= firstVisibleElementIndex && index <= lastVisibileElementIndex"
|
||||
tile
|
||||
:data-id="photo.ID"
|
||||
:data-uid="photo.UID"
|
||||
style="user-select: none"
|
||||
|
@ -197,6 +243,7 @@
|
|||
import download from "common/download";
|
||||
import Notify from "common/notify";
|
||||
import {Input, InputInvalid, ClickShort, ClickLong} from "common/input";
|
||||
import {virtualizationTools} from 'common/virtualization-tools';
|
||||
|
||||
export default {
|
||||
name: 'PPhotoCards',
|
||||
|
@ -244,9 +291,53 @@ export default {
|
|||
featPrivate,
|
||||
debug,
|
||||
input,
|
||||
firstVisibleElementIndex: 0,
|
||||
lastVisibileElementIndex: 0,
|
||||
visibleElementIndices: new Set(),
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
photos: {
|
||||
handler() {
|
||||
this.$nextTick(() => {
|
||||
this.observeItems();
|
||||
});
|
||||
},
|
||||
immediate: true,
|
||||
}
|
||||
},
|
||||
beforeCreate() {
|
||||
this.intersectionObserver = new IntersectionObserver((entries) => {
|
||||
this.visibilitiesChanged(entries);
|
||||
}, {
|
||||
rootMargin: "50% 0px",
|
||||
});
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.intersectionObserver.disconnect();
|
||||
},
|
||||
methods: {
|
||||
observeItems() {
|
||||
if (this.$refs.items === undefined) {
|
||||
return;
|
||||
}
|
||||
for (const item of this.$refs.items) {
|
||||
this.intersectionObserver.observe(item);
|
||||
}
|
||||
},
|
||||
elementIndexFromIntersectionObserverEntry(entry) {
|
||||
return parseInt(entry.target.getAttribute('data-index'));
|
||||
},
|
||||
visibilitiesChanged(entries) {
|
||||
const [smallestIndex, largestIndex] = virtualizationTools.updateVisibleElementIndices(
|
||||
this.visibleElementIndices,
|
||||
entries,
|
||||
this.elementIndexFromIntersectionObserverEntry,
|
||||
);
|
||||
|
||||
this.firstVisibleElementIndex = smallestIndex;
|
||||
this.lastVisibileElementIndex = largestIndex;
|
||||
},
|
||||
livePlayer(photo) {
|
||||
return document.querySelector("#live-player-" + photo.ID);
|
||||
},
|
||||
|
|
|
@ -21,6 +21,7 @@
|
|||
</v-alert>
|
||||
</div>
|
||||
<v-data-table v-else
|
||||
ref="dataTable"
|
||||
v-model="selected"
|
||||
:headers="listColumns"
|
||||
:items="photos"
|
||||
|
@ -30,32 +31,40 @@
|
|||
disable-initial-sort
|
||||
item-key="ID"
|
||||
:no-data-text="notFoundMessage"
|
||||
|
||||
>
|
||||
<template #items="props">
|
||||
<td style="user-select: none;" :data-uid="props.item.UID" class="result" :class="props.item.classes()">
|
||||
<v-img :key="props.item.Hash"
|
||||
:src="props.item.thumbnailUrl('tile_50')"
|
||||
:alt="props.item.Title"
|
||||
:transition="false"
|
||||
aspect-ratio="1"
|
||||
style="user-select: none"
|
||||
class="accent lighten-2 clickable"
|
||||
@touchstart="onMouseDown($event, props.index)"
|
||||
@touchend.stop.prevent="onClick($event, props.index)"
|
||||
@mousedown="onMouseDown($event, props.index)"
|
||||
@contextmenu.stop="onContextMenu($event, props.index)"
|
||||
@click.stop.prevent="onClick($event, props.index)"
|
||||
<div
|
||||
v-if="props.index < firstVisibleElementIndex || props.index > lastVisibileElementIndex"
|
||||
class="v-image accent lighten-2"
|
||||
style="aspect-ratio: 1"
|
||||
/>
|
||||
<v-img
|
||||
v-if="props.index >= firstVisibleElementIndex && props.index <= lastVisibileElementIndex"
|
||||
:key="props.item.Hash"
|
||||
:src="props.item.thumbnailUrl('tile_50')"
|
||||
:alt="props.item.Title"
|
||||
:transition="false"
|
||||
aspect-ratio="1"
|
||||
style="user-select: none"
|
||||
class="accent lighten-2 clickable"
|
||||
@touchstart="onMouseDown($event, props.index)"
|
||||
@touchend.stop.prevent="onClick($event, props.index)"
|
||||
@mousedown="onMouseDown($event, props.index)"
|
||||
@contextmenu.stop="onContextMenu($event, props.index)"
|
||||
@click.stop.prevent="onClick($event, props.index)"
|
||||
>
|
||||
<v-btn v-if="selectMode" :ripple="false"
|
||||
flat icon large absolute
|
||||
class="input-select">
|
||||
flat icon large absolute
|
||||
class="input-select">
|
||||
<v-icon color="white" class="select-on">check_circle</v-icon>
|
||||
<v-icon color="white" class="select-off">radio_button_off</v-icon>
|
||||
</v-btn>
|
||||
<v-btn v-else-if="props.item.Type === 'video' || props.item.Type === 'live' || props.item.Type === 'animated'"
|
||||
:ripple="false"
|
||||
flat icon large absolute class="input-open"
|
||||
@click.stop.prevent="openPhoto(props.index, true)">
|
||||
:ripple="false"
|
||||
flat icon large absolute class="input-open"
|
||||
@click.stop.prevent="openPhoto(props.index, true)">
|
||||
<v-icon color="white" class="default-hidden action-live" :title="$gettext('Live')">$vuetify.icons.live_photo</v-icon>
|
||||
<v-icon color="white" class="default-hidden action-animated" :title="$gettext('Animated')">gif</v-icon>
|
||||
<v-icon color="white" class="default-hidden action-play" :title="$gettext('Video')">play_arrow</v-icon>
|
||||
|
@ -92,18 +101,25 @@
|
|||
</span>
|
||||
</td>
|
||||
<td class="text-xs-center">
|
||||
<v-btn v-if="hidePrivate" class="input-private" icon small flat :ripple="false"
|
||||
:data-uid="props.item.UID" @click.stop.prevent="props.item.togglePrivate()">
|
||||
<v-icon v-if="props.item.Private" color="secondary-dark" class="select-on">lock</v-icon>
|
||||
<v-icon v-else color="secondary" class="select-off">lock_open</v-icon>
|
||||
</v-btn>
|
||||
<v-btn class="input-like" icon small flat :ripple="false"
|
||||
:data-uid="props.item.UID" @click.stop.prevent="props.item.toggleLike()">
|
||||
<v-icon v-if="props.item.Favorite" color="pink lighten-3" :data-uid="props.item.UID" class="select-on">
|
||||
favorite
|
||||
</v-icon>
|
||||
<v-icon v-else color="secondary" :data-uid="props.item.UID" class="select-off">favorite_border</v-icon>
|
||||
</v-btn>
|
||||
<template v-if="props.index < firstVisibleElementIndex || props.index > lastVisibileElementIndex">
|
||||
<div v-if="hidePrivate" class="v-btn v-btn--icon v-btn--small" />
|
||||
<div class="v-btn v-btn--icon v-btn--small" />
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<v-btn v-if="hidePrivate" class="input-private" icon small flat :ripple="false"
|
||||
:data-uid="props.item.UID" @click.stop.prevent="props.item.togglePrivate()">
|
||||
<v-icon v-if="props.item.Private" color="secondary-dark" class="select-on">lock</v-icon>
|
||||
<v-icon v-else color="secondary" class="select-off">lock_open</v-icon>
|
||||
</v-btn>
|
||||
<v-btn class="input-like" icon small flat :ripple="false"
|
||||
:data-uid="props.item.UID" @click.stop.prevent="props.item.toggleLike()">
|
||||
<v-icon v-if="props.item.Favorite" color="pink lighten-3" :data-uid="props.item.UID" class="select-on">
|
||||
favorite
|
||||
</v-icon>
|
||||
<v-icon v-else color="secondary" :data-uid="props.item.UID" class="select-off">favorite_border</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
</td>
|
||||
</template>
|
||||
</v-data-table>
|
||||
|
@ -112,6 +128,7 @@
|
|||
<script>
|
||||
import download from "common/download";
|
||||
import Notify from "common/notify";
|
||||
import {virtualizationTools} from 'common/virtualization-tools';
|
||||
|
||||
export default {
|
||||
name: 'PPhotoList',
|
||||
|
@ -184,9 +201,54 @@ export default {
|
|||
scrollY: window.scrollY,
|
||||
timeStamp: -1,
|
||||
},
|
||||
firstVisibleElementIndex: 0,
|
||||
lastVisibileElementIndex: 0,
|
||||
visibleElementIndices: new Set(),
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
photos: {
|
||||
handler() {
|
||||
this.$nextTick(() => {
|
||||
this.observeItems();
|
||||
});
|
||||
},
|
||||
immediate: true,
|
||||
}
|
||||
},
|
||||
beforeCreate() {
|
||||
this.intersectionObserver = new IntersectionObserver((entries) => {
|
||||
this.visibilitiesChanged(entries);
|
||||
}, {
|
||||
rootMargin: "100% 0px",
|
||||
});
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.intersectionObserver.disconnect();
|
||||
},
|
||||
methods: {
|
||||
observeItems() {
|
||||
if (this.$refs.dataTable === undefined) {
|
||||
return;
|
||||
}
|
||||
const rows = this.$refs.dataTable.$el.getElementsByTagName('tbody')[0].children;
|
||||
for (const row of rows) {
|
||||
this.intersectionObserver.observe(row);
|
||||
}
|
||||
},
|
||||
elementIndexFromIntersectionObserverEntry(entry) {
|
||||
return entry.target.rowIndex - 2;
|
||||
},
|
||||
visibilitiesChanged(entries) {
|
||||
const [smallestIndex, largestIndex] = virtualizationTools.updateVisibleElementIndices(
|
||||
this.visibleElementIndices,
|
||||
entries,
|
||||
this.elementIndexFromIntersectionObserverEntry,
|
||||
);
|
||||
|
||||
this.firstVisibleElementIndex = smallestIndex;
|
||||
this.lastVisibileElementIndex = largestIndex;
|
||||
},
|
||||
downloadFile(index) {
|
||||
Notify.success(this.$gettext("Downloading…"));
|
||||
|
||||
|
|
|
@ -1,43 +1,54 @@
|
|||
<template>
|
||||
<v-container grid-list-xs fluid class="pa-2 p-photos p-photo-mosaic">
|
||||
<v-alert
|
||||
:value="photos.length === 0"
|
||||
color="secondary-dark" icon="lightbulb_outline" class="no-results ma-2 opacity-70" outline
|
||||
>
|
||||
<h3 v-if="filter.order === 'edited'" class="body-2 ma-0 pa-0">
|
||||
<translate>No recently edited pictures</translate>
|
||||
</h3>
|
||||
<h3 v-else class="body-2 ma-0 pa-0">
|
||||
<translate>No pictures found</translate>
|
||||
</h3>
|
||||
<p class="body-1 mt-2 mb-0 pa-0">
|
||||
<translate>Try again using other filters or keywords.</translate>
|
||||
<translate>In case pictures you expect are missing, please rescan your library and wait until indexing has been completed.</translate>
|
||||
<template v-if="$config.feature('review')">
|
||||
<translate>Non-photographic and low-quality images require a review before they appear in search results.</translate>
|
||||
</template>
|
||||
</p>
|
||||
</v-alert>
|
||||
<template v-if="photos.length === 0">
|
||||
<v-alert
|
||||
:value="true"
|
||||
color="secondary-dark" icon="lightbulb_outline" class="no-results ma-2 opacity-70" outline
|
||||
>
|
||||
<h3 v-if="filter.order === 'edited'" class="body-2 ma-0 pa-0">
|
||||
<translate>No recently edited pictures</translate>
|
||||
</h3>
|
||||
<h3 v-else class="body-2 ma-0 pa-0">
|
||||
<translate>No pictures found</translate>
|
||||
</h3>
|
||||
<p class="body-1 mt-2 mb-0 pa-0">
|
||||
<translate>Try again using other filters or keywords.</translate>
|
||||
<translate>In case pictures you expect are missing, please rescan your library and wait until indexing has been completed.</translate>
|
||||
<template v-if="$config.feature('review')">
|
||||
<translate>Non-photographic and low-quality images require a review before they appear in search results.</translate>
|
||||
</template>
|
||||
</p>
|
||||
</v-alert>
|
||||
</template>
|
||||
<v-layout row wrap class="search-results photo-results mosaic-view" :class="{'select-results': selectMode}">
|
||||
<v-flex
|
||||
v-for="(photo, index) in photos"
|
||||
ref="items"
|
||||
:key="photo.ID"
|
||||
:data-index="index"
|
||||
xs4 sm3 md2 lg1 d-flex
|
||||
>
|
||||
<v-card tile
|
||||
|
||||
<div v-if="index < firstVisibleElementIndex || index > lastVisibileElementIndex"
|
||||
style="user-select: none; aspect-ratio: 1"
|
||||
class="accent lighten-2 result"
|
||||
:class="photo.classes()"/>
|
||||
<v-card v-if="index >= firstVisibleElementIndex && index <= lastVisibileElementIndex"
|
||||
tile
|
||||
:data-id="photo.ID"
|
||||
:data-uid="photo.UID"
|
||||
style="user-select: none"
|
||||
class="result"
|
||||
style="user-select: none; aspect-ratio: 1"
|
||||
class="accent lighten-2 result"
|
||||
:class="photo.classes()"
|
||||
@contextmenu.stop="onContextMenu($event, index)">
|
||||
<v-img :key="photo.Hash"
|
||||
<v-img
|
||||
:key="photo.Hash"
|
||||
:src="photo.thumbnailUrl('tile_224')"
|
||||
:alt="photo.Title"
|
||||
:title="photo.Title"
|
||||
:transition="false"
|
||||
aspect-ratio="1"
|
||||
class="accent lighten-2 clickable"
|
||||
class="clickable"
|
||||
@touchstart.passive="input.touchStart($event, index)"
|
||||
@touchend.stop.prevent="onClick($event, index)"
|
||||
@mousedown.stop.prevent="input.mouseDown($event, index)"
|
||||
|
@ -118,6 +129,7 @@
|
|||
</template>
|
||||
<script>
|
||||
import {Input, InputInvalid, ClickShort, ClickLong} from "common/input";
|
||||
import {virtualizationTools} from 'common/virtualization-tools';
|
||||
|
||||
export default {
|
||||
name: 'PPhotoMosaic',
|
||||
|
@ -152,9 +164,53 @@ export default {
|
|||
return {
|
||||
hidePrivate: this.$config.settings().features.private,
|
||||
input: new Input(),
|
||||
firstVisibleElementIndex: 0,
|
||||
lastVisibileElementIndex: 0,
|
||||
visibleElementIndices: new Set(),
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
photos: {
|
||||
handler() {
|
||||
this.$nextTick(() => {
|
||||
this.observeItems();
|
||||
});
|
||||
},
|
||||
immediate: true,
|
||||
}
|
||||
},
|
||||
beforeCreate() {
|
||||
this.intersectionObserver = new IntersectionObserver((entries) => {
|
||||
this.visibilitiesChanged(entries);
|
||||
}, {
|
||||
rootMargin: "50% 0px",
|
||||
});
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.intersectionObserver.disconnect();
|
||||
},
|
||||
methods: {
|
||||
observeItems() {
|
||||
if (this.$refs.items === undefined) {
|
||||
return;
|
||||
}
|
||||
for (const item of this.$refs.items) {
|
||||
this.intersectionObserver.observe(item);
|
||||
}
|
||||
},
|
||||
elementIndexFromIntersectionObserverEntry(entry) {
|
||||
return parseInt(entry.target.getAttribute('data-index'));
|
||||
},
|
||||
visibilitiesChanged(entries) {
|
||||
const [smallestIndex, largestIndex] = virtualizationTools.updateVisibleElementIndices(
|
||||
this.visibleElementIndices,
|
||||
entries,
|
||||
this.elementIndexFromIntersectionObserverEntry,
|
||||
);
|
||||
|
||||
this.firstVisibleElementIndex = smallestIndex;
|
||||
this.lastVisibileElementIndex = largestIndex;
|
||||
},
|
||||
livePlayer(photo) {
|
||||
return document.querySelector("#live-player-" + photo.ID);
|
||||
},
|
||||
|
|
|
@ -23,6 +23,8 @@ Additional information can be found in our Developer Guide:
|
|||
|
||||
*/
|
||||
|
||||
import memoizeOne from 'memoize-one';
|
||||
|
||||
import RestModel from "model/rest";
|
||||
import File from "model/file";
|
||||
import Marker from "model/marker";
|
||||
|
@ -179,17 +181,21 @@ export class Photo extends RestModel {
|
|||
}
|
||||
|
||||
classes() {
|
||||
return this.generateClasses(this.isPlayable(), Clipboard.has(this), this.Portrait, this.Favorite, this.Private, this.Files.length > 1)
|
||||
}
|
||||
|
||||
generateClasses = memoizeOne((isPlayable, isInClipboard, portrait, favorite, isPrivate, hasMultipleFiles) => {
|
||||
let classes = ["is-photo", "uid-" + this.UID, "type-" + this.Type];
|
||||
|
||||
if (this.isPlayable()) classes.push("is-playable");
|
||||
if (Clipboard.has(this)) classes.push("is-selected");
|
||||
if (this.Portrait) classes.push("is-portrait");
|
||||
if (this.Favorite) classes.push("is-favorite");
|
||||
if (this.Private) classes.push("is-private");
|
||||
if (this.Files.length > 1) classes.push("is-stack");
|
||||
if (isPlayable) classes.push("is-playable");
|
||||
if (isInClipboard) classes.push("is-selected");
|
||||
if (portrait) classes.push("is-portrait");
|
||||
if (favorite) classes.push("is-favorite");
|
||||
if (isPrivate) classes.push("is-private");
|
||||
if (hasMultipleFiles) classes.push("is-stack");
|
||||
|
||||
return classes;
|
||||
}
|
||||
})
|
||||
|
||||
localDayString() {
|
||||
if (!this.TakenAtLocal) {
|
||||
|
@ -295,7 +301,7 @@ export class Photo extends RestModel {
|
|||
let iso = this.localDateString(time);
|
||||
let zone = this.getTimeZone();
|
||||
|
||||
if (this.getTimeZone() === "") {
|
||||
if (zone === "") {
|
||||
zone = "UTC";
|
||||
}
|
||||
|
||||
|
@ -303,9 +309,13 @@ export class Photo extends RestModel {
|
|||
}
|
||||
|
||||
utcDate() {
|
||||
return DateTime.fromISO(this.TakenAt).toUTC();
|
||||
return this.generateUtcDate(this.TakenAt);
|
||||
}
|
||||
|
||||
generateUtcDate = memoizeOne((takenAt) => {
|
||||
return DateTime.fromISO(takenAt).toUTC();
|
||||
})
|
||||
|
||||
baseName(truncate) {
|
||||
let result = this.fileBase(this.FileName ? this.FileName : this.mainFile().Name);
|
||||
|
||||
|
@ -352,14 +362,18 @@ export class Photo extends RestModel {
|
|||
}
|
||||
|
||||
isPlayable() {
|
||||
if (this.Type === MediaAnimated) {
|
||||
return this.generateIsPlayable(this.Type, this.Files);
|
||||
}
|
||||
|
||||
generateIsPlayable = memoizeOne((type, files) => {
|
||||
if (type === MediaAnimated) {
|
||||
return true;
|
||||
} else if (!this.Files) {
|
||||
} else if (!files) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.Files.findIndex((f) => f.Video) !== -1;
|
||||
}
|
||||
return files.some((f) => f.Video);
|
||||
})
|
||||
|
||||
videoParams() {
|
||||
const uri = this.videoUrl();
|
||||
|
@ -416,18 +430,22 @@ export class Photo extends RestModel {
|
|||
}
|
||||
|
||||
videoFile() {
|
||||
if (!this.Files) {
|
||||
return this.getVideoFileFromFiles(this.Files);
|
||||
}
|
||||
|
||||
getVideoFileFromFiles = memoizeOne((files) => {
|
||||
if (!files) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let file = this.Files.find((f) => f.Codec === CodecAvc1);
|
||||
let file = files.find((f) => f.Codec === CodecAvc1);
|
||||
|
||||
if (!file) {
|
||||
file = this.Files.find((f) => f.FileType === FormatMp4);
|
||||
file = files.find((f) => f.FileType === FormatMp4);
|
||||
}
|
||||
|
||||
if (!file) {
|
||||
file = this.Files.find((f) => !!f.Video);
|
||||
file = files.find((f) => !!f.Video);
|
||||
}
|
||||
|
||||
if (!file) {
|
||||
|
@ -435,7 +453,7 @@ export class Photo extends RestModel {
|
|||
}
|
||||
|
||||
return file;
|
||||
}
|
||||
})
|
||||
|
||||
gifFile() {
|
||||
if (!this.Files) {
|
||||
|
@ -456,18 +474,22 @@ export class Photo extends RestModel {
|
|||
}
|
||||
|
||||
mainFile() {
|
||||
if (!this.Files) {
|
||||
return this.getMainFileFromFiles(this.Files)
|
||||
}
|
||||
|
||||
getMainFileFromFiles = memoizeOne((files) => {
|
||||
if (!files) {
|
||||
return this;
|
||||
}
|
||||
|
||||
let file = this.Files.find((f) => !!f.Primary);
|
||||
let file = files.find((f) => !!f.Primary);
|
||||
|
||||
if (file) {
|
||||
return file;
|
||||
}
|
||||
|
||||
return this.Files.find((f) => f.FileType === FormatJpeg);
|
||||
}
|
||||
return files.find((f) => f.FileType === FormatJpeg);
|
||||
})
|
||||
|
||||
jpegFiles() {
|
||||
if (!this.Files) {
|
||||
|
@ -478,18 +500,20 @@ export class Photo extends RestModel {
|
|||
}
|
||||
|
||||
mainFileHash() {
|
||||
if (this.Files) {
|
||||
let file = this.mainFile();
|
||||
return this.generateMainFileHash(this.mainFile(), this.Hash);
|
||||
}
|
||||
|
||||
if (file && file.Hash) {
|
||||
return file.Hash;
|
||||
generateMainFileHash = memoizeOne((mainFile, hash) => {
|
||||
if (this.Files) {
|
||||
if (mainFile && mainFile.Hash) {
|
||||
return mainFile.Hash;
|
||||
}
|
||||
} else if (this.Hash) {
|
||||
return this.Hash;
|
||||
} else if (hash) {
|
||||
return hash;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
});
|
||||
|
||||
fileModels() {
|
||||
let result = [];
|
||||
|
@ -516,20 +540,22 @@ export class Photo extends RestModel {
|
|||
}
|
||||
|
||||
thumbnailUrl(size) {
|
||||
let hash = this.mainFileHash();
|
||||
return this.generateThumbnailUrl(this.mainFileHash(), this.videoFile(), config.contentUri, config.previewToken(), size);
|
||||
}
|
||||
|
||||
generateThumbnailUrl = memoizeOne((mainFileHash, videoFile, contentUri, previewToken, size) => {
|
||||
let hash = mainFileHash;
|
||||
|
||||
if (!hash) {
|
||||
let video = this.videoFile();
|
||||
|
||||
if (video && video.Hash) {
|
||||
return `${config.contentUri}/t/${video.Hash}/${config.previewToken()}/${size}`;
|
||||
if (videoFile && videoFile.Hash) {
|
||||
return `${contentUri}/t/${videoFile.Hash}/${previewToken}/${size}`;
|
||||
}
|
||||
|
||||
return `${config.contentUri}/svg/photo`;
|
||||
return `${contentUri}/svg/photo`;
|
||||
}
|
||||
|
||||
return `${config.contentUri}/t/${hash}/${config.previewToken()}/${size}`;
|
||||
}
|
||||
return `${contentUri}/t/${hash}/${previewToken}/${size}`;
|
||||
})
|
||||
|
||||
getDownloadUrl() {
|
||||
return `${config.apiUri}/dl/${this.mainFileHash()}?t=${config.downloadToken()}`;
|
||||
|
@ -616,33 +642,41 @@ export class Photo extends RestModel {
|
|||
}
|
||||
|
||||
getDateString(showTimeZone) {
|
||||
if (!this.TakenAt || this.Year === YearUnknown) {
|
||||
return this.generateDateString(showTimeZone, this.TakenAt, this.Year, this.Month, this.Day, this.TimeZone);
|
||||
}
|
||||
|
||||
generateDateString = memoizeOne((showTimeZone, takenAt, year, month, day, timeZone) => {
|
||||
if (!takenAt || year === YearUnknown) {
|
||||
return $gettext("Unknown");
|
||||
} else if (this.Month === MonthUnknown) {
|
||||
} else if (month === MonthUnknown) {
|
||||
return this.localYearString();
|
||||
} else if (this.Day === DayUnknown) {
|
||||
} else if (day === DayUnknown) {
|
||||
return this.localDate().toLocaleString({
|
||||
month: long,
|
||||
year: num,
|
||||
});
|
||||
} else if (this.TimeZone) {
|
||||
} else if (timeZone) {
|
||||
return this.localDate().toLocaleString(showTimeZone ? DATE_FULL_TZ : DATE_FULL);
|
||||
}
|
||||
|
||||
return this.localDate().toLocaleString(DateTime.DATE_HUGE);
|
||||
}
|
||||
})
|
||||
|
||||
shortDateString() {
|
||||
if (!this.TakenAt || this.Year === YearUnknown) {
|
||||
shortDateString = () => {
|
||||
return this.generateShortDateString(this.TakenAt, this.Year, this.Month, this.Day)
|
||||
}
|
||||
|
||||
generateShortDateString = memoizeOne((takenAt, year, month, day) => {
|
||||
if (!takenAt || year === YearUnknown) {
|
||||
return $gettext("Unknown");
|
||||
} else if (this.Month === MonthUnknown) {
|
||||
} else if (month === MonthUnknown) {
|
||||
return this.localYearString();
|
||||
} else if (this.Day === DayUnknown) {
|
||||
} else if (day === DayUnknown) {
|
||||
return this.localDate().toLocaleString({ month: "long", year: "numeric" });
|
||||
}
|
||||
|
||||
return this.localDate().toLocaleString(DateTime.DATE_MED);
|
||||
}
|
||||
})
|
||||
|
||||
hasLocation() {
|
||||
return this.Lat !== 0 || this.Lng !== 0;
|
||||
|
@ -660,19 +694,23 @@ export class Photo extends RestModel {
|
|||
return $gettext("Unknown");
|
||||
}
|
||||
|
||||
locationInfo() {
|
||||
if (this.PlaceID === "zz" && this.Country !== "zz") {
|
||||
const country = countries.find((c) => c.Code === this.Country);
|
||||
locationInfo = () => {
|
||||
return this.generateLocationInfo(this.PlaceID, this.Country, this.Place, this.PlaceLabel)
|
||||
}
|
||||
|
||||
generateLocationInfo = memoizeOne((placeId, countryCode, place, placeLabel) => {
|
||||
if (placeId === "zz" && countryCode !== "zz") {
|
||||
const country = countries.find((c) => c.Code === countryCode);
|
||||
|
||||
if (country) {
|
||||
return country.Name;
|
||||
}
|
||||
} else if (this.Place && this.Place.Label) {
|
||||
return this.Place.Label;
|
||||
} else if (place && place.Label) {
|
||||
return place.Label;
|
||||
}
|
||||
|
||||
return this.PlaceLabel ? this.PlaceLabel : $gettext("Unknown");
|
||||
}
|
||||
return placeLabel ? placeLabel : $gettext("Unknown");
|
||||
})
|
||||
|
||||
addSizeInfo(file, info) {
|
||||
if (!file) {
|
||||
|
@ -699,18 +737,19 @@ export class Photo extends RestModel {
|
|||
}
|
||||
}
|
||||
|
||||
getVideoInfo() {
|
||||
let info = [];
|
||||
let file = this.videoFile();
|
||||
|
||||
if (!file) {
|
||||
file = this.mainFile();
|
||||
}
|
||||
getVideoInfo = () => {
|
||||
let file = this.videoFile() || this.mainFile();
|
||||
return this.generateVideoInfo(file)
|
||||
}
|
||||
|
||||
generateVideoInfo = memoizeOne((file) => {
|
||||
if (!file) {
|
||||
return $gettext("Video");
|
||||
}
|
||||
|
||||
const info = [];
|
||||
|
||||
|
||||
if (file.Duration > 0) {
|
||||
info.push(Util.duration(file.Duration));
|
||||
}
|
||||
|
@ -726,30 +765,35 @@ export class Photo extends RestModel {
|
|||
}
|
||||
|
||||
return info.join(", ");
|
||||
})
|
||||
|
||||
getPhotoInfo = () => {
|
||||
let file = this.videoFile();
|
||||
if (!file || !file.Width) {
|
||||
file = this.mainFile();
|
||||
}
|
||||
|
||||
return this.generatePhotoInfo(this.Camera, this.CameraModel, this.CameraMake, file);
|
||||
}
|
||||
|
||||
getPhotoInfo() {
|
||||
generatePhotoInfo = memoizeOne((camera, cameraModel, cameraMake, file) => {
|
||||
let info = [];
|
||||
|
||||
if (this.Camera) {
|
||||
if (this.Camera.Model.length > 7) {
|
||||
info.push(this.Camera.Model);
|
||||
if (camera) {
|
||||
if (camera.Model.length > 7) {
|
||||
info.push(camera.Model);
|
||||
} else {
|
||||
info.push(this.Camera.Make + " " + this.Camera.Model);
|
||||
info.push(camera.Make + " " + camera.Model);
|
||||
}
|
||||
} else if (this.CameraModel && this.CameraMake) {
|
||||
if (this.CameraModel.length > 7) {
|
||||
info.push(this.CameraModel);
|
||||
} else if (cameraModel && cameraMake) {
|
||||
if (cameraModel.length > 7) {
|
||||
info.push(cameraModel);
|
||||
} else {
|
||||
info.push(this.CameraMake + " " + this.CameraModel);
|
||||
info.push(cameraMake + " " + cameraModel);
|
||||
}
|
||||
}
|
||||
|
||||
let file = this.videoFile();
|
||||
|
||||
if (!file || !file.Width) {
|
||||
file = this.mainFile();
|
||||
} else if (file.Codec) {
|
||||
if (file && file.Width && file.Codec) {
|
||||
info.push(file.Codec.toUpperCase());
|
||||
}
|
||||
|
||||
|
@ -760,7 +804,7 @@ export class Photo extends RestModel {
|
|||
}
|
||||
|
||||
return info.join(", ");
|
||||
}
|
||||
})
|
||||
|
||||
getCamera() {
|
||||
if (this.Camera) {
|
||||
|
|
Loading…
Add table
Reference in a new issue