Frontend: Refactor clipboard #477

This commit is contained in:
Michael Mayer 2021-01-09 01:33:20 +01:00
parent 39595ee34d
commit ec71858477
13 changed files with 123 additions and 69 deletions

View file

@ -59,7 +59,6 @@ import offline from "offline-plugin/runtime";
// Initialize helpers
const viewer = new Viewer();
const clipboard = new Clipboard(window.localStorage, "photo_clipboard");
const isPublic = config.get("public");
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
navigator.userAgent
@ -83,7 +82,7 @@ Vue.prototype.$api = Api;
Vue.prototype.$log = Log;
Vue.prototype.$socket = Socket;
Vue.prototype.$config = config;
Vue.prototype.$clipboard = clipboard;
Vue.prototype.$clipboard = Clipboard;
Vue.prototype.$isMobile = isMobile;
Vue.prototype.$rtl = rtl;

View file

@ -31,10 +31,11 @@ https://docs.photoprism.org/developer-guide/
import RestModel from "model/rest";
import Notify from "common/notify";
import { $gettext } from "./vm";
import Event from "pubsub-js";
export const MaxItems = 999;
export default class Clipboard {
export class Clipboard {
/**
* @param {Storage} storage
* @param {string} key
@ -83,28 +84,37 @@ export default class Clipboard {
}
const id = model.getId();
this.toggleId(id);
model.Selected = this.toggleId(id);
}
toggleId(id) {
const index = this.selection.indexOf(id);
let result = false;
if (index === -1) {
if (this.selection.length >= this.maxItems) {
Notify.warn($gettext("Can't select more items"));
return;
}
Event.publish("photos.updated", { entities: [{ UID: id, Selected: true }] });
this.selection.push(id);
this.selectionMap["id:" + id] = true;
this.lastId = id;
result = true;
} else {
Event.publish("photos.updated", { entities: [{ UID: id, Selected: false }] });
this.selection.splice(index, 1);
delete this.selectionMap["id:" + id];
this.lastId = "";
}
this.saveToStorage();
return result;
}
add(model) {
@ -114,17 +124,19 @@ export default class Clipboard {
const id = model.getId();
this.addId(id);
model.Selected = this.addId(id);
}
addId(id) {
Event.publish("photos.updated", { entities: [{ UID: id, Selected: true }] });
if (this.hasId(id)) {
return;
return true;
}
if (this.selection.length >= this.maxItems) {
Notify.warn($gettext("Can't select more items"));
return;
return false;
}
this.selection.push(id);
@ -132,6 +144,8 @@ export default class Clipboard {
this.lastId = id;
this.saveToStorage();
return true;
}
addRange(rangeEnd, models) {
@ -177,11 +191,15 @@ export default class Clipboard {
return;
}
this.removeId(model.getId());
model.Selected = this.removeId(model.getId());
}
removeId(id) {
if (!this.hasId(id)) return;
Event.publish("photos.updated", { entities: [{ UID: id, Selected: false }] });
if (!this.hasId(id)) {
return false;
}
const index = this.selection.indexOf(id);
@ -190,6 +208,8 @@ export default class Clipboard {
delete this.selectionMap["id:" + id];
this.saveToStorage();
return false;
}
getIds() {
@ -209,9 +229,19 @@ export default class Clipboard {
}
clear() {
Event.publish("photos.updated", {
entities: this.selection.map((uid) => {
return { UID: uid, Selected: false };
}),
});
this.lastId = "";
this.selectionMap = {};
this.selection.splice(0, this.selection.length);
this.storage.removeItem(this.storageKey);
}
}
const PhotoClipboard = new Clipboard(window.localStorage, "photo_clipboard");
export default PhotoClipboard;

View file

@ -26,30 +26,30 @@
:data-uid="photo.UID"
class="p-photo"
xs12 sm6 md4 lg3 xlg2 xxxl1 d-flex
:class="{ 'is-selected': clipboard.has(photo), portrait: photo.Portrait }"
:class="{ 'is-selected': photo.Selected, portrait: photo.Portrait }"
>
<v-hover>
<v-card slot-scope="{ hover }" tile
:dark="clipboard.has(photo)"
:class="clipboard.has(photo) ? 'elevation-10 ma-0 accent darken-1 white--text' : 'elevation-0 ma-1 accent lighten-3'"
:dark="photo.Selected"
:class="photo.Selected ? 'elevation-10 ma-0 accent darken-1 white--text select-transition' : 'elevation-0 ma-1 accent lighten-3 select-transition'"
@contextmenu="onContextMenu($event, index)">
<v-img :src="photo.thumbnailUrl('tile_500')"
aspect-ratio="1"
:class="{ selected: clipboard.has(photo) }"
:class="{ selected: photo.Selected }"
class="accent lighten-2 clickable"
@mousedown="onMouseDown($event, index)"
@click.stop.prevent="onClick($event, index)"
>
<v-layout
<!-- v-layout
v-if="spinners"
slot="placeholder"
fill-height
align-center
justify-center
ma-0
>
<v-progress-circular indeterminate color="accent lighten-5"></v-progress-circular>
</v-layout>
</v-layout -->
<v-layout
v-if="photo.Type === 'live'"
@ -72,11 +72,11 @@
<v-icon color="white">lock</v-icon>
</v-btn>
<v-btn v-if="hover || selection.length && clipboard.has(photo)" :ripple="false"
<v-btn v-if="hover || photo.Selected" :ripple="false"
icon flat large absolute
:class="selection.length && clipboard.has(photo) ? 'p-photo-select' : 'p-photo-select opacity-50'"
:class="photo.Selected ? 'p-photo-select' : 'p-photo-select opacity-50'"
@click.stop.prevent="onSelect($event, index)">
<v-icon v-if="selection.length && clipboard.has(photo)" color="white"
<v-icon v-if="photo.Selected" color="white"
class="t-select t-on">check_circle
</v-icon>
<v-icon v-else color="accent lighten-3" class="t-select t-off">radio_button_off</v-icon>
@ -110,7 +110,7 @@
@click.stop.prevent="openPhoto(index, true)">
<v-icon color="white" class="action-burst">burst_mode</v-icon>
</v-btn>
<v-btn v-else-if="photo.Type === 'image' && selection.length && hover" :ripple="false"
<v-btn v-else-if="photo.Type === 'image' && selectMode && hover" :ripple="false"
icon flat large absolute class="p-photo-fullscreen opacity-75"
@click.stop.prevent="openPhoto(index, false)">
<v-icon color="white" class="action-open">zoom_in</v-icon>
@ -207,10 +207,11 @@ export default {
album: Object,
filter: Object,
context: String,
selectMode: Boolean,
},
data() {
return {
clipboard: this.$clipboard,
spinners: false,
showLocation: this.$config.settings().features.places,
hidePrivate: this.$config.settings().features.private,
debug: this.$config.get('debug'),
@ -246,7 +247,7 @@ export default {
onClick(ev, index) {
let longClick = (this.mouseDown.index === index && ev.timeStamp - this.mouseDown.timeStamp > 400);
if (longClick || this.selection.length > 0) {
if (longClick || this.selectMode) {
if (longClick || ev.shiftKey) {
this.selectRange(index);
} else {

View file

@ -39,7 +39,8 @@
@contextmenu="onContextMenu($event, props.index)"
@click.stop.prevent="onClick($event, props.index)"
>
<v-layout
<!-- v-layout
v-if="spinners"
slot="placeholder"
fill-height
align-center
@ -48,13 +49,13 @@
>
<v-progress-circular indeterminate
color="accent lighten-5"></v-progress-circular>
</v-layout>
</v-layout -->
<v-btn v-if="selection.length && clipboard.has(props.item)" :ripple="false"
<v-btn v-if="props.item.Selected" :ripple="false"
flat icon large absolute class="p-photo-select">
<v-icon color="white" class="t-select t-on">check_circle</v-icon>
</v-btn>
<v-btn v-else-if="!selection.length && (props.item.Type === 'video' || props.item.Type === 'live')"
<v-btn v-else-if="!selectMode && (props.item.Type === 'video' || props.item.Type === 'live')"
:ripple="false"
flat icon large absolute class="p-photo-play opacity-75"
@click.stop.prevent="openPhoto(props.index, true)">
@ -112,13 +113,13 @@ export default {
name: 'PPhotoList',
props: {
photos: Array,
selection: Array,
openPhoto: Function,
editPhoto: Function,
openLocation: Function,
album: Object,
filter: Object,
context: String,
selectMode: Boolean,
},
data() {
let m = this.$gettext("Couldn't find anything.");
@ -132,7 +133,7 @@ export default {
let showName = this.filter.order === 'name';
return {
clipboard: this.$clipboard,
spinners: false,
config: this.$config.values,
notFoundMessage: m,
'selected': [],
@ -199,7 +200,7 @@ export default {
onClick(ev, index) {
let longClick = (this.mouseDown.index === index && ev.timeStamp - this.mouseDown.timeStamp > 400);
if (longClick || this.selection.length > 0) {
if (longClick || this.selectMode) {
if (longClick || ev.shiftKey) {
this.selectRange(index);
} else {

View file

@ -24,13 +24,13 @@
v-for="(photo, index) in photos"
:key="index"
:data-uid="photo.UID"
:class="{ selected: $clipboard.has(photo), portrait: photo.Portrait }"
:class="{ selected: photo.Selected, portrait: photo.Portrait }"
class="p-photo"
xs4 sm3 md2 lg1 d-flex
>
<v-hover>
<v-card slot-scope="{ hover }" tile
:class="$clipboard.has(photo) ? 'elevation-10 ma-0' : 'elevation-0 ma-1'"
:class="photo.Selected ? 'elevation-10 ma-0 select-transition' : 'elevation-0 ma-1 select-transition'"
:title="photo.Title"
@contextmenu="onContextMenu($event, index)">
<v-img :src="photo.thumbnailUrl('tile_224')"
@ -39,7 +39,8 @@
@mousedown="onMouseDown($event, index)"
@click.stop.prevent="onClick($event, index)"
>
<v-layout
<!-- v-layout
v-if="spinners"
slot="placeholder"
fill-height
align-center
@ -48,7 +49,7 @@
>
<v-progress-circular indeterminate
color="accent lighten-5"></v-progress-circular>
</v-layout>
</v-layout -->
<v-layout
v-if="photo.Type === 'live'"
@ -71,11 +72,11 @@
<v-icon color="white">lock</v-icon>
</v-btn>
<v-btn v-if="hover || selection.length && $clipboard.has(photo)" :ripple="false"
<v-btn v-if="hover || photo.Selected" :ripple="false"
icon flat small absolute
:class="selection.length && $clipboard.has(photo) ? 'p-photo-select' : 'p-photo-select opacity-50'"
:class="photo.Selected ? 'p-photo-select' : 'p-photo-select opacity-50'"
@click.stop.prevent="onSelect($event, index)">
<v-icon v-if="selection.length && $clipboard.has(photo)" color="white"
<v-icon v-if="photo.Selected" color="white"
class="t-select t-on">check_circle
</v-icon>
<v-icon v-else color="accent lighten-3" class="t-select t-off">radio_button_off</v-icon>
@ -106,7 +107,7 @@
@click.stop.prevent="openPhoto(index, true)">
<v-icon color="white" class="action-burst">burst_mode</v-icon>
</v-btn>
<v-btn v-else-if="photo.Type === 'image' && selection.length && hover" :ripple="false"
<v-btn v-else-if="photo.Type === 'image' && selectMode && hover" :ripple="false"
icon flat small absolute class="p-photo-fullscreen opacity-75"
@click.stop.prevent="openPhoto(index, false)">
<v-icon color="white" class="action-open">zoom_in</v-icon>
@ -128,15 +129,16 @@ export default {
name: 'PPhotoMosaic',
props: {
photos: Array,
selection: Array,
openPhoto: Function,
editPhoto: Function,
album: Object,
filter: Object,
context: String,
selectMode: Boolean,
},
data() {
return {
spinners: false,
hidePrivate: this.$config.settings().features.private,
mouseDown: {
index: -1,
@ -159,7 +161,7 @@ export default {
onClick(ev, index) {
let longClick = (this.mouseDown.index === index && ev.timeStamp - this.mouseDown.timeStamp > 400);
if (longClick || this.selection.length > 0) {
if (longClick || this.selectMode) {
if (longClick || ev.shiftKey) {
this.selectRange(index);
} else {

View file

@ -92,3 +92,10 @@
#photoprism .animate-stretch {
animation: stretch 1.5s ease-out 0s alternate infinite none running;
}
#photoprism .select-transition {
-webkit-transition-duration: 15ms !important;
-moz-transition-duration: 15ms !important;
-o-transition-duration: 15ms !important;
transition-duration: 15ms !important;
}

View file

@ -36,6 +36,7 @@ import Util from "common/util";
import { config } from "../session";
import countries from "options/countries.json";
import { $gettext } from "common/vm";
import Clipboard from "common/clipboard";
export const SrcManual = "manual";
export const CodecAvc1 = "avc1";
@ -51,8 +52,14 @@ export const MonthUnknown = -1;
export const DayUnknown = -1;
export class Photo extends RestModel {
constructor(values) {
super(values);
this.Selected = Clipboard.has(this);
}
getDefaults() {
return {
Selected: false,
UID: "",
DocumentID: "",
Type: TypeImage,

View file

@ -16,14 +16,14 @@
<p-photo-mosaic v-if="settings.view === 'mosaic'"
:context="context"
:photos="results"
:selection="selection"
:select-mode="selectMode"
:filter="filter"
:edit-photo="editPhoto"
:open-photo="openPhoto"></p-photo-mosaic>
<p-photo-list v-else-if="settings.view === 'list'"
:context="context"
:photos="results"
:selection="selection"
:select-mode="selectMode"
:filter="filter"
:open-photo="openPhoto"
:edit-photo="editPhoto"
@ -31,7 +31,7 @@
<p-photo-cards v-else
:context="context"
:photos="results"
:selection="selection"
:select-mode="selectMode"
:filter="filter"
:open-photo="openPhoto"
:edit-photo="editPhoto"
@ -108,6 +108,9 @@ export default {
};
},
computed: {
selectMode: function() {
return this.selection.length > 0;
},
context: function () {
if (!this.staticFilter) {
return "photos";

View file

@ -58,7 +58,6 @@ import * as options from "./options/options";
// Initialize helpers
const viewer = new Viewer();
const clipboard = new Clipboard(window.localStorage, "photo_clipboard");
const isPublic = config.get("public");
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
navigator.userAgent
@ -82,7 +81,7 @@ Vue.prototype.$api = Api;
Vue.prototype.$log = Log;
Vue.prototype.$socket = Socket;
Vue.prototype.$config = config;
Vue.prototype.$clipboard = clipboard;
Vue.prototype.$clipboard = Clipboard;
Vue.prototype.$isMobile = isMobile;
Vue.prototype.$rtl = rtl;

View file

@ -22,16 +22,16 @@
:data-uid="photo.UID"
class="p-photo"
xs12 sm6 md4 lg3 xlg2 xxxl1 d-flex
:class="{ 'is-selected': $clipboard.has(photo), portrait: photo.Portrait }"
:class="{ 'is-selected': photo.Selected, portrait: photo.Portrait }"
>
<v-hover>
<v-card slot-scope="{ hover }" tile
:dark="$clipboard.has(photo)"
:class="$clipboard.has(photo) ? 'elevation-10 ma-0 accent darken-1 white--text' : 'elevation-0 ma-1 accent lighten-3'"
:dark="photo.Selected"
:class="photo.Selected ? 'elevation-10 ma-0 accent darken-1 white--text select-transition' : 'elevation-0 ma-1 accent lighten-3 select-transition'"
@contextmenu="onContextMenu($event, index)">
<v-img :src="photo.thumbnailUrl('tile_500')"
aspect-ratio="1"
:class="{ selected: $clipboard.has(photo) }"
:class="{ selected: photo.Selected }"
class="accent lighten-2 clickable"
@mousedown="onMouseDown($event, index)"
@click.stop.prevent="onClick($event, index)"
@ -68,11 +68,11 @@
<v-icon color="white">lock</v-icon>
</v-btn>
<v-btn v-if="hover || selection.length && $clipboard.has(photo)" :ripple="false"
<v-btn v-if="hover || photo.Selected" :ripple="false"
icon flat large absolute
:class="selection.length && $clipboard.has(photo) ? 'p-photo-select' : 'p-photo-select opacity-50'"
:class="photo.Selected ? 'p-photo-select' : 'p-photo-select opacity-50'"
@click.stop.prevent="onSelect($event, index)">
<v-icon v-if="selection.length && $clipboard.has(photo)" color="white"
<v-icon v-if="photo.Selected" color="white"
class="t-select t-on">check_circle
</v-icon>
<v-icon v-else color="accent lighten-3" class="t-select t-off">radio_button_off</v-icon>
@ -106,7 +106,7 @@
@click.stop.prevent="openPhoto(index, true)">
<v-icon color="white" class="action-burst">burst_mode</v-icon>
</v-btn>
<v-btn v-else-if="photo.Type === 'image' && selection.length && hover" :ripple="false"
<v-btn v-else-if="photo.Type === 'image' && selectMode && hover" :ripple="false"
icon flat large absolute class="p-photo-merged opacity-75"
@click.stop.prevent="openPhoto(index, false)">
<v-icon color="white" class="action-open">zoom_in</v-icon>
@ -162,12 +162,12 @@ export default {
name: 'PPhotoCards',
props: {
photos: Array,
selection: Array,
openPhoto: Function,
editPhoto: Function,
openLocation: Function,
album: Object,
filter: Object,
selectMode: Boolean,
},
data() {
return {
@ -202,7 +202,7 @@ export default {
onClick(ev, index) {
let longClick = (this.mouseDown.index === index && ev.timeStamp - this.mouseDown.timeStamp > 400);
if (longClick || this.selection.length > 0) {
if (longClick || this.selectMode) {
if (longClick || ev.shiftKey) {
this.selectRange(index);
} else {

View file

@ -46,11 +46,11 @@
color="accent lighten-5"></v-progress-circular>
</v-layout>
<v-btn v-if="selection.length && $clipboard.has(props.item)" :ripple="false"
<v-btn v-if="props.item.Selected" :ripple="false"
flat icon large absolute class="p-photo-select">
<v-icon color="white" class="t-select t-on">check_circle</v-icon>
</v-btn>
<v-btn v-else-if="!selection.length && (props.item.Type === 'video' || props.item.Type === 'live')"
<v-btn v-else-if="!selectMode && (props.item.Type === 'video' || props.item.Type === 'live')"
:ripple="false"
flat icon large absolute class="p-photo-play opacity-75"
@click.stop.prevent="openPhoto(props.index, true)">
@ -93,12 +93,12 @@ export default {
name: 'PPhotoList',
props: {
photos: Array,
selection: Array,
openPhoto: Function,
editPhoto: Function,
openLocation: Function,
album: Object,
filter: Object,
selectMode: Boolean,
},
data() {
let m = this.$gettext("Couldn't find anything.");
@ -172,7 +172,7 @@ export default {
onClick(ev, index) {
let longClick = (this.mouseDown.index === index && ev.timeStamp - this.mouseDown.timeStamp > 400);
if (longClick || this.selection.length > 0) {
if (longClick || this.selectMode) {
if (longClick || ev.shiftKey) {
this.selectRange(index);
} else {

View file

@ -20,13 +20,13 @@
v-for="(photo, index) in photos"
:key="index"
:data-uid="photo.UID"
:class="{ selected: $clipboard.has(photo), portrait: photo.Portrait }"
:class="{ selected: photo.Selected, portrait: photo.Portrait }"
class="p-photo"
xs4 sm3 md2 lg1 d-flex
>
<v-hover>
<v-card slot-scope="{ hover }" tile
:class="$clipboard.has(photo) ? 'elevation-10 ma-0' : 'elevation-0 ma-1'"
:class="photo.Selected ? 'elevation-10 ma-0 select-transition' : 'elevation-0 ma-1 select-transition'"
:title="photo.Title"
@contextmenu="onContextMenu($event, index)">
<v-img :src="photo.thumbnailUrl('tile_224')"
@ -67,11 +67,11 @@
<v-icon color="white">lock</v-icon>
</v-btn>
<v-btn v-if="hover || selection.length && $clipboard.has(photo)" :ripple="false"
<v-btn v-if="hover || photo.Selected" :ripple="false"
icon flat small absolute
:class="selection.length && $clipboard.has(photo) ? 'p-photo-select' : 'p-photo-select opacity-50'"
:class="photo.Selected ? 'p-photo-select' : 'p-photo-select opacity-50'"
@click.stop.prevent="onSelect($event, index)">
<v-icon v-if="selection.length && $clipboard.has(photo)" color="white"
<v-icon v-if="photo.Selected" color="white"
class="t-select t-on">check_circle
</v-icon>
<v-icon v-else color="accent lighten-3" class="t-select t-off">radio_button_off</v-icon>
@ -101,7 +101,7 @@
@click.stop.prevent="openPhoto(index, true)">
<v-icon color="white" class="action-burst">burst_mode</v-icon>
</v-btn>
<v-btn v-else-if="photo.Type === 'image' && selection.length && hover" :ripple="false"
<v-btn v-else-if="photo.Type === 'image' && selectMode && hover" :ripple="false"
icon flat small absolute class="p-photo-merged opacity-75"
@click.stop.prevent="openPhoto(index, false)">
<v-icon color="white" class="action-open">zoom_in</v-icon>
@ -123,11 +123,11 @@ export default {
name: 'PPhotoMosaic',
props: {
photos: Array,
selection: Array,
openPhoto: Function,
editPhoto: Function,
album: Object,
filter: Object,
selectMode: Boolean,
},
data() {
return {
@ -153,7 +153,7 @@ export default {
onClick(ev, index) {
let longClick = (this.mouseDown.index === index && ev.timeStamp - this.mouseDown.timeStamp > 400);
if (longClick || this.selection.length > 0) {
if (longClick || this.selectMode) {
if (longClick || ev.shiftKey) {
this.selectRange(index);
} else {

View file

@ -60,14 +60,14 @@
<p-photo-mosaic v-if="settings.view === 'mosaic'"
:photos="results"
:selection="selection"
:select-mode="selectMode"
:filter="filter"
:album="model"
:edit-photo="editPhoto"
:open-photo="openPhoto"></p-photo-mosaic>
<p-photo-list v-else-if="settings.view === 'list'"
:photos="results"
:selection="selection"
:select-mode="selectMode"
:filter="filter"
:album="model"
:open-photo="openPhoto"
@ -75,7 +75,7 @@
:open-location="openLocation"></p-photo-list>
<p-photo-cards v-else
:photos="results"
:selection="selection"
:select-mode="selectMode"
:filter="filter"
:album="model"
:open-photo="openPhoto"
@ -134,6 +134,11 @@ export default {
},
};
},
computed: {
selectMode: function() {
return this.$clipboard.selection.length > 0;
},
},
watch: {
'$route'() {
const query = this.$route.query;