mirror of
https://github.com/ente-io/ente.git
synced 2025-07-12 09:58:48 +00:00
527 lines
19 KiB
TypeScript
527 lines
19 KiB
TypeScript
/* eslint-disable */
|
|
// @ts-nocheck
|
|
|
|
import log from "@/base/log";
|
|
import type { EnteFile } from "@/media/file";
|
|
import { FileType } from "@/media/file-type";
|
|
import { t } from "i18next";
|
|
import {
|
|
forgetExif,
|
|
forgetExifForItemData,
|
|
forgetFailedItemDataForFile,
|
|
forgetFailedItems,
|
|
itemDataForFile,
|
|
updateFileInfoExifIfNeeded,
|
|
} from "./data-source";
|
|
import type { FileViewerProps } from "./FileViewer";
|
|
import { createPSRegisterElementIconHTML } from "./icons";
|
|
|
|
// TODO(PS): WIP gallery using upstream photoswipe
|
|
//
|
|
// Needs (not committed yet):
|
|
// yarn workspace gallery add photoswipe@^5.4.4
|
|
// mv node_modules/photoswipe packages/new/photos/components/ps5
|
|
|
|
if (process.env.NEXT_PUBLIC_ENTE_WIP_PS5) {
|
|
console.warn("Using WIP upstream photoswipe");
|
|
} else {
|
|
throw new Error("Whoa");
|
|
}
|
|
|
|
let PhotoSwipe;
|
|
if (process.env.NEXT_PUBLIC_ENTE_WIP_PS5) {
|
|
// TODO(PS): Comment me before merging into main.
|
|
// PhotoSwipe = require("./ps5/dist/photoswipe.esm.js").default;
|
|
}
|
|
|
|
type FileViewerPhotoSwipeOptions = {
|
|
/**
|
|
* Called when the file viewer is closed.
|
|
*/
|
|
onClose: () => void;
|
|
/**
|
|
* Called whenever the slide changes to obtain the derived data for the file
|
|
* that is about to be displayed.
|
|
*/
|
|
onAnnotate: (file: EnteFile) => FileViewerFileAnnotation;
|
|
/**
|
|
* Called when the user activates the info action on a file.
|
|
*/
|
|
onViewInfo: (annotatedFile: FileViewerAnnotatedFile) => void;
|
|
} & Pick<FileViewerProps, "files" | "initialIndex" | "disableDownload">;
|
|
|
|
/**
|
|
* Derived data for a file that is needed to display the file viewer controls
|
|
* etc associated with the file.
|
|
*
|
|
* This is recomputed on-demand each time the slide changes.
|
|
*/
|
|
export interface FileViewerFileAnnotation {
|
|
/**
|
|
* The id of the file whose annotation this is.
|
|
*/
|
|
fileID: number;
|
|
/**
|
|
* `true` if this file is owned by the logged in user (if any).
|
|
*/
|
|
isOwnFile: boolean;
|
|
}
|
|
|
|
/**
|
|
* A file and its annotation, in a nice cosy box.
|
|
*/
|
|
export interface FileViewerAnnotatedFile {
|
|
file: EnteFile;
|
|
annotation: FileViewerFileAnnotation;
|
|
}
|
|
|
|
/**
|
|
* A wrapper over {@link PhotoSwipe} to tailor its interface for use by our file
|
|
* viewer.
|
|
*
|
|
* This is somewhat akin to the {@link PhotoSwipeLightbox}, except this doesn't
|
|
* have any UI of its own, it only modifies PhotoSwipe Core's behaviour.
|
|
*
|
|
* [Note: PhotoSwipe]
|
|
*
|
|
* PhotoSwipe is a library that behaves similarly to the OG "lightbox" image
|
|
* gallery JavaScript component from the middle ages.
|
|
*
|
|
* We don't need the lightbox functionality since we already have our own
|
|
* thumbnail list (the "gallery"), so we only use the "Core" PhotoSwipe module
|
|
* as our image viewer component.
|
|
*
|
|
* When the user clicks on one of the thumbnails in our gallery, we make the
|
|
* root PhotoSwipe component visible. Within the DOM this is a dialog-like div
|
|
* that takes up the entire viewport, shows the image, various controls etc.
|
|
*
|
|
* Documentation: https://photoswipe.com/.
|
|
*/
|
|
export class FileViewerPhotoSwipe {
|
|
/**
|
|
* The PhotoSwipe instance which we wrap.
|
|
*/
|
|
private pswp: PhotoSwipe;
|
|
/**
|
|
* The options with which we were initialized.
|
|
*/
|
|
private opts: Pick<FileViewerPhotoSwipeOptions, "disableDownload">;
|
|
/**
|
|
* An interval that invokes a periodic check of whether we should the hide
|
|
* controls if the user does not perform any pointer events for a while.
|
|
*/
|
|
private autoHideCheckIntervalId: ReturnType<typeof setTimeout> | undefined;
|
|
/**
|
|
* The time the last activity occurred. Used in tandem with
|
|
* {@link autoHideCheckIntervalId} to implement the auto hiding of controls
|
|
* when the user stops moving the pointer for a while.
|
|
*
|
|
* Apart from a date, this can also be:
|
|
*
|
|
* - "already-hidden" if controls have already been hidden, say by a
|
|
* bgClickAction.
|
|
*
|
|
* - "auto-hidden" if controls were hidden by us because of inactivity.
|
|
*/
|
|
private lastActivityDate: Date | "auto-hidden" | "already-hidden";
|
|
/**
|
|
* Derived data about the currently displayed file.
|
|
*
|
|
* This is recomputed on-demand (by using the {@link onAnnotate} callback)
|
|
* each time the slide changes, and cached until the next slide change.
|
|
*
|
|
* Instead of accessing this property directly, code should funnel through
|
|
* the `activeFileAnnotation` helper function defined in the constructor
|
|
* scope.
|
|
*/
|
|
private activeFileAnnotation: FileViewerFileAnnotation | undefined;
|
|
|
|
constructor({
|
|
files,
|
|
initialIndex,
|
|
disableDownload,
|
|
onClose,
|
|
onAnnotate,
|
|
onViewInfo,
|
|
}: FileViewerPhotoSwipeOptions) {
|
|
this.files = files;
|
|
this.opts = { disableDownload };
|
|
this.lastActivityDate = new Date();
|
|
|
|
const pswp = new PhotoSwipe({
|
|
// Opaque background.
|
|
bgOpacity: 1,
|
|
// The default, "zoom", cannot be used since we're not animating
|
|
// from a thumbnail, so effectively "fade" is in effect anyway. Set
|
|
// it still, just for and explicitness and documentation.
|
|
showHideAnimationType: "fade",
|
|
// The default imageClickAction is "zoom-or-close". When the image
|
|
// is small and cannot be zoomed into further (which is common when
|
|
// just the thumbnail has been loaded), this causes PhotoSwipe to
|
|
// close. Disable this behaviour.
|
|
clickToCloseNonZoomable: false,
|
|
// The default `bgClickAction` is "close", but it is not always
|
|
// apparent where the background is and where the controls are,
|
|
// since everything is black, and so accidentally closing PhotoSwipe
|
|
// is easy.
|
|
//
|
|
// Disable this behaviour, instead repurposing this action to behave
|
|
// the same as the `tapAction` ("tap on PhotoSwipe viewport
|
|
// content") and toggle the visibility of UI controls (We also have
|
|
// auto hide based on mouse activity, but that would not have any
|
|
// effect on touch devices)
|
|
bgClickAction: "toggle-controls",
|
|
// At least on macOS, manual zooming with the trackpad is very
|
|
// cumbersome (possibly because of the small multiplier in the
|
|
// PhotoSwipe source, but I'm not sure). The other option to do a
|
|
// manual zoom is to scroll (e.g. with the trackpad) but with the
|
|
// CTRL key pressed, however on macOS this invokes the system zoom
|
|
// if enabled in accessibility settings.
|
|
//
|
|
// Taking a step back though, the PhotoSwipe viewport is fixed, so
|
|
// we can just directly map wheel / trackpad scrolls to zooming.
|
|
wheelToZoom: true,
|
|
// Chrome yells about incorrectly mixing focus and aria-hidden if we
|
|
// leave this at the default (true) and then swipe between slides
|
|
// fast, or show MUI drawers etc.
|
|
//
|
|
// See: [Note: Overzealous Chrome? Complicated ARIA?], but time with
|
|
// a different library.
|
|
trapFocus: false,
|
|
// Set the index within files that we should open to. Subsequent
|
|
// updates to the index will be tracked by PhotoSwipe internally.
|
|
index: initialIndex,
|
|
// TODO(PS): padding option? for handling custom title bar.
|
|
// TODO(PS): will we need this?
|
|
mainClass: "pswp-ente",
|
|
// Translated variants
|
|
closeTitle: t("close_key"),
|
|
zoomTitle: t("zoom_in_out_key") /* TODO(PS): Add "(scroll)" */,
|
|
arrowPrevTitle: t("previous_key"),
|
|
arrowNextTitle: t("next_key"),
|
|
// TODO(PS): Move to translations (unpreviewable_file_notification).
|
|
errorMsg: "This file could not be previewed",
|
|
});
|
|
|
|
this.pswp = pswp;
|
|
|
|
// Helper routines to obtain the file at `currIndex`.
|
|
|
|
const currentFile = () => this.files[pswp.currIndex]!;
|
|
|
|
const currentAnnotatedFile = () => {
|
|
const file = currentFile();
|
|
let annotation = this.activeFileAnnotation;
|
|
if (annotation?.fileID != file.id) {
|
|
annotation = onAnnotate(file);
|
|
this.activeFileAnnotation = annotation;
|
|
}
|
|
return {
|
|
file,
|
|
// The above condition implies that annotation can never be
|
|
// undefined, but it doesn't seem to be enough to convince
|
|
// TypeScript. Writing the condition in a more unnatural way
|
|
// `(annotation && annotation?.fileID == file.id)` works, but
|
|
// instead we use a non-null assertion here.
|
|
annotation: annotation!,
|
|
};
|
|
};
|
|
|
|
const currentFileAnnotation = () => currentAnnotatedFile().annotation;
|
|
|
|
const withCurrentAnnotatedFile =
|
|
(cb: (af: AnnotatedFile) => void) => () =>
|
|
cb(currentFileAnnotation());
|
|
|
|
// Provide data about slides to PhotoSwipe via callbacks
|
|
// https://photoswipe.com/data-sources/#dynamically-generated-data
|
|
|
|
pswp.addFilter("numItems", () => {
|
|
return this.files.length;
|
|
});
|
|
|
|
pswp.addFilter("itemData", (_, index) => {
|
|
const file = files[index]!;
|
|
|
|
let itemData = itemDataForFile(file, () => {
|
|
this.pswp.refreshSlideContent(index);
|
|
});
|
|
|
|
const { fileType, videoURL, ...rest } = itemData;
|
|
if (fileType === FileType.video && videoURL) {
|
|
const disableDownload = !!this.opts.disableDownload;
|
|
itemData = {
|
|
...rest,
|
|
html: videoHTML(videoURL, disableDownload),
|
|
};
|
|
}
|
|
|
|
log.debug(() => ["[viewer]", { index, itemData, file }]);
|
|
|
|
if (this.lastActivityDate != "already-hidden")
|
|
this.lastActivityDate = new Date();
|
|
|
|
return itemData;
|
|
});
|
|
|
|
pswp.addFilter("isContentLoading", (isLoading, content) => {
|
|
return content.data.isContentLoading ?? isLoading;
|
|
});
|
|
|
|
pswp.addFilter("isContentZoomable", (isZoomable, content) => {
|
|
return content.data.isContentZoomable ?? isZoomable;
|
|
});
|
|
|
|
pswp.addFilter("preventPointerEvent", (preventPointerEvent) => {
|
|
// There was a pointer event. We don't care which one, we just use
|
|
// this as a hook to show the UI again (if needed), and update our
|
|
// last activity date.
|
|
this.onPointerActivity();
|
|
return preventPointerEvent;
|
|
});
|
|
|
|
pswp.on("contentAppend", (e) => {
|
|
const { fileType, videoURL } = e.content.data;
|
|
if (fileType !== FileType.livePhoto) return;
|
|
if (!videoURL) return;
|
|
|
|
// This slide is displaying a live photo. Append a video element to
|
|
// show its video part.
|
|
|
|
const img = e.content.element;
|
|
const video = createElementFromHTMLString(
|
|
livePhotoVideoHTML(videoURL),
|
|
);
|
|
const container = e.content.slide.container;
|
|
container.style = "position: relative";
|
|
container.appendChild(video);
|
|
// Set z-index to 1 to keep it on top, and set pointer-events to
|
|
// none to pass the clicks through.
|
|
video.style =
|
|
"position: absolute; top: 0; left: 0; z-index: 1; pointer-events: none;";
|
|
|
|
// Size it to the underlying image.
|
|
video.style.width = img.style.width;
|
|
video.style.height = img.style.height;
|
|
});
|
|
|
|
pswp.on("imageSizeChange", ({ content, width, height }) => {
|
|
if (content.data.fileType !== FileType.livePhoto) return;
|
|
|
|
// This slide is displaying a live photo. Resize the size of the
|
|
// video element to match that of the image.
|
|
|
|
const video =
|
|
content.slide.container.getElementsByTagName("video")[0];
|
|
if (!video) {
|
|
// We might have been called before "contentAppend".
|
|
return;
|
|
}
|
|
|
|
video.style.width = `${width}px`;
|
|
video.style.height = `${height}px`;
|
|
});
|
|
|
|
pswp.on("contentDeactivate", (e) => {
|
|
// Reset failures, if any, for this file so that the fetch is tried
|
|
// again when we come back to it^.
|
|
//
|
|
// ^ Note that because of how the preloading works, this will have
|
|
// an effect (i.e. the retry will happen) only if the user moves
|
|
// more than 2 slides and then back, or if they reopen the viewer.
|
|
//
|
|
// See: [Note: File viewer error handling]
|
|
forgetFailedItemDataForFile(currentFile());
|
|
|
|
// Pause the video element, if any, when we move away from the
|
|
// slide.
|
|
const video =
|
|
e.content?.slide?.container?.getElementsByTagName("video")[0];
|
|
video?.pause();
|
|
});
|
|
|
|
pswp.on("contentActivate", (e) => {
|
|
// Undo the effect of a previous "contentDeactivate" if it was
|
|
// displaying a live photo.
|
|
if (e.content?.slide.data?.fileType === FileType.livePhoto) {
|
|
e.content?.slide?.container
|
|
?.getElementsByTagName("video")[0]
|
|
?.play();
|
|
}
|
|
});
|
|
|
|
pswp.on("loadComplete", (e) =>
|
|
updateFileInfoExifIfNeeded(e.content.data),
|
|
);
|
|
|
|
// pswp.on("change", (e) => {
|
|
// const itemData = pswp.currSlide.content.data;
|
|
// exifForItemData(itemData).then((data) =>
|
|
// console.log("exif data", data),
|
|
// );
|
|
// });
|
|
|
|
pswp.on("contentDestroy", (e) => forgetExifForItemData(e.content.data));
|
|
|
|
// The PhotoSwipe dialog has being closed and the animations have
|
|
// completed.
|
|
pswp.on("destroy", () => {
|
|
this.clearAutoHideIntervalIfNeeded();
|
|
forgetFailedItems();
|
|
forgetExif();
|
|
// Let our parent know that we have been closed.
|
|
onClose();
|
|
});
|
|
|
|
// Add our custom UI elements to inside the PhotoSwipe dialog.
|
|
//
|
|
// API docs for registerElement:
|
|
// https://photoswipe.com/adding-ui-elements/#uiregisterelement-api
|
|
//
|
|
// The "order" prop is used to position items. Some landmarks:
|
|
// - counter: 5
|
|
// - preloader: 7
|
|
// - zoom: 10
|
|
// - close: 20
|
|
pswp.on("uiRegister", () => {
|
|
pswp.ui.registerElement({
|
|
name: "error",
|
|
order: 6,
|
|
// TODO(PS): Change color?
|
|
html: createPSRegisterElementIconHTML("error"),
|
|
onInit: (errorElement, pswp) => {
|
|
pswp.on("change", () => {
|
|
const { fetchFailed, isContentLoading } =
|
|
pswp.currSlide.content.data;
|
|
errorElement.classList.toggle(
|
|
"pswp__error--active",
|
|
!!fetchFailed && !isContentLoading,
|
|
);
|
|
});
|
|
},
|
|
});
|
|
pswp.ui.registerElement({
|
|
name: "info",
|
|
title: t("info"),
|
|
order: 15,
|
|
isButton: true,
|
|
html: createPSRegisterElementIconHTML("info"),
|
|
onClick: withCurrentAnnotatedFile(onViewInfo),
|
|
});
|
|
});
|
|
|
|
// Modify the default UI elements.
|
|
pswp.addFilter("uiElement", (element, data) => {
|
|
if (element.name == "preloader") {
|
|
// TODO(PS): Left as an example. For now, this is customized in
|
|
// the CSS.
|
|
}
|
|
return element;
|
|
});
|
|
|
|
// Initializing PhotoSwipe adds it to the DOM as a dialog-like div with
|
|
// the class "pswp".
|
|
pswp.init();
|
|
|
|
this.autoHideCheckIntervalId = setInterval(() => {
|
|
this.autoHideIfInactive();
|
|
}, 1000);
|
|
}
|
|
|
|
/**
|
|
* Close this instance of {@link FileViewerPhotoSwipe} if it hasn't itself
|
|
* initiated the close.
|
|
*
|
|
* This instance **cannot** be used after this function has been called.
|
|
*/
|
|
closeIfNeeded() {
|
|
// Closing PhotoSwipe removes it from the DOM.
|
|
//
|
|
// This will only have an effect if we're being closed externally (e.g.
|
|
// if the user selects an album in the file info).
|
|
//
|
|
// If this cleanup function is running in the sequence where we were
|
|
// closed internally (e.g. the user activated the close button within
|
|
// the file viewer), then PhotoSwipe will ignore this extra close.
|
|
this.pswp.close();
|
|
}
|
|
|
|
updateFiles(files: EnteFile[]) {
|
|
// TODO(PS)
|
|
}
|
|
|
|
private clearAutoHideIntervalIfNeeded() {
|
|
if (this.autoHideCheckIntervalId) {
|
|
clearInterval(this.autoHideCheckIntervalId);
|
|
this.autoHideCheckIntervalId = undefined;
|
|
}
|
|
}
|
|
|
|
private onPointerActivity() {
|
|
if (this.lastActivityDate == "already-hidden") return;
|
|
if (this.lastActivityDate == "auto-hidden") this.showUIControls();
|
|
this.lastActivityDate = new Date();
|
|
}
|
|
|
|
private autoHideIfInactive() {
|
|
if (this.lastActivityDate == "already-hidden") return;
|
|
if (this.lastActivityDate == "auto-hidden") return;
|
|
if (Date.now() - this.lastActivityDate.getTime() > 5000 /* 5s */) {
|
|
if (this.areUIControlsVisible()) {
|
|
this.hideUIControlsIfNotFocused();
|
|
this.lastActivityDate = "auto-hidden";
|
|
} else {
|
|
this.lastActivityDate = "already-hidden";
|
|
}
|
|
}
|
|
}
|
|
|
|
private areUIControlsVisible() {
|
|
return this.pswp.element.classList.contains("pswp--ui-visible");
|
|
}
|
|
|
|
private showUIControls() {
|
|
this.pswp.element.classList.add("pswp--ui-visible");
|
|
}
|
|
|
|
private hideUIControlsIfNotFocused() {
|
|
// Check if the current keyboard focus is on any of the UI controls.
|
|
//
|
|
// By default, the pswp root element takes up the keyboard focus, so we
|
|
// check if the currently focused element is still the PhotoSwipe dialog
|
|
// (if so, this means we're not focused on a specific control).
|
|
const isDefaultFocus = document
|
|
.querySelector(":focus-visible")
|
|
?.classList.contains("pswp");
|
|
if (!isDefaultFocus) {
|
|
// The user focused (e.g. via keyboard tabs) to a specific UI
|
|
// element. Skip auto hiding.
|
|
return;
|
|
}
|
|
|
|
// TODO(PS): Commented during testing
|
|
// this.pswp.element.classList.remove("pswp--ui-visible");
|
|
}
|
|
}
|
|
|
|
const videoHTML = (url: string, disableDownload: boolean) => `
|
|
<video controls ${disableDownload && "controlsList=nodownload"} oncontextmenu="return false;">
|
|
<source src="${url}" />
|
|
Your browser does not support video playback.
|
|
</video>
|
|
`;
|
|
|
|
const livePhotoVideoHTML = (videoURL: string) => `
|
|
<video autoplay loop muted oncontextmenu="return false;">
|
|
<source src="${videoURL}" />
|
|
</video>
|
|
`;
|
|
|
|
const createElementFromHTMLString = (htmlString: string) => {
|
|
const template = document.createElement("template");
|
|
// Excess whitespace causes excess DOM nodes, causing our firstChild to not
|
|
// be what we wanted them to be.
|
|
template.innerHTML = htmlString.trim();
|
|
return template.content.firstChild;
|
|
};
|