Implement drag selection (#28)
parent
60501b5d58
commit
604b5e54b7
|
@ -65,6 +65,8 @@ import {
|
|||
import { getCurrentUser } from "@nextcloud/auth";
|
||||
|
||||
import * as dav from "../services/DavRequests";
|
||||
import * as utils from "../services/Utils";
|
||||
|
||||
import EditDate from "./modal/EditDate.vue";
|
||||
import FaceMoveModal from "./modal/FaceMoveModal.vue";
|
||||
import AddToAlbumModal from "./modal/AddToAlbumModal.vue";
|
||||
|
@ -97,6 +99,9 @@ type Selection = Map<number, IPhoto>;
|
|||
export default class SelectionManager extends Mixins(GlobalMixin, UserConfig) {
|
||||
@Prop() public heads: { [dayid: number]: IHeadRow };
|
||||
|
||||
/** List of rows for multi selection */
|
||||
@Prop() public rows: IRow[];
|
||||
|
||||
/** Rows are in ascending order (desc is normal) */
|
||||
@Prop() public isreverse: boolean;
|
||||
|
||||
|
@ -105,6 +110,11 @@ export default class SelectionManager extends Mixins(GlobalMixin, UserConfig) {
|
|||
private readonly selection!: Selection;
|
||||
private readonly defaultActions: ISelectionAction[];
|
||||
|
||||
private touchAnchor: IPhoto = null;
|
||||
private touchTimer: number = 0;
|
||||
private touchPrevSel!: Selection;
|
||||
private prevOver!: IPhoto;
|
||||
|
||||
@Emit("refresh")
|
||||
refresh() {}
|
||||
|
||||
|
@ -197,6 +207,26 @@ export default class SelectionManager extends Mixins(GlobalMixin, UserConfig) {
|
|||
};
|
||||
}
|
||||
|
||||
/** Archive is not allowed only on folder routes */
|
||||
private allowArchive() {
|
||||
return this.$route.name !== "folders";
|
||||
}
|
||||
|
||||
/** Is archive route */
|
||||
private routeIsArchive() {
|
||||
return this.$route.name === "archive";
|
||||
}
|
||||
|
||||
/** Is album route */
|
||||
private routeIsAlbum() {
|
||||
return this.config_albumsEnabled && this.$route.name === "albums";
|
||||
}
|
||||
|
||||
/** Public route that can't modify anything */
|
||||
private routeIsPublic() {
|
||||
return this.$route.name === "folder-share";
|
||||
}
|
||||
|
||||
@Watch("show")
|
||||
onShowChange() {
|
||||
const klass = "has-top-bar";
|
||||
|
@ -207,6 +237,7 @@ export default class SelectionManager extends Mixins(GlobalMixin, UserConfig) {
|
|||
}
|
||||
}
|
||||
|
||||
/** Trigger to update props from selection set */
|
||||
private selectionChanged() {
|
||||
this.show = this.selection.size > 0;
|
||||
this.size = this.selection.size;
|
||||
|
@ -220,37 +251,11 @@ export default class SelectionManager extends Mixins(GlobalMixin, UserConfig) {
|
|||
return this.selection.has(fileid);
|
||||
}
|
||||
|
||||
/** Restore selections from new day object */
|
||||
public restoreDay(day: IDay) {
|
||||
if (!this.has()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// FileID => Photo for new day
|
||||
const dayMap = new Map<number, IPhoto>();
|
||||
day.detail.forEach((photo) => {
|
||||
dayMap.set(photo.fileid, photo);
|
||||
});
|
||||
|
||||
this.selection.forEach((photo, fileid) => {
|
||||
// Process this day only
|
||||
if (photo.dayid !== day.dayid) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove all selections that are not in the new day
|
||||
if (!dayMap.has(fileid)) {
|
||||
this.selection.delete(fileid);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the photo object
|
||||
const newPhoto = dayMap.get(fileid);
|
||||
this.selection.set(fileid, newPhoto);
|
||||
newPhoto.flag |= this.c.FLAG_SELECTED;
|
||||
});
|
||||
|
||||
this.selectionChanged();
|
||||
/** Get the actions list */
|
||||
private getActions(): ISelectionAction[] {
|
||||
return this.defaultActions.filter(
|
||||
(a) => (!a.if || a.if(this)) && (!this.routeIsPublic() || a.allowPublic)
|
||||
);
|
||||
}
|
||||
|
||||
/** Click on an action */
|
||||
|
@ -265,43 +270,145 @@ export default class SelectionManager extends Mixins(GlobalMixin, UserConfig) {
|
|||
}
|
||||
}
|
||||
|
||||
/** Get the actions list */
|
||||
private getActions(): ISelectionAction[] {
|
||||
return this.defaultActions.filter(
|
||||
(a) => (!a.if || a.if(this)) && (!this.routeIsPublic() || a.allowPublic)
|
||||
);
|
||||
/** Clicking on photo */
|
||||
public clickPhoto(photo: IPhoto, event: any, rowIdx: number) {
|
||||
if (photo.flag & this.c.FLAG_PLACEHOLDER) return;
|
||||
|
||||
if (this.has()) {
|
||||
if (event.shiftKey) {
|
||||
this.selectMulti(photo, this.rows, rowIdx);
|
||||
} else {
|
||||
this.selectPhoto(photo);
|
||||
}
|
||||
} else {
|
||||
this.openViewer(photo);
|
||||
}
|
||||
}
|
||||
|
||||
/** Tap on */
|
||||
public touchstartPhoto(photo: IPhoto, event: any, rowIdx: number) {
|
||||
if (photo.flag & this.c.FLAG_PLACEHOLDER) return;
|
||||
|
||||
this.touchAnchor = photo;
|
||||
this.prevOver = photo;
|
||||
this.touchPrevSel = new Map(this.selection);
|
||||
this.touchTimer = window.setTimeout(() => {
|
||||
if (this.touchAnchor === photo) {
|
||||
this.selectPhoto(photo, true);
|
||||
}
|
||||
this.touchTimer = 0;
|
||||
}, 600);
|
||||
}
|
||||
|
||||
/** Tap off */
|
||||
public touchendPhoto(photo: IPhoto, event: any, rowIdx: number) {
|
||||
if (photo.flag & this.c.FLAG_PLACEHOLDER) return;
|
||||
window.clearTimeout(this.touchTimer);
|
||||
this.touchTimer = 0;
|
||||
this.touchAnchor = null;
|
||||
this.prevOver = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tap over
|
||||
* photo and rowIdx are that of the *anchor*
|
||||
*/
|
||||
public touchmovePhoto(anchor: IPhoto, event: any, rowIdx: number) {
|
||||
if (anchor.flag & this.c.FLAG_PLACEHOLDER) return;
|
||||
|
||||
if (this.touchTimer) {
|
||||
// Touch is not held, just cancel
|
||||
window.clearTimeout(this.touchTimer);
|
||||
this.touchTimer = 0;
|
||||
this.touchAnchor = null;
|
||||
return;
|
||||
} else if (!this.touchAnchor) {
|
||||
// Touch was previously cancelled
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent scrolling
|
||||
event.preventDefault();
|
||||
|
||||
// Use first touch -- can't do much better yet
|
||||
const touch: Touch = event.touches[0];
|
||||
if (!touch) return;
|
||||
|
||||
// Which photo is the cursor over, if any
|
||||
const elems = document.elementsFromPoint(touch.clientX, touch.clientY);
|
||||
const photoComp: any = elems.find((e) => e.classList.contains("p-outer"));
|
||||
let overPhoto: IPhoto = photoComp?.__vue__?.data;
|
||||
if (overPhoto && overPhoto.flag & this.c.FLAG_PLACEHOLDER) overPhoto = null;
|
||||
|
||||
// Do multi-selection "till" overPhoto "from" anchor
|
||||
// This logic is completely different from the desktop because of the
|
||||
// existence of a definitive "anchor" element. We just need to find
|
||||
// rverything between the anchor and the current photo
|
||||
if (overPhoto && this.prevOver !== overPhoto) {
|
||||
this.prevOver = overPhoto;
|
||||
|
||||
// days reverse XOR rows reverse
|
||||
let reverse: boolean;
|
||||
if (overPhoto.dayid === this.touchAnchor.dayid) {
|
||||
const l = overPhoto.d.detail;
|
||||
const ai = l.indexOf(this.touchAnchor);
|
||||
const oi = l.indexOf(overPhoto);
|
||||
if (ai === -1 || oi === -1) return; // Shouldn't happen
|
||||
reverse = ai > oi;
|
||||
} else {
|
||||
reverse = overPhoto.dayid > this.touchAnchor.dayid != this.isreverse;
|
||||
}
|
||||
|
||||
const newSelection = new Map(this.touchPrevSel);
|
||||
const updatedDays = new Set<number>();
|
||||
|
||||
// Walk over rows
|
||||
let i = rowIdx;
|
||||
let j = this.rows[i].photos.indexOf(this.touchAnchor);
|
||||
while (true) {
|
||||
let p = this.rows[i]?.photos?.[j];
|
||||
if (!p) break; // shouldn't happen, ever
|
||||
|
||||
j += reverse ? -1 : 1;
|
||||
if (j < 0) {
|
||||
while (!this.rows[--i].photos);
|
||||
j = this.rows[i].photos.length - 1;
|
||||
} else if (j >= this.rows[i].photos.length) {
|
||||
while (!this.rows[++i].photos);
|
||||
j = 0;
|
||||
}
|
||||
|
||||
// This is there now
|
||||
newSelection.set(p.fileid, p);
|
||||
|
||||
// Perf: only update heads if not selected
|
||||
if (!(p.flag & this.c.FLAG_SELECTED)) {
|
||||
this.selectPhoto(p, true, true);
|
||||
updatedDays.add(p.dayid);
|
||||
}
|
||||
|
||||
// We're trying to update too much -- something went wrong
|
||||
if (newSelection.size - this.selection.size > 50) break;
|
||||
|
||||
// Check goal
|
||||
if (p === overPhoto) break;
|
||||
}
|
||||
|
||||
// Remove unselected
|
||||
for (const [fileid, p] of this.selection) {
|
||||
if (!newSelection.has(fileid)) {
|
||||
this.selectPhoto(p, false, true);
|
||||
updatedDays.add(p.dayid);
|
||||
}
|
||||
}
|
||||
|
||||
// Update heads
|
||||
for (const dayid of updatedDays) {
|
||||
this.updateHeadSelected(this.heads[dayid]);
|
||||
}
|
||||
|
||||
/** Clear all selected photos */
|
||||
public clearSelection(only?: IPhoto[]) {
|
||||
const heads = new Set<IHeadRow>();
|
||||
const toClear = only || this.selection.values();
|
||||
Array.from(toClear).forEach((photo: IPhoto) => {
|
||||
photo.flag &= ~this.c.FLAG_SELECTED;
|
||||
heads.add(this.heads[photo.d.dayid]);
|
||||
this.selection.delete(photo.fileid);
|
||||
this.selectionChanged();
|
||||
});
|
||||
heads.forEach(this.updateHeadSelected);
|
||||
this.$forceUpdate();
|
||||
}
|
||||
|
||||
/** Check if the day for a photo is selected entirely */
|
||||
private updateHeadSelected(head: IHeadRow) {
|
||||
let selected = true;
|
||||
|
||||
// Check if all photos are selected
|
||||
for (const row of head.day.rows) {
|
||||
for (const photo of row.photos) {
|
||||
if (!(photo.flag & this.c.FLAG_SELECTED)) {
|
||||
selected = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update head
|
||||
head.selected = selected;
|
||||
}
|
||||
|
||||
/** Add a photo to selection list */
|
||||
|
@ -339,7 +446,6 @@ export default class SelectionManager extends Mixins(GlobalMixin, UserConfig) {
|
|||
|
||||
/** Multi-select */
|
||||
public selectMulti(photo: IPhoto, rows: IRow[], rowIdx: number) {
|
||||
console.log("selectMulti", photo, rows, rowIdx);
|
||||
const pRow = rows[rowIdx];
|
||||
const pIdx = pRow.photos.indexOf(photo);
|
||||
if (pIdx === -1) return;
|
||||
|
@ -407,6 +513,71 @@ export default class SelectionManager extends Mixins(GlobalMixin, UserConfig) {
|
|||
this.$forceUpdate();
|
||||
}
|
||||
|
||||
/** Check if the day for a photo is selected entirely */
|
||||
private updateHeadSelected(head: IHeadRow) {
|
||||
let selected = true;
|
||||
|
||||
// Check if all photos are selected
|
||||
for (const row of head.day.rows) {
|
||||
for (const photo of row.photos) {
|
||||
if (!(photo.flag & this.c.FLAG_SELECTED)) {
|
||||
selected = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update head
|
||||
head.selected = selected;
|
||||
}
|
||||
|
||||
/** Clear all selected photos */
|
||||
public clearSelection(only?: IPhoto[]) {
|
||||
const heads = new Set<IHeadRow>();
|
||||
const toClear = only || this.selection.values();
|
||||
Array.from(toClear).forEach((photo: IPhoto) => {
|
||||
photo.flag &= ~this.c.FLAG_SELECTED;
|
||||
heads.add(this.heads[photo.d.dayid]);
|
||||
this.selection.delete(photo.fileid);
|
||||
this.selectionChanged();
|
||||
});
|
||||
heads.forEach(this.updateHeadSelected);
|
||||
this.$forceUpdate();
|
||||
}
|
||||
|
||||
/** Restore selections from new day object */
|
||||
public restoreDay(day: IDay) {
|
||||
if (!this.has()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// FileID => Photo for new day
|
||||
const dayMap = new Map<number, IPhoto>();
|
||||
day.detail.forEach((photo) => {
|
||||
dayMap.set(photo.fileid, photo);
|
||||
});
|
||||
|
||||
this.selection.forEach((photo, fileid) => {
|
||||
// Process this day only
|
||||
if (photo.dayid !== day.dayid) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove all selections that are not in the new day
|
||||
if (!dayMap.has(fileid)) {
|
||||
this.selection.delete(fileid);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the photo object
|
||||
const newPhoto = dayMap.get(fileid);
|
||||
this.selection.set(fileid, newPhoto);
|
||||
newPhoto.flag |= this.c.FLAG_SELECTED;
|
||||
});
|
||||
|
||||
this.selectionChanged();
|
||||
}
|
||||
|
||||
/**
|
||||
* Download the currently selected files
|
||||
*/
|
||||
|
@ -521,26 +692,6 @@ export default class SelectionManager extends Mixins(GlobalMixin, UserConfig) {
|
|||
}
|
||||
}
|
||||
|
||||
/** Archive is not allowed only on folder routes */
|
||||
private allowArchive() {
|
||||
return this.$route.name !== "folders";
|
||||
}
|
||||
|
||||
/** Is archive route */
|
||||
private routeIsArchive() {
|
||||
return this.$route.name === "archive";
|
||||
}
|
||||
|
||||
/** Is album route */
|
||||
private routeIsAlbum() {
|
||||
return this.config_albumsEnabled && this.$route.name === "albums";
|
||||
}
|
||||
|
||||
/** Public route that can't modify anything */
|
||||
private routeIsPublic() {
|
||||
return this.$route.name === "folder-share";
|
||||
}
|
||||
|
||||
/**
|
||||
* Move selected photos to album
|
||||
*/
|
||||
|
@ -623,6 +774,14 @@ export default class SelectionManager extends Mixins(GlobalMixin, UserConfig) {
|
|||
this.deletePhotos(delPhotos);
|
||||
}
|
||||
}
|
||||
|
||||
/** Open viewer with given photo */
|
||||
private openViewer(photo: IPhoto) {
|
||||
this.$router.push({
|
||||
...this.$route,
|
||||
hash: utils.getViewerHash(photo),
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
@ -98,7 +98,12 @@
|
|||
:day="item.day"
|
||||
:key="photo.fileid"
|
||||
@select="selectionManager.selectPhoto"
|
||||
@click="clickPhoto(photo, $event, index)"
|
||||
@mousedown="selectionManager.clickPhoto(photo, $event, index)"
|
||||
@touchstart="
|
||||
selectionManager.touchstartPhoto(photo, $event, index)
|
||||
"
|
||||
@touchend="selectionManager.touchendPhoto(photo, $event, index)"
|
||||
@touchmove="selectionManager.touchmovePhoto(photo, $event, index)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -117,6 +122,7 @@
|
|||
<SelectionManager
|
||||
ref="selectionManager"
|
||||
:heads="heads"
|
||||
:rows="list"
|
||||
:isreverse="isMonthView"
|
||||
@refresh="softRefresh"
|
||||
@delete="deleteFromViewWithAnimation"
|
||||
|
@ -1193,24 +1199,6 @@ export default class Timeline extends Mixins(GlobalMixin, UserConfig) {
|
|||
return row;
|
||||
}
|
||||
|
||||
/** Clicking on photo */
|
||||
clickPhoto(photo: IPhoto, event: any, rowIdx: number) {
|
||||
if (photo.flag & this.c.FLAG_PLACEHOLDER) return;
|
||||
|
||||
if (this.selectionManager.has()) {
|
||||
if (event.shiftKey) {
|
||||
this.selectionManager.selectMulti(photo, this.list, rowIdx);
|
||||
} else {
|
||||
this.selectionManager.selectPhoto(photo);
|
||||
}
|
||||
} else {
|
||||
this.$router.push({
|
||||
...this.$route,
|
||||
hash: utils.getViewerHash(photo),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete elements from main view with some animation
|
||||
*
|
||||
|
|
|
@ -28,11 +28,11 @@
|
|||
<div
|
||||
class="img-outer fill-block"
|
||||
@contextmenu="contextmenu"
|
||||
@mousedown.passive="emitClick"
|
||||
@touchstart.passive="touchstart"
|
||||
@touchmove.passive="touchend"
|
||||
@touchend.passive="touchend"
|
||||
@touchcancel.passive="touchend"
|
||||
@mousedown="$emit('mousedown', $event)"
|
||||
@touchstart.passive="$emit('touchstart', $event)"
|
||||
@touchmove="$emit('touchmove', $event)"
|
||||
@touchend.passive="$emit('touchend', $event)"
|
||||
@touchcancel.passive="$emit('touchend', $event)"
|
||||
>
|
||||
<img
|
||||
ref="img"
|
||||
|
@ -78,7 +78,6 @@ export default class Photo extends Mixins(GlobalMixin) {
|
|||
@Prop() day: IDay;
|
||||
|
||||
@Emit("select") emitSelect(data: IPhoto) {}
|
||||
@Emit("click") emitClick() {}
|
||||
|
||||
@Watch("data")
|
||||
onDataChange(newData: IPhoto, oldData: IPhoto) {
|
||||
|
@ -210,10 +209,10 @@ export default class Photo extends Mixins(GlobalMixin) {
|
|||
|
||||
contextmenu(e: Event) {
|
||||
// on mobile only
|
||||
if (this.hasTouch) {
|
||||
// if (this.hasTouch) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
// }
|
||||
}
|
||||
|
||||
touchend() {
|
||||
|
|
Loading…
Reference in New Issue