Implement drag selection (#28)

pull/221/head
Varun Patil 2022-11-11 22:28:58 -08:00
parent 60501b5d58
commit 604b5e54b7
3 changed files with 260 additions and 114 deletions

View File

@ -65,6 +65,8 @@ import {
import { getCurrentUser } from "@nextcloud/auth"; import { getCurrentUser } from "@nextcloud/auth";
import * as dav from "../services/DavRequests"; import * as dav from "../services/DavRequests";
import * as utils from "../services/Utils";
import EditDate from "./modal/EditDate.vue"; import EditDate from "./modal/EditDate.vue";
import FaceMoveModal from "./modal/FaceMoveModal.vue"; import FaceMoveModal from "./modal/FaceMoveModal.vue";
import AddToAlbumModal from "./modal/AddToAlbumModal.vue"; import AddToAlbumModal from "./modal/AddToAlbumModal.vue";
@ -97,6 +99,9 @@ type Selection = Map<number, IPhoto>;
export default class SelectionManager extends Mixins(GlobalMixin, UserConfig) { export default class SelectionManager extends Mixins(GlobalMixin, UserConfig) {
@Prop() public heads: { [dayid: number]: IHeadRow }; @Prop() public heads: { [dayid: number]: IHeadRow };
/** List of rows for multi selection */
@Prop() public rows: IRow[];
/** Rows are in ascending order (desc is normal) */ /** Rows are in ascending order (desc is normal) */
@Prop() public isreverse: boolean; @Prop() public isreverse: boolean;
@ -105,6 +110,11 @@ export default class SelectionManager extends Mixins(GlobalMixin, UserConfig) {
private readonly selection!: Selection; private readonly selection!: Selection;
private readonly defaultActions: ISelectionAction[]; private readonly defaultActions: ISelectionAction[];
private touchAnchor: IPhoto = null;
private touchTimer: number = 0;
private touchPrevSel!: Selection;
private prevOver!: IPhoto;
@Emit("refresh") @Emit("refresh")
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") @Watch("show")
onShowChange() { onShowChange() {
const klass = "has-top-bar"; 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() { private selectionChanged() {
this.show = this.selection.size > 0; this.show = this.selection.size > 0;
this.size = this.selection.size; this.size = this.selection.size;
@ -220,37 +251,11 @@ export default class SelectionManager extends Mixins(GlobalMixin, UserConfig) {
return this.selection.has(fileid); return this.selection.has(fileid);
} }
/** Restore selections from new day object */ /** Get the actions list */
public restoreDay(day: IDay) { private getActions(): ISelectionAction[] {
if (!this.has()) { return this.defaultActions.filter(
return; (a) => (!a.if || a.if(this)) && (!this.routeIsPublic() || a.allowPublic)
} );
// 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();
} }
/** Click on an action */ /** Click on an action */
@ -265,43 +270,145 @@ export default class SelectionManager extends Mixins(GlobalMixin, UserConfig) {
} }
} }
/** Get the actions list */ /** Clicking on photo */
private getActions(): ISelectionAction[] { public clickPhoto(photo: IPhoto, event: any, rowIdx: number) {
return this.defaultActions.filter( if (photo.flag & this.c.FLAG_PLACEHOLDER) return;
(a) => (!a.if || a.if(this)) && (!this.routeIsPublic() || a.allowPublic)
);
}
/** Clear all selected photos */ if (this.has()) {
public clearSelection(only?: IPhoto[]) { if (event.shiftKey) {
const heads = new Set<IHeadRow>(); this.selectMulti(photo, this.rows, rowIdx);
const toClear = only || this.selection.values(); } else {
Array.from(toClear).forEach((photo: IPhoto) => { this.selectPhoto(photo);
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;
}
} }
} 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;
} }
// Update head // Prevent scrolling
head.selected = selected; 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]);
}
this.$forceUpdate();
}
} }
/** Add a photo to selection list */ /** Add a photo to selection list */
@ -339,7 +446,6 @@ export default class SelectionManager extends Mixins(GlobalMixin, UserConfig) {
/** Multi-select */ /** Multi-select */
public selectMulti(photo: IPhoto, rows: IRow[], rowIdx: number) { public selectMulti(photo: IPhoto, rows: IRow[], rowIdx: number) {
console.log("selectMulti", photo, rows, rowIdx);
const pRow = rows[rowIdx]; const pRow = rows[rowIdx];
const pIdx = pRow.photos.indexOf(photo); const pIdx = pRow.photos.indexOf(photo);
if (pIdx === -1) return; if (pIdx === -1) return;
@ -407,6 +513,71 @@ export default class SelectionManager extends Mixins(GlobalMixin, UserConfig) {
this.$forceUpdate(); 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 * 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 * Move selected photos to album
*/ */
@ -623,6 +774,14 @@ export default class SelectionManager extends Mixins(GlobalMixin, UserConfig) {
this.deletePhotos(delPhotos); this.deletePhotos(delPhotos);
} }
} }
/** Open viewer with given photo */
private openViewer(photo: IPhoto) {
this.$router.push({
...this.$route,
hash: utils.getViewerHash(photo),
});
}
} }
</script> </script>

View File

@ -98,7 +98,12 @@
:day="item.day" :day="item.day"
:key="photo.fileid" :key="photo.fileid"
@select="selectionManager.selectPhoto" @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> </div>
</template> </template>
@ -117,6 +122,7 @@
<SelectionManager <SelectionManager
ref="selectionManager" ref="selectionManager"
:heads="heads" :heads="heads"
:rows="list"
:isreverse="isMonthView" :isreverse="isMonthView"
@refresh="softRefresh" @refresh="softRefresh"
@delete="deleteFromViewWithAnimation" @delete="deleteFromViewWithAnimation"
@ -1193,24 +1199,6 @@ export default class Timeline extends Mixins(GlobalMixin, UserConfig) {
return row; 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 * Delete elements from main view with some animation
* *

View File

@ -28,11 +28,11 @@
<div <div
class="img-outer fill-block" class="img-outer fill-block"
@contextmenu="contextmenu" @contextmenu="contextmenu"
@mousedown.passive="emitClick" @mousedown="$emit('mousedown', $event)"
@touchstart.passive="touchstart" @touchstart.passive="$emit('touchstart', $event)"
@touchmove.passive="touchend" @touchmove="$emit('touchmove', $event)"
@touchend.passive="touchend" @touchend.passive="$emit('touchend', $event)"
@touchcancel.passive="touchend" @touchcancel.passive="$emit('touchend', $event)"
> >
<img <img
ref="img" ref="img"
@ -78,7 +78,6 @@ export default class Photo extends Mixins(GlobalMixin) {
@Prop() day: IDay; @Prop() day: IDay;
@Emit("select") emitSelect(data: IPhoto) {} @Emit("select") emitSelect(data: IPhoto) {}
@Emit("click") emitClick() {}
@Watch("data") @Watch("data")
onDataChange(newData: IPhoto, oldData: IPhoto) { onDataChange(newData: IPhoto, oldData: IPhoto) {
@ -210,10 +209,10 @@ export default class Photo extends Mixins(GlobalMixin) {
contextmenu(e: Event) { contextmenu(e: Event) {
// on mobile only // on mobile only
if (this.hasTouch) { // if (this.hasTouch) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
} // }
} }
touchend() { touchend() {