Add fav to viewer

pull/175/head
Varun Patil 2022-11-05 17:00:55 -07:00 committed by Varun Patil
parent 6146b513b2
commit daf079f101
5 changed files with 395 additions and 286 deletions

View File

@ -276,6 +276,9 @@ body {
z-index: 3000; z-index: 3000;
} }
} }
#content-vue.has-viewer {
z-index: 3000;
}
// Patch viewer to remove the title and // Patch viewer to remove the title and
// make the image fill the entire screen // make the image fill the entire screen

View File

@ -44,7 +44,6 @@
<OnThisDay <OnThisDay
v-if="$route.name === 'timeline'" v-if="$route.name === 'timeline'"
:viewerManager="viewerManager"
:key="config_timelinePath" :key="config_timelinePath"
@load="scrollerManager.adjust()" @load="scrollerManager.adjust()"
> >
@ -121,35 +120,46 @@
@delete="deleteFromViewWithAnimation" @delete="deleteFromViewWithAnimation"
@updateLoading="updateLoading" @updateLoading="updateLoading"
/> />
<Viewer
ref="viewer"
@deleted="deleteFromViewWithAnimation"
@fetchDay="fetchDay"
@updateLoading="updateLoading"
/>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Mixins, Watch } from "vue-property-decorator";
import GlobalMixin from "../mixins/GlobalMixin";
import UserConfig from "../mixins/UserConfig";
import axios from "@nextcloud/axios"; import axios from "@nextcloud/axios";
import { showError } from "@nextcloud/dialogs"; import { showError } from "@nextcloud/dialogs";
import { subscribe, unsubscribe } from "@nextcloud/event-bus"; import { subscribe, unsubscribe } from "@nextcloud/event-bus";
import { generateUrl } from "@nextcloud/router"; import { generateUrl } from "@nextcloud/router";
import { NcEmptyContent } from "@nextcloud/vue"; import { NcEmptyContent } from "@nextcloud/vue";
import PeopleIcon from "vue-material-design-icons/AccountMultiple.vue";
import CheckCircle from "vue-material-design-icons/CheckCircle.vue";
import ImageMultipleIcon from "vue-material-design-icons/ImageMultiple.vue";
import ArchiveIcon from "vue-material-design-icons/PackageDown.vue";
import { Component, Mixins, Watch } from "vue-property-decorator";
import GlobalMixin from "../mixins/GlobalMixin";
import UserConfig from "../mixins/UserConfig";
import * as dav from "../services/DavRequests";
import { getLayout } from "../services/Layout"; import { getLayout } from "../services/Layout";
import * as utils from "../services/Utils";
import { ViewerManager } from "../services/Viewer";
import { IDay, IFolder, IHeadRow, IPhoto, IRow, IRowType } from "../types"; import { IDay, IFolder, IHeadRow, IPhoto, IRow, IRowType } from "../types";
import Folder from "./frame/Folder.vue"; import Folder from "./frame/Folder.vue";
import Photo from "./frame/Photo.vue"; import Photo from "./frame/Photo.vue";
import Tag from "./frame/Tag.vue"; import Tag from "./frame/Tag.vue";
import ScrollerManager from "./ScrollerManager.vue"; import ScrollerManager from "./ScrollerManager.vue";
import SelectionManager from "./SelectionManager.vue"; import SelectionManager from "./SelectionManager.vue";
import Viewer from "./Viewer.vue";
import OnThisDay from "./top-matter/OnThisDay.vue"; import OnThisDay from "./top-matter/OnThisDay.vue";
import TopMatter from "./top-matter/TopMatter.vue"; import TopMatter from "./top-matter/TopMatter.vue";
import * as dav from "../services/DavRequests";
import * as utils from "../services/Utils";
import PeopleIcon from "vue-material-design-icons/AccountMultiple.vue";
import CheckCircle from "vue-material-design-icons/CheckCircle.vue";
import ImageMultipleIcon from "vue-material-design-icons/ImageMultiple.vue";
import ArchiveIcon from "vue-material-design-icons/PackageDown.vue";
const SCROLL_LOAD_DELAY = 100; // Delay in loading data when scrolling const SCROLL_LOAD_DELAY = 100; // Delay in loading data when scrolling
const DESKTOP_ROW_HEIGHT = 200; // Height of row on desktop const DESKTOP_ROW_HEIGHT = 200; // Height of row on desktop
const MOBILE_ROW_HEIGHT = 120; // Approx row height on mobile const MOBILE_ROW_HEIGHT = 120; // Approx row height on mobile
@ -163,6 +173,7 @@ const MOBILE_ROW_HEIGHT = 120; // Approx row height on mobile
OnThisDay, OnThisDay,
SelectionManager, SelectionManager,
ScrollerManager, ScrollerManager,
Viewer,
NcEmptyContent, NcEmptyContent,
CheckCircle, CheckCircle,
@ -212,12 +223,6 @@ export default class Timeline extends Mixins(GlobalMixin, UserConfig) {
/** Scroller manager component */ /** Scroller manager component */
private scrollerManager!: ScrollerManager & any; private scrollerManager!: ScrollerManager & any;
/** Nextcloud viewer proxy */
private viewerManager = new ViewerManager({
ondelete: this.deleteFromViewWithAnimation,
fetchDay: this.fetchDay,
});
mounted() { mounted() {
this.selectionManager = this.$refs.selectionManager; this.selectionManager = this.$refs.selectionManager;
this.scrollerManager = this.$refs.scrollerManager; this.scrollerManager = this.$refs.scrollerManager;
@ -1140,7 +1145,7 @@ export default class Timeline extends Mixins(GlobalMixin, UserConfig) {
// selection mode // selection mode
this.selectionManager.selectPhoto(photo); this.selectionManager.selectPhoto(photo);
} else { } else {
this.viewerManager.open(photo, this.list); (<any>this.$refs.viewer).open(photo, this.list);
} }
} }

View File

@ -0,0 +1,369 @@
<template>
<div class="memories_viewer outer">
<div class="inner" ref="inner">
<div class="top-bar" v-if="photoswipe">
<NcActions :inline="2" container=".memories_viewer .pswp">
<NcActionButton
:aria-label="t('memories', 'Delete')"
@click="deleteCurrent"
:close-after-click="true"
>
{{ t("memories", "Delete") }}
<template #icon> <DeleteIcon :size="24" /> </template>
</NcActionButton>
<NcActionButton
:aria-label="t('memories', 'Favorite')"
@click="favoriteCurrent"
:close-after-click="true"
>
{{ t("memories", "Favorite") }}
<template #icon>
<StarIcon v-if="isFavorite()" :size="24" />
<StarOutlineIcon v-else :size="24" />
</template>
</NcActionButton>
</NcActions>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Component, Emit, Mixins } from "vue-property-decorator";
import GlobalMixin from "../mixins/GlobalMixin";
import { IDay, IPhoto, IRow, IRowType } from "../types";
import { NcActions, NcActionButton } from "@nextcloud/vue";
import * as dav from "../services/DavRequests";
import * as utils from "../services/Utils";
import { getPreviewUrl } from "../services/FileUtils";
import PhotoSwipe, { PhotoSwipeOptions } from "photoswipe";
import "photoswipe/style.css";
import DeleteIcon from "vue-material-design-icons/Delete.vue";
import StarIcon from "vue-material-design-icons/Star.vue";
import StarOutlineIcon from "vue-material-design-icons/StarOutline.vue";
@Component({
components: {
NcActions,
NcActionButton,
DeleteIcon,
StarIcon,
StarOutlineIcon,
},
})
export default class Viewer extends Mixins(GlobalMixin) {
@Emit("deleted") deleted(photos: IPhoto[]) {}
@Emit("fetchDay") fetchDay(dayId: number) {}
@Emit("updateLoading") updateLoading(delta: number) {}
/** Base dialog */
private photoswipe: PhotoSwipe | null = null;
private list: IPhoto[] = [];
private days = new Map<number, IDay>();
private dayIds: number[] = [];
private globalCount = 0;
private globalAnchor = -1;
private getBaseBox(args: PhotoSwipeOptions) {
this.photoswipe = new PhotoSwipe({
counter: true,
zoom: false,
loop: false,
bgOpacity: 1,
appendToEl: this.$refs.inner as HTMLElement,
...args,
});
// Make sure buttons are styled properly
this.photoswipe.addFilter("uiElement", (element, data) => {
// add button-vue class if button
if (element.classList.contains("pswp__button")) {
element.classList.add("button-vue");
}
return element;
});
// Total number of photos in this view
this.photoswipe.addFilter("numItems", (numItems) => {
return this.globalCount;
});
// Put viewer over everything else
const contentElem = document.getElementById("content-vue");
const navElem = document.getElementById("app-navigation-vue");
const klass = "has-viewer";
this.photoswipe.on("beforeOpen", () => {
contentElem.classList.add(klass);
navElem.style.zIndex = "0";
});
this.photoswipe.on("destroy", () => {
contentElem.classList.remove(klass);
navElem.style.zIndex = "";
// reset everything
this.photoswipe = null;
this.list = [];
this.days.clear();
this.dayIds = [];
this.globalCount = 0;
this.globalAnchor = -1;
});
return this.photoswipe;
}
public async open(anchorPhoto: IPhoto, rows?: IRow[]) {
// list = list || photo.d?.detail;
// if (!list?.length) return;
// // Repopulate map
// this.photoMap.clear();
// for (const p of list) {
// this.photoMap.set(p.fileid, p);
// }
// // Get file infos
// let fileInfos: IFileInfo[];
// try {
// this.updateLoading(1);
// fileInfos = await dav.getFiles(list);
// } catch (e) {
// console.error("Failed to load fileInfos", e);
// showError("Failed to load fileInfos");
// return;
// } finally {
// this.updateLoading(-1);
// }
// if (fileInfos.length === 0) {
// return;
// }
// // Fix sorting of the fileInfos
// const itemPositions = {};
// for (const [index, p] of list.entries()) {
// itemPositions[p.fileid] = index;
// }
// fileInfos.sort(function (a, b) {
// return itemPositions[a.fileid] - itemPositions[b.fileid];
// });
// // Get this photo in the fileInfos
// const fInfo = fileInfos.find((d) => Number(d.fileid) === photo.fileid);
// if (!fInfo) {
// showError(t("memories", "Cannot find this photo anymore!"));
// return;
// }
// // Check viewer > 2.0.0
// const viewerVersion: string = globalThis.OCA.Viewer.version;
// const viewerMajor = Number(viewerVersion.split(".")[0]);
// // Open Nextcloud viewer
// globalThis.OCA.Viewer.open({
// fileInfo: fInfo,
// path: viewerMajor < 2 ? fInfo.filename : undefined, // Only specify path upto Nextcloud 24
// list: fileInfos, // file list
// canLoop: false, // don't loop
// onClose: () => {
// // on viewer close
// if (globalThis.OCA.Files.Sidebar.file) {
// localStorage.setItem(SIDEBAR_KEY, "1");
// } else {
// localStorage.removeItem(SIDEBAR_KEY);
// }
// globalThis.OCA.Files.Sidebar.close();
// },
// });
// // Restore sidebar state
// if (localStorage.getItem(SIDEBAR_KEY) === "1") {
// globalThis.OCA.Files.Sidebar.open(fInfo.filename);
// }
this.list = [...anchorPhoto.d.detail];
let startIndex = -1;
for (const r of rows) {
if (r.type === IRowType.HEAD) {
if (r.day.dayid == anchorPhoto.d.dayid) {
startIndex = r.day.detail.findIndex(
(p) => p.fileid === anchorPhoto.fileid
);
this.globalAnchor = this.globalCount;
}
this.globalCount += r.day.count;
this.days.set(r.day.dayid, r.day);
this.dayIds.push(r.day.dayid);
}
}
this.getBaseBox({
index: this.globalAnchor + startIndex,
});
this.photoswipe.addFilter("itemData", (itemData, index) => {
// Get photo object from list
let idx = index - this.globalAnchor;
if (idx < 0) {
// Load previous day
const firstDayId = this.list[0].d.dayid;
const firstDayIdx = utils.binarySearch(this.dayIds, firstDayId);
if (firstDayIdx === 0) {
// No previous day
return {};
}
const prevDayId = this.dayIds[firstDayIdx - 1];
const prevDay = this.days.get(prevDayId);
if (!prevDay.detail) {
console.error("[BUG] No detail for previous day");
return {};
}
this.list.unshift(...prevDay.detail);
this.globalAnchor -= prevDay.count;
} else if (idx >= this.list.length) {
// Load next day
const lastDayId = this.list[this.list.length - 1].d.dayid;
const lastDayIdx = utils.binarySearch(this.dayIds, lastDayId);
if (lastDayIdx === this.dayIds.length - 1) {
// No next day
return {};
}
const nextDayId = this.dayIds[lastDayIdx + 1];
const nextDay = this.days.get(nextDayId);
if (!nextDay.detail) {
console.error("[BUG] No detail for next day");
return {};
}
this.list.push(...nextDay.detail);
}
idx = index - this.globalAnchor;
const photo = this.list[idx];
// Something went really wrong
if (!photo) {
return {};
}
// Preload next and previous 3 days
const dayIdx = utils.binarySearch(this.dayIds, photo.d.dayid);
const preload = (idx: number) => {
if (
idx > 0 &&
idx < this.dayIds.length &&
!this.days.get(this.dayIds[idx]).detail
) {
this.fetchDay(this.dayIds[idx]);
}
};
preload(dayIdx - 1);
preload(dayIdx - 2);
preload(dayIdx - 3);
preload(dayIdx + 1);
preload(dayIdx + 2);
preload(dayIdx + 3);
// Get thumb image
const thumbSrc: string =
this.thumbElem(photo)?.querySelector("img")?.getAttribute("src") ||
getPreviewUrl(photo, false, 256);
// Get full image
return {
src: getPreviewUrl(photo, false, 256),
msrc: thumbSrc,
width: photo.w || undefined,
height: photo.h || undefined,
thumbCropped: true,
};
});
this.photoswipe.addFilter("thumbEl", (thumbEl, data, index) => {
const photo = this.list[index - this.globalAnchor];
return this.thumbElem(photo) || thumbEl;
});
this.photoswipe.init();
}
private thumbElem(photo: IPhoto) {
if (!photo) return;
return document.getElementById(
`memories-photo-${photo.key || photo.fileid}`
);
}
private async deleteCurrent() {
const idx = this.photoswipe.currIndex - this.globalAnchor;
// Delete with WebDAV
try {
this.updateLoading(1);
for await (const p of dav.deletePhotos([this.list[idx]])) {
if (!p[0]) return;
}
} finally {
this.updateLoading(-1);
}
const spliced = this.list.splice(idx, 1);
this.globalCount--;
for (let i = idx - 3; i <= idx + 3; i++) {
this.photoswipe.refreshSlideContent(i + this.globalAnchor);
}
this.deleted(spliced);
}
private isFavorite() {
const idx = this.photoswipe.currIndex - this.globalAnchor;
return Boolean(this.list[idx].flag & this.c.FLAG_IS_FAVORITE);
}
private async favoriteCurrent() {
const idx = this.photoswipe.currIndex - this.globalAnchor;
const photo = this.list[idx];
const val = !this.isFavorite();
try {
this.updateLoading(1);
for await (const p of dav.favoritePhotos([photo], val)) {
if (!p[0]) return;
}
} finally {
this.updateLoading(-1);
}
// Set flag on success
if (val) {
photo.flag |= this.c.FLAG_IS_FAVORITE;
} else {
photo.flag &= ~this.c.FLAG_IS_FAVORITE;
}
}
}
</script>
<style lang="scss" scoped>
.outer {
z-index: 3000;
width: 100vw;
height: 30vh;
position: absolute;
top: 0;
left: 0;
overflow: hidden;
color: white;
}
.top-bar {
z-index: 100001;
position: fixed;
top: 8px;
right: 50px;
:deep .button-vue--icon-only {
color: white;
background-color: transparent !important;
}
}
</style>

View File

@ -47,7 +47,6 @@ import { NcActions, NcActionButton } from "@nextcloud/vue";
import * as utils from "../../services/Utils"; import * as utils from "../../services/Utils";
import * as dav from "../../services/DavRequests"; import * as dav from "../../services/DavRequests";
import { ViewerManager } from "../../services/Viewer";
import { IPhoto } from "../../types"; import { IPhoto } from "../../types";
import { getPreviewUrl } from "../../services/FileUtils"; import { getPreviewUrl } from "../../services/FileUtils";
@ -83,14 +82,6 @@ export default class OnThisDay extends Mixins(GlobalMixin) {
private hasLeft = false; private hasLeft = false;
private scrollStack: number[] = []; private scrollStack: number[] = [];
/**
* Nextcloud viewer proxy
* Can't use the timeline instance because these photos
* might not be in view, so can't delete them
*/
@Prop()
private viewerManager!: ViewerManager;
mounted() { mounted() {
const inner = this.$refs.inner as HTMLElement; const inner = this.$refs.inner as HTMLElement;
inner.addEventListener("scroll", this.onScroll.bind(this), { inner.addEventListener("scroll", this.onScroll.bind(this), {

View File

@ -1,259 +0,0 @@
import Vue from "vue";
import { IDay, IFileInfo, IPhoto, IRow, IRowType } from "../types";
import { showError } from "@nextcloud/dialogs";
import { subscribe } from "@nextcloud/event-bus";
import { translate as t, translatePlural as n } from "@nextcloud/l10n";
import { Route } from "vue-router";
import { getPreviewUrl } from "./FileUtils";
import * as dav from "./DavRequests";
import * as utils from "./Utils";
import PhotoSwipe, { PhotoSwipeOptions } from "photoswipe";
import "photoswipe/style.css";
import DeleteIcon from "vue-material-design-icons/Delete.vue";
// Key to store sidebar state
const SIDEBAR_KEY = "memories:sidebar-open";
// Options
type opts_t = {
ondelete: (photos: IPhoto[]) => void;
fetchDay: (dayId: number) => void;
};
export class ViewerManager {
/** Delete click callback */
private deleteClick!: () => void;
constructor(private opts: opts_t) {}
private getVueBtn(typ: any) {
const btn = new (Vue.extend(typ))({
propsData: {
size: 24,
},
});
btn.$mount();
return btn.$el;
}
private getBaseBox(args: PhotoSwipeOptions) {
const photoswipe = new PhotoSwipe({
counter: true,
loop: false,
...args,
});
photoswipe.addFilter("uiElement", (element, data) => {
// add button-vue class if button
if (element.classList.contains("pswp__button")) {
element.classList.add("button-vue");
}
return element;
});
photoswipe.on("uiRegister", () => {
photoswipe.ui.registerElement({
name: "delete-button",
ariaLabel: "Delete",
order: 9,
isButton: true,
html: this.getVueBtn(DeleteIcon).outerHTML,
onClick: () => this.deleteClick(),
});
});
return photoswipe;
}
public async open(anchorPhoto: IPhoto, rows?: IRow[]) {
// list = list || photo.d?.detail;
// if (!list?.length) return;
// // Repopulate map
// this.photoMap.clear();
// for (const p of list) {
// this.photoMap.set(p.fileid, p);
// }
// // Get file infos
// let fileInfos: IFileInfo[];
// try {
// this.updateLoading(1);
// fileInfos = await dav.getFiles(list);
// } catch (e) {
// console.error("Failed to load fileInfos", e);
// showError("Failed to load fileInfos");
// return;
// } finally {
// this.updateLoading(-1);
// }
// if (fileInfos.length === 0) {
// return;
// }
// // Fix sorting of the fileInfos
// const itemPositions = {};
// for (const [index, p] of list.entries()) {
// itemPositions[p.fileid] = index;
// }
// fileInfos.sort(function (a, b) {
// return itemPositions[a.fileid] - itemPositions[b.fileid];
// });
// // Get this photo in the fileInfos
// const fInfo = fileInfos.find((d) => Number(d.fileid) === photo.fileid);
// if (!fInfo) {
// showError(t("memories", "Cannot find this photo anymore!"));
// return;
// }
// // Check viewer > 2.0.0
// const viewerVersion: string = globalThis.OCA.Viewer.version;
// const viewerMajor = Number(viewerVersion.split(".")[0]);
// // Open Nextcloud viewer
// globalThis.OCA.Viewer.open({
// fileInfo: fInfo,
// path: viewerMajor < 2 ? fInfo.filename : undefined, // Only specify path upto Nextcloud 24
// list: fileInfos, // file list
// canLoop: false, // don't loop
// onClose: () => {
// // on viewer close
// if (globalThis.OCA.Files.Sidebar.file) {
// localStorage.setItem(SIDEBAR_KEY, "1");
// } else {
// localStorage.removeItem(SIDEBAR_KEY);
// }
// globalThis.OCA.Files.Sidebar.close();
// },
// });
// // Restore sidebar state
// if (localStorage.getItem(SIDEBAR_KEY) === "1") {
// globalThis.OCA.Files.Sidebar.open(fInfo.filename);
// }
const list = [...anchorPhoto.d.detail];
const days = new Map<number, IDay>();
const dayIds = [];
let globalCount = 0;
let globalAnchor = -1;
let startIndex = -1;
for (const r of rows) {
if (r.type === IRowType.HEAD) {
if (r.day.dayid == anchorPhoto.d.dayid) {
startIndex = r.day.detail.findIndex(
(p) => p.fileid === anchorPhoto.fileid
);
globalAnchor = globalCount;
}
globalCount += r.day.count;
days.set(r.day.dayid, r.day);
dayIds.push(r.day.dayid);
}
}
const photoswipe = this.getBaseBox({
index: globalAnchor + startIndex,
});
photoswipe.addFilter("numItems", (numItems) => {
return globalCount;
});
photoswipe.addFilter("itemData", (itemData, index) => {
// Get photo object from list
let idx = index - globalAnchor;
if (idx < 0) {
// Load previous day
const firstDayId = list[0].d.dayid;
const firstDayIdx = utils.binarySearch(dayIds, firstDayId);
if (firstDayIdx === 0) {
// No previous day
return {};
}
const prevDayId = dayIds[firstDayIdx - 1];
const prevDay = days.get(prevDayId);
if (!prevDay.detail) {
console.error("[BUG] No detail for previous day");
return {};
}
list.unshift(...prevDay.detail);
globalAnchor -= prevDay.count;
} else if (idx >= list.length) {
// Load next day
const lastDayId = list[list.length - 1].d.dayid;
const lastDayIdx = utils.binarySearch(dayIds, lastDayId);
if (lastDayIdx === dayIds.length - 1) {
// No next day
return {};
}
const nextDayId = dayIds[lastDayIdx + 1];
const nextDay = days.get(nextDayId);
if (!nextDay.detail) {
console.error("[BUG] No detail for next day");
return {};
}
list.push(...nextDay.detail);
}
idx = index - globalAnchor;
const photo = list[idx];
// Something went really wrong
if (!photo) {
return {};
}
// Preload next and previous 3 days
const dayIdx = utils.binarySearch(dayIds, photo.d.dayid);
const preload = (idx: number) => {
if (idx > 0 && idx < dayIds.length && !days.get(dayIds[idx]).detail) {
this.opts.fetchDay(dayIds[idx]);
}
};
preload(dayIdx - 1);
preload(dayIdx - 2);
preload(dayIdx - 3);
preload(dayIdx + 1);
preload(dayIdx + 2);
preload(dayIdx + 3);
// Get thumb image
const thumbSrc: string =
this.thumbElem(photo)?.querySelector("img")?.getAttribute("src") ||
getPreviewUrl(photo, false, 256);
// Get full image
return {
src: getPreviewUrl(photo, false, 256),
msrc: thumbSrc,
width: photo.w || undefined,
height: photo.h || undefined,
thumbCropped: true,
};
});
photoswipe.addFilter("thumbEl", (thumbEl, data, index) => {
const photo = list[index - globalAnchor];
return this.thumbElem(photo) || thumbEl;
});
this.deleteClick = () => {
const idx = photoswipe.currIndex - globalAnchor;
const spliced = list.splice(idx, 1);
globalCount--;
for (let i = idx - 3; i <= idx + 3; i++) {
photoswipe.refreshSlideContent(i + globalAnchor);
}
this.opts.ondelete(spliced);
};
photoswipe.init();
}
private thumbElem(photo: IPhoto) {
if (!photo) return;
return document.getElementById(
`memories-photo-${photo.key || photo.fileid}`
);
}
}