/* 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; /** * 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; /** * 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 | 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) => ` `; const livePhotoVideoHTML = (videoURL: string) => ` `; 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; };