Merge branch 'g3n35i5-fix/unassigned-faces-selection-problem'
commit
1f2305b804
|
@ -71,9 +71,7 @@ import AlbumsIcon from 'vue-material-design-icons/ImageAlbum.vue';
|
||||||
import AlbumRemoveIcon from 'vue-material-design-icons/BookRemove.vue';
|
import AlbumRemoveIcon from 'vue-material-design-icons/BookRemove.vue';
|
||||||
import FolderMoveIcon from 'vue-material-design-icons/FolderMove.vue';
|
import FolderMoveIcon from 'vue-material-design-icons/FolderMove.vue';
|
||||||
|
|
||||||
import { IDay, IHeadRow, IPhoto, IRow, IRowType, ISelectionAction } from '../types';
|
import { IDay, IHeadRow, IPhoto, IRow, IRowType } from '../types';
|
||||||
|
|
||||||
type Selection = Map<number, IPhoto>;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The distance for which the touch selection is clamped.
|
* The distance for which the touch selection is clamped.
|
||||||
|
@ -86,6 +84,63 @@ const TOUCH_SELECT_CLAMP = {
|
||||||
bufferPx: 5, // number of pixels to clamp inside recycler area
|
bufferPx: 5, // number of pixels to clamp inside recycler area
|
||||||
};
|
};
|
||||||
|
|
||||||
|
class Selection extends Map<string, IPhoto> {
|
||||||
|
addBy(photo: IPhoto): this {
|
||||||
|
console.assert(photo?.key, 'SelectionManager::addBy encountered a photo without a key');
|
||||||
|
this.set(photo.key!, photo);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
getBy({ key }: { key?: string }): IPhoto | undefined {
|
||||||
|
console.assert(key, 'SelectionManager::getBy encountered a photo without a key');
|
||||||
|
return this.get(key!);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteBy({ key }: { key?: string }): boolean {
|
||||||
|
console.assert(key, 'SelectionManager::deleteBy encountered a photo without a key');
|
||||||
|
return this.delete(key!);
|
||||||
|
}
|
||||||
|
|
||||||
|
hasBy({ key }: { key?: string }): boolean {
|
||||||
|
console.assert(key, 'SelectionManager::hasBy encountered a photo without a key');
|
||||||
|
return this.has(key!);
|
||||||
|
}
|
||||||
|
|
||||||
|
fileids(): Set<number> {
|
||||||
|
return new Set(Array.from(this.values()).map((p) => p.fileid));
|
||||||
|
}
|
||||||
|
|
||||||
|
photosNoDupFileId(): IPhoto[] {
|
||||||
|
const fileids = this.fileids();
|
||||||
|
return Array.from(this.values()).filter((p) => fileids.delete(p.fileid));
|
||||||
|
}
|
||||||
|
|
||||||
|
photosFromFileIds(fileIds: number[] | Set<number>): IPhoto[] {
|
||||||
|
const idSet = new Set(fileIds);
|
||||||
|
const photos = Array.from(this.values());
|
||||||
|
return photos.filter((p) => idSet.has(p?.fileid));
|
||||||
|
}
|
||||||
|
|
||||||
|
clone(): Selection {
|
||||||
|
return new Selection(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ISelectionAction = {
|
||||||
|
/** Identifier (optional) */
|
||||||
|
id?: string;
|
||||||
|
/** Display text */
|
||||||
|
name: string;
|
||||||
|
/** Icon component */
|
||||||
|
icon: any;
|
||||||
|
/** Action to perform */
|
||||||
|
callback: (selection: Selection) => Promise<void>;
|
||||||
|
/** Condition to check for including */
|
||||||
|
if?: (self?: any) => boolean;
|
||||||
|
/** Allow for public routes (default false) */
|
||||||
|
allowPublic?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: 'SelectionManager',
|
name: 'SelectionManager',
|
||||||
components: {
|
components: {
|
||||||
|
@ -124,7 +179,7 @@ export default defineComponent({
|
||||||
data: () => ({
|
data: () => ({
|
||||||
show: false,
|
show: false,
|
||||||
size: 0,
|
size: 0,
|
||||||
selection: new Map<number, IPhoto>(),
|
selection: new Selection(),
|
||||||
defaultActions: null! as ISelectionAction[],
|
defaultActions: null! as ISelectionAction[],
|
||||||
|
|
||||||
touchAnchor: null as IPhoto | null,
|
touchAnchor: null as IPhoto | null,
|
||||||
|
@ -247,7 +302,7 @@ export default defineComponent({
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteSelectedPhotosById(delIds: number[], selection: Selection) {
|
deleteSelectedPhotosById(delIds: number[], selection: Selection) {
|
||||||
return this.deletePhotos(delIds.map((id) => selection.get(id)).filter((p): p is IPhoto => p !== undefined));
|
return this.deletePhotos(selection.photosFromFileIds(delIds));
|
||||||
},
|
},
|
||||||
|
|
||||||
updateLoading(delta: number) {
|
updateLoading(delta: number) {
|
||||||
|
@ -285,12 +340,9 @@ export default defineComponent({
|
||||||
document.body.classList.toggle('has-top-bar', has);
|
document.body.classList.toggle('has-top-bar', has);
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Is this fileid (or anything if not specified) selected */
|
/** Is the selection empty */
|
||||||
has(fileid?: number) {
|
empty(): boolean {
|
||||||
if (fileid === undefined) {
|
return !this.selection.size;
|
||||||
return this.selection.size > 0;
|
|
||||||
}
|
|
||||||
return this.selection.has(fileid);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Get the actions list */
|
/** Get the actions list */
|
||||||
|
@ -316,7 +368,7 @@ export default defineComponent({
|
||||||
if (event.pointerType === 'touch') return; // let touch events handle this
|
if (event.pointerType === 'touch') return; // let touch events handle this
|
||||||
if (event.pointerType === 'mouse' && event.button !== 0) return; // only left click for mouse
|
if (event.pointerType === 'mouse' && event.button !== 0) return; // only left click for mouse
|
||||||
|
|
||||||
if (this.has() || event.ctrlKey || event.shiftKey) {
|
if (!this.empty() || event.ctrlKey || event.shiftKey) {
|
||||||
this.clickSelectionIcon(photo, event, rowIdx);
|
this.clickSelectionIcon(photo, event, rowIdx);
|
||||||
} else {
|
} else {
|
||||||
this.openViewer(photo);
|
this.openViewer(photo);
|
||||||
|
@ -325,7 +377,7 @@ export default defineComponent({
|
||||||
|
|
||||||
/** Clicking on checkmark icon */
|
/** Clicking on checkmark icon */
|
||||||
clickSelectionIcon(photo: IPhoto, event: PointerEvent, rowIdx: number) {
|
clickSelectionIcon(photo: IPhoto, event: PointerEvent, rowIdx: number) {
|
||||||
if (this.has() && event.shiftKey) {
|
if (!this.empty() && event.shiftKey) {
|
||||||
this.selectMulti(photo, this.rows, rowIdx);
|
this.selectMulti(photo, this.rows, rowIdx);
|
||||||
} else {
|
} else {
|
||||||
this.selectPhoto(photo);
|
this.selectPhoto(photo);
|
||||||
|
@ -342,7 +394,7 @@ export default defineComponent({
|
||||||
this.touchAnchor = photo;
|
this.touchAnchor = photo;
|
||||||
this.prevOver = photo;
|
this.prevOver = photo;
|
||||||
this.prevTouch = event.touches[0];
|
this.prevTouch = event.touches[0];
|
||||||
this.touchPrevSel = new Map(this.selection);
|
this.touchPrevSel = this.selection.clone();
|
||||||
this.touchMoved = false;
|
this.touchMoved = false;
|
||||||
this.touchTimer = window.setTimeout(() => {
|
this.touchTimer = window.setTimeout(() => {
|
||||||
if (this.touchAnchor === photo) {
|
if (this.touchAnchor === photo) {
|
||||||
|
@ -499,7 +551,7 @@ export default defineComponent({
|
||||||
reverse = overPhoto.dayid > this.touchAnchor.dayid != this.isreverse;
|
reverse = overPhoto.dayid > this.touchAnchor.dayid != this.isreverse;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newSelection = new Map(this.touchPrevSel);
|
const newSelection = this.touchPrevSel!.clone();
|
||||||
const updatedDays = new Set<number>();
|
const updatedDays = new Set<number>();
|
||||||
|
|
||||||
// Walk over rows
|
// Walk over rows
|
||||||
|
@ -520,31 +572,31 @@ export default defineComponent({
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let p = this.rows[i]?.photos?.[j];
|
const photo = this.rows[i]?.photos?.[j];
|
||||||
if (!p) break; // shouldn't happen, ever
|
if (!photo) break; // shouldn't happen, ever
|
||||||
|
|
||||||
// This is there now
|
// This is there now
|
||||||
newSelection.set(p.fileid, p);
|
newSelection.addBy(photo);
|
||||||
|
|
||||||
// Perf: only update heads if not selected
|
// Perf: only update heads if not selected
|
||||||
if (!(p.flag & this.c.FLAG_SELECTED)) {
|
if (!(photo.flag & this.c.FLAG_SELECTED)) {
|
||||||
this.selectPhoto(p, true, true);
|
this.selectPhoto(photo, true, true);
|
||||||
updatedDays.add(p.dayid);
|
updatedDays.add(photo.dayid);
|
||||||
}
|
}
|
||||||
|
|
||||||
// We're trying to update too much -- something went wrong
|
// We're trying to update too much -- something went wrong
|
||||||
if (newSelection.size - this.selection.size > 50) break;
|
if (newSelection.size - this.selection.size > 50) break;
|
||||||
|
|
||||||
// Check goal
|
// Check goal
|
||||||
if (p === overPhoto) break;
|
if (photo === overPhoto) break;
|
||||||
j += reverse ? -1 : 1;
|
j += reverse ? -1 : 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove unselected
|
// Remove unselected
|
||||||
for (const [fileid, p] of this.selection) {
|
for (const [_, photo] of this.selection) {
|
||||||
if (!newSelection.has(fileid)) {
|
if (!newSelection.hasBy(photo)) {
|
||||||
this.selectPhoto(p, false, true);
|
this.selectPhoto(photo, false, true);
|
||||||
updatedDays.add(p.dayid);
|
updatedDays.add(photo.dayid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -563,22 +615,16 @@ export default defineComponent({
|
||||||
return; // ignore placeholders
|
return; // ignore placeholders
|
||||||
}
|
}
|
||||||
|
|
||||||
const nval = val ?? !this.selection.has(photo.fileid);
|
const nval = val ?? !this.selection.hasBy(photo);
|
||||||
if (nval) {
|
if (nval) {
|
||||||
photo.flag |= this.c.FLAG_SELECTED;
|
photo.flag |= this.c.FLAG_SELECTED;
|
||||||
this.selection.set(photo.fileid, photo);
|
this.selection.addBy(photo);
|
||||||
this.selectionChanged();
|
this.selectionChanged();
|
||||||
} else {
|
} else {
|
||||||
photo.flag &= ~this.c.FLAG_SELECTED;
|
photo.flag &= ~this.c.FLAG_SELECTED;
|
||||||
|
this.selection.deleteBy(photo);
|
||||||
// Only do this if the photo in the selection set is this one.
|
|
||||||
// The problem arises when there are duplicates (e.g. face rect)
|
|
||||||
// in the list, which creates an inconsistent state if we do this.
|
|
||||||
if (this.selection.get(photo.fileid) === photo) {
|
|
||||||
this.selection.delete(photo.fileid);
|
|
||||||
this.selectionChanged();
|
this.selectionChanged();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (!noUpdate) {
|
if (!noUpdate) {
|
||||||
this.updateHeadSelected(this.heads[photo.dayid]);
|
this.updateHeadSelected(this.heads[photo.dayid]);
|
||||||
|
@ -679,7 +725,7 @@ export default defineComponent({
|
||||||
Array.from(toClear).forEach((photo: IPhoto) => {
|
Array.from(toClear).forEach((photo: IPhoto) => {
|
||||||
photo.flag &= ~this.c.FLAG_SELECTED;
|
photo.flag &= ~this.c.FLAG_SELECTED;
|
||||||
heads.add(this.heads[photo.dayid]);
|
heads.add(this.heads[photo.dayid]);
|
||||||
this.selection.delete(photo.fileid);
|
this.selection.deleteBy(photo);
|
||||||
this.selectionChanged();
|
this.selectionChanged();
|
||||||
});
|
});
|
||||||
heads.forEach(this.updateHeadSelected);
|
heads.forEach(this.updateHeadSelected);
|
||||||
|
@ -688,31 +734,27 @@ export default defineComponent({
|
||||||
|
|
||||||
/** Restore selections from new day object */
|
/** Restore selections from new day object */
|
||||||
restoreDay(day: IDay) {
|
restoreDay(day: IDay) {
|
||||||
if (!this.has()) {
|
if (this.empty()) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// FileID => Photo for new day
|
// FileID => Photo for new day
|
||||||
const dayMap = new Map<number, IPhoto>();
|
const dayMap = new Selection();
|
||||||
day.detail?.forEach((photo) => {
|
day.detail?.forEach((photo) => dayMap.addBy(photo));
|
||||||
dayMap.set(photo.fileid, photo);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.selection.forEach((photo, fileid) => {
|
this.selection.forEach((photo, key) => {
|
||||||
// Process this day only
|
// Process this day only
|
||||||
if (photo.dayid !== day.dayid) {
|
if (photo.dayid !== day.dayid) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove all selections that are not in the new day
|
// Remove all selections that are not in the new day
|
||||||
const newPhoto = dayMap.get(fileid);
|
const newPhoto = dayMap.get(key);
|
||||||
if (!newPhoto) {
|
if (!newPhoto) {
|
||||||
this.selection.delete(fileid);
|
this.selection.delete(key);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the photo object
|
// Update the photo object
|
||||||
this.selection.set(fileid, newPhoto);
|
this.selection.addBy(newPhoto);
|
||||||
newPhoto.flag |= this.c.FLAG_SELECTED;
|
newPhoto.flag |= this.c.FLAG_SELECTED;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -728,7 +770,7 @@ export default defineComponent({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await dav.downloadFilesByPhotos(Array.from(selection.values()));
|
await dav.downloadFilesByPhotos(selection.photosNoDupFileId());
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -743,7 +785,8 @@ export default defineComponent({
|
||||||
*/
|
*/
|
||||||
async favoriteSelection(selection: Selection) {
|
async favoriteSelection(selection: Selection) {
|
||||||
const val = !this.allSelectedFavorites(selection);
|
const val = !this.allSelectedFavorites(selection);
|
||||||
for await (const favIds of dav.favoritePhotos(Array.from(selection.values()), val)) {
|
for await (const ids of dav.favoritePhotos(selection.photosNoDupFileId(), val)) {
|
||||||
|
selection.photosFromFileIds(ids).forEach((photo) => dav.favoriteSetFlag(photo, val));
|
||||||
}
|
}
|
||||||
this.clearSelection();
|
this.clearSelection();
|
||||||
},
|
},
|
||||||
|
@ -759,7 +802,7 @@ export default defineComponent({
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for await (const delIds of dav.deletePhotos(Array.from(selection.values()))) {
|
for await (const delIds of dav.deletePhotos(selection.photosNoDupFileId())) {
|
||||||
this.deleteSelectedPhotosById(delIds, selection);
|
this.deleteSelectedPhotosById(delIds, selection);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
@ -772,7 +815,7 @@ export default defineComponent({
|
||||||
* Open the edit date dialog
|
* Open the edit date dialog
|
||||||
*/
|
*/
|
||||||
async editMetadataSelection(selection: Selection, sections?: number[]) {
|
async editMetadataSelection(selection: Selection, sections?: number[]) {
|
||||||
globalThis.editMetadata(Array.from(selection.values()), sections);
|
globalThis.editMetadata(selection.photosNoDupFileId(), sections);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -789,12 +832,12 @@ export default defineComponent({
|
||||||
*/
|
*/
|
||||||
async archiveSelection(selection: Selection) {
|
async archiveSelection(selection: Selection) {
|
||||||
if (selection.size >= 100) {
|
if (selection.size >= 100) {
|
||||||
if (!confirm(this.t('memories', 'You are about to touch a large number of files. Are you sure?'))) {
|
if (!confirm(this.t('memories', 'You are about to move a large number of files. Are you sure?'))) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for await (let delIds of dav.archiveFilesByIds(Array.from(selection.keys()), !this.routeIsArchive)) {
|
for await (let delIds of dav.archiveFilesByIds(Array.from(selection.fileids()), !this.routeIsArchive)) {
|
||||||
this.deleteSelectedPhotosById(delIds, selection);
|
this.deleteSelectedPhotosById(delIds, selection);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -803,14 +846,14 @@ export default defineComponent({
|
||||||
* Move selected photos to album
|
* Move selected photos to album
|
||||||
*/
|
*/
|
||||||
async addToAlbum(selection: Selection) {
|
async addToAlbum(selection: Selection) {
|
||||||
globalThis.updateAlbums(Array.from(selection.values()));
|
globalThis.updateAlbums(selection.photosNoDupFileId());
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Move selected photos to folder
|
* Move selected photos to folder
|
||||||
*/
|
*/
|
||||||
async moveToFolder(selection: Selection) {
|
async moveToFolder(selection: Selection) {
|
||||||
(<any>this.$refs.moveToFolderModal).open(Array.from(selection.values()));
|
(<any>this.$refs.moveToFolderModal).open(selection.photosNoDupFileId());
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -60,11 +60,7 @@ export async function* favoritePhotos(photos: IPhoto[], favoriteState: boolean)
|
||||||
try {
|
try {
|
||||||
await favoriteFile(fileInfo.originalFilename, favoriteState);
|
await favoriteFile(fileInfo.originalFilename, favoriteState);
|
||||||
const photo = photos.find((p) => p.fileid === fileInfo.fileid)!;
|
const photo = photos.find((p) => p.fileid === fileInfo.fileid)!;
|
||||||
if (favoriteState) {
|
favoriteSetFlag(photo, favoriteState);
|
||||||
photo.flag |= utils.constants.c.FLAG_IS_FAVORITE;
|
|
||||||
} else {
|
|
||||||
photo.flag &= ~utils.constants.c.FLAG_IS_FAVORITE;
|
|
||||||
}
|
|
||||||
return fileInfo.fileid as number;
|
return fileInfo.fileid as number;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to favorite', fileInfo, error);
|
console.error('Failed to favorite', fileInfo, error);
|
||||||
|
@ -79,3 +75,16 @@ export async function* favoritePhotos(photos: IPhoto[], favoriteState: boolean)
|
||||||
|
|
||||||
yield* base.runInParallel(calls, 10);
|
yield* base.runInParallel(calls, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the favorite flag on a photo
|
||||||
|
* @param photo Photo to set the flag on
|
||||||
|
* @param val New value of the flag
|
||||||
|
*/
|
||||||
|
export function favoriteSetFlag(photo: IPhoto, val: boolean) {
|
||||||
|
if (val) {
|
||||||
|
photo.flag |= utils.constants.c.FLAG_IS_FAVORITE;
|
||||||
|
} else {
|
||||||
|
photo.flag &= ~utils.constants.c.FLAG_IS_FAVORITE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
15
src/types.ts
15
src/types.ts
|
@ -221,21 +221,6 @@ export type ITick = {
|
||||||
key?: number;
|
key?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ISelectionAction = {
|
|
||||||
/** Identifier (optional) */
|
|
||||||
id?: string;
|
|
||||||
/** Display text */
|
|
||||||
name: string;
|
|
||||||
/** Icon component */
|
|
||||||
icon: any;
|
|
||||||
/** Action to perform */
|
|
||||||
callback: (selection: Map<number, IPhoto>) => Promise<void>;
|
|
||||||
/** Condition to check for including */
|
|
||||||
if?: (self?: any) => boolean;
|
|
||||||
/** Allow for public routes (default false) */
|
|
||||||
allowPublic?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type IConfig = {
|
export type IConfig = {
|
||||||
version: string;
|
version: string;
|
||||||
vod_disable: boolean;
|
vod_disable: boolean;
|
||||||
|
|
Loading…
Reference in New Issue