diff --git a/web/apps/photos/src/components/PhotoFrame.tsx b/web/apps/photos/src/components/PhotoFrame.tsx index 25576c0be62..7cd68255ab5 100644 --- a/web/apps/photos/src/components/PhotoFrame.tsx +++ b/web/apps/photos/src/components/PhotoFrame.tsx @@ -7,7 +7,7 @@ import { } from "@/gallery/services/download"; import { EnteFile } from "@/media/file"; import { FileType } from "@/media/file-type"; -import { FileViewer } from "@/new/photos/components/FileViewer"; +import { FileViewer } from "@/new/photos/components/FileViewerComponents"; import type { GalleryBarMode } from "@/new/photos/components/gallery/reducer"; import { TRASH_SECTION } from "@/new/photos/services/collection"; import { styled } from "@mui/material"; diff --git a/web/apps/photos/src/components/PhotoViewer/index.tsx b/web/apps/photos/src/components/PhotoViewer/index.tsx index 48b484bb7e9..41b83cc307a 100644 --- a/web/apps/photos/src/components/PhotoViewer/index.tsx +++ b/web/apps/photos/src/components/PhotoViewer/index.tsx @@ -19,7 +19,7 @@ import type { Collection } from "@/media/collection"; import { fileLogID, type EnteFile } from "@/media/file"; import { FileType } from "@/media/file-type"; import { isHEICExtension, needsJPEGConversion } from "@/media/formats"; -import { ConfirmDeleteFileDialog } from "@/new/photos/components/FileViewer"; +import { ConfirmDeleteFileDialog } from "@/new/photos/components/FileViewerComponents"; import { ImageEditorOverlay } from "@/new/photos/components/ImageEditorOverlay"; import { moveToTrash } from "@/new/photos/services/collection"; import { extractRawExif, parseExif } from "@/new/photos/services/exif"; @@ -992,8 +992,8 @@ export const PhotoViewer: React.FC = ({ onConfirm={handleDeleteFile} /> void; exif: FileInfoExif | undefined; /** * TODO: Rename and flip to allowEdits. @@ -138,14 +139,14 @@ export interface FileInfoProps { * Called when the user selects a person in the file info panel. */ onSelectPerson?: ((personID: string) => void) | undefined; -} +}; export const FileInfo: React.FC = ({ + open, + onClose, file, shouldDisableEdits, allowMap, - showInfo, - handleCloseInfo, exif, scheduleUpdate, refreshPhotoswipe, @@ -209,8 +210,8 @@ export const FileInfo: React.FC = ({ onSelectPerson?.(personID); return ( - - + + = ({ @@ -400,7 +401,16 @@ const parseExifInfo = ( const FileInfoSidebar = styled( (props: Pick) => ( - + ), )(({ theme }) => ({ zIndex: fileInfoDrawerZ, diff --git a/web/packages/new/photos/components/.gitignore b/web/packages/gallery/components/viewer/.gitignore similarity index 100% rename from web/packages/new/photos/components/.gitignore rename to web/packages/gallery/components/viewer/.gitignore diff --git a/web/packages/new/photos/components/FileViewer5.tsx b/web/packages/gallery/components/viewer/FileViewer.tsx similarity index 71% rename from web/packages/new/photos/components/FileViewer5.tsx rename to web/packages/gallery/components/viewer/FileViewer.tsx index 0b678f881d1..9deedd9180b 100644 --- a/web/packages/new/photos/components/FileViewer5.tsx +++ b/web/packages/gallery/components/viewer/FileViewer.tsx @@ -13,10 +13,12 @@ if (process.env.NEXT_PUBLIC_ENTE_WIP_PS5) { throw new Error("Whoa"); } +import { useModalVisibility } from "@/base/components/utils/modal"; +import { FileInfo } from "@/gallery/components/FileInfo"; import type { EnteFile } from "@/media/file.js"; import { Button, styled } from "@mui/material"; -import { useCallback, useEffect, useRef } from "react"; -import { FileViewerPhotoSwipe } from "./FileViewerPhotoSwipe"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { FileViewerPhotoSwipe } from "./photoswipe"; export interface FileViewerProps { /** @@ -57,8 +59,23 @@ const FileViewer: React.FC = ({ }) => { const pswpRef = useRef(); + // Whenever we get a callback from our custom PhotoSwipe instance, we also + // get the active file on which that action was performed as an argument. + // Save it as a prop so that the rest of our React tree can use it. + // + // This is not guaranteed, or even intended, to be in sync with the active + // file shown within the file viewer. All that this guarantees is this will + // refer to the file on which the last user initiated action was performed. + const [activeFile, setActiveFile] = useState( + undefined, + ); + + const { show: showFileInfo, props: fileInfoVisibilityProps } = + useModalVisibility(); + const handleViewInfo = useCallback((file: EnteFile) => { - console.log("view-info", file); + setActiveFile(file); + showFileInfo(); }, []); useEffect(() => { @@ -85,6 +102,7 @@ const FileViewer: React.FC = ({ return ( + ); }; diff --git a/web/packages/gallery/components/viewer/data-source.ts b/web/packages/gallery/components/viewer/data-source.ts new file mode 100644 index 00000000000..c2f96cb03cf --- /dev/null +++ b/web/packages/gallery/components/viewer/data-source.ts @@ -0,0 +1,181 @@ +/* xeslint-disable */ +// x-@ts-nocheck + +import { + downloadManager, + type LivePhotoSourceURL, +} from "@/gallery/services/download"; +import type { EnteFile } from "@/media/file"; +import { FileType } from "@/media/file-type"; + +// TODO(PS): +//import { type SlideData } from "./ps5/dist/types/slide/" +interface SlideData { + /** + * image URL + */ + src?: string | undefined; + /** + * image width + */ + width?: number | undefined; + /** + * image height + */ + height?: number | undefined; + /** + * html content of a slide + */ + html?: string | undefined; +} + +type ItemData = SlideData & { + // Our props. TODO(PS) document if end up using these. + videoURL?: string; + livePhotoVideoURL?: string; + isContentLoading?: boolean; + isContentZoomable?: boolean; +}; + +/** + * A class that stores and serves data required by our custom PhotoSwipe + * instance, effectively acting as an in-memory cache. + * + * By keeping this independent of the lifetime of the PhotoSwipe instance, we + * can reuse the same cache for multiple displays of our file viewer. + */ +export class FileViewerDataSource { + private itemDataByFileID = new Map(); + private needsRefreshByFileID = new Map void>(); + + /** + * Return the best available ItemData for rendering the given {@link file}. + * + * If an entry does not exist for a particular file, then it is lazily added + * on demand, and updated as we keep getting better data (thumbnail, + * original) for the file. + * + * At each step, we call the provided callback so that file viewer can call + * us again to get the updated data. + * + * --- + * + * Detailed flow: + * + * If we already have the final data about the file, then this function will + * return it and do nothing subsequently. + * + * Otherwise, it will: + * + * 1. Return empty slide data; PhotoSwipe will not show anything in the + * image area but will otherwise render UI controls properly (in most + * cases a cached renderable thumbnail URL will be available shortly) + * + * 2. Insert empty data so that we don't enqueue multiple updates, and + * return this empty data. + * + * Then it we start fetching data for the file. + * + * First it'll fetch the thumbnail. Once that is done, it'll update the data + * it has cached, and notify the caller (using the provided callback) so it + * can refresh the slide. + * + * Then it'll continue fetching the original. + * + * - For images and videos, this will be the single original. + * + * - For live photos, this will also be a two step process, first with the + * original image, then again with the video component. + * + * At this point, the data for this file will be considered final, and + * subsequent calls for the same file will return this same value unless it + * is invalidated. + * + * If at any point an error occurs, we reset our cache so that the next time + * the data is requested we repeat the process instead of continuing to + * serve the incomplete result. + */ + itemDataForFile(file: EnteFile, needsRefresh: () => void) { + let itemData = this.itemDataByFileID.get(file.id); + // We assume that there is only one file viewer that is using us + // at a given point of time. This assumption is currently valid. + this.needsRefreshByFileID.set(file.id, needsRefresh); + + if (!itemData) { + itemData = {}; + this.itemDataByFileID.set(file.id, itemData); + void this.enqueueUpdates(file); + } + + return itemData; + } + + private async enqueueUpdates(file: EnteFile) { + const update = (itemData: ItemData) => { + this.itemDataByFileID.set(file.id, itemData); + this.needsRefreshByFileID.get(file.id)?.(); + }; + + const thumbnailURL = await downloadManager.renderableThumbnailURL(file); + // TODO(PS): + const thumbnailData = await withDimensions(thumbnailURL!); + update({ + ...thumbnailData, + isContentLoading: true, + isContentZoomable: false, + }); + + switch (file.metadata.fileType) { + case FileType.image: { + const sourceURLs = + await downloadManager.renderableSourceURLs(file); + // TODO(PS): + const itemData = await withDimensions(sourceURLs.url as string); + update(itemData); + break; + } + + case FileType.video: { + const sourceURLs = + await downloadManager.renderableSourceURLs(file); + // TODO(PS): + update({ videoURL: sourceURLs.url as string }); + break; + } + + case FileType.livePhoto: { + const sourceURLs = + await downloadManager.renderableSourceURLs(file); + const livePhotoSourceURLs = + sourceURLs.url as LivePhotoSourceURL; + const imageURL = await livePhotoSourceURLs.image(); + // TODO(PS): + const imageData = await withDimensions(imageURL!); + update(imageData); + const livePhotoVideoURL = await livePhotoSourceURLs.video(); + update({ ...imageData, livePhotoVideoURL }); + break; + } + } + } +} + +/** + * Take a image URL, determine its dimensions using browser APIs, and return the URL + * and its dimensions in a form that can directly be passed to PhotoSwipe as + * {@link ItemData}. + */ +const withDimensions = (imageURL: string): Promise => + new Promise((resolve) => { + const image = new Image(); + image.onload = () => { + resolve({ + src: imageURL, + width: image.naturalWidth, + height: image.naturalHeight, + }); + }; + // image.onerror = () + // TODO(PS): Handle imageElement.onerror + image.src = imageURL; + }); diff --git a/web/packages/gallery/components/viewer/icons.tsx b/web/packages/gallery/components/viewer/icons.tsx new file mode 100644 index 00000000000..ef4fadd8e1c --- /dev/null +++ b/web/packages/gallery/components/viewer/icons.tsx @@ -0,0 +1,44 @@ +/** + * @file [Note: SVG paths of MUI icons] + * + * When creating buttons for use with PhotoSwipe, we need to provide just the + * contents of the SVG element (e.g. paths) as an HTML string. + * + * Since we only need a handful, these strings were created by temporarily + * adding the following code in some existing React component to render the + * corresponding MUI icon React component to a string, and retain the path. + * + * + * import { renderToString } from "react-dom/server"; + * import InfoOutlinedIcon from ; + * + * console.log(renderToString()); + */ + +const paths = { + // "@mui/icons-material/InfoOutlined" + info: '', + * outlineID: "pswp__icn-info", + * } + * + */ +export const createPSRegisterElementIconHTML = (name: "info") => ({ + isCustomSVG: true, + // TODO(PS): This transform is temporary, audit later. + inner: `${paths[name]} transform="translate(3.5, 3.5)" id="pswp__icn-${name}"`, + outlineID: `pswp__icn-${name}`, +}); diff --git a/web/packages/new/photos/components/FileViewerPhotoSwipe.tsx b/web/packages/gallery/components/viewer/photoswipe.ts similarity index 68% rename from web/packages/new/photos/components/FileViewerPhotoSwipe.tsx rename to web/packages/gallery/components/viewer/photoswipe.ts index db1771f3798..739abae54fe 100644 --- a/web/packages/new/photos/components/FileViewerPhotoSwipe.tsx +++ b/web/packages/gallery/components/viewer/photoswipe.ts @@ -1,17 +1,12 @@ /* eslint-disable */ // @ts-nocheck -import { assertionFailed } from "@/base/assert"; import log from "@/base/log"; -import { - downloadManager, - type LivePhotoSourceURL, -} from "@/gallery/services/download"; import type { EnteFile } from "@/media/file"; -import { FileType } from "@/media/file-type"; -import type { FileViewerProps } from "./FileViewer5"; - -// import { renderToString } from "react-dom/server"; +import { t } from "i18next"; +import { FileViewerDataSource } from "./data-source"; +import type { FileViewerProps } from "./FileViewer"; +import { createPSRegisterElementIconHTML } from "./icons"; // TODO(PS): WIP gallery using upstream photoswipe // @@ -30,58 +25,6 @@ if (process.env.NEXT_PUBLIC_ENTE_WIP_PS5) { // TODO(PS): Comment me before merging into main. // PhotoSwipe = require("./ps5/dist/photoswipe.esm.js").default; } -// TODO(PS): -//import { type SlideData } from "./ps5/dist/types/slide/" -type SlideData = { - /** - * thumbnail element - */ - element?: HTMLElement | undefined; - /** - * image URL - */ - src?: string | undefined; - /** - * image srcset - */ - srcset?: string | undefined; - /** - * image width (deprecated) - */ - w?: number | undefined; - /** - * image height (deprecated) - */ - h?: number | undefined; - /** - * image width - */ - width?: number | undefined; - /** - * image height - */ - height?: number | undefined; - /** - * placeholder image URL that's displayed before large image is loaded - */ - msrc?: string | undefined; - /** - * image alt text - */ - alt?: string | undefined; - /** - * whether thumbnail is cropped client-side or not - */ - thumbCropped?: boolean | undefined; - /** - * html content of a slide - */ - html?: string | undefined; - /** - * slide type - */ - type?: string | undefined; -}; type FileViewerPhotoSwipeOptions = FileViewerProps & { /** @@ -126,13 +69,12 @@ export class FileViewerPhotoSwipe { */ private opts: Pick; /** - * The best available SlideData for rendering the file with the given ID. + * Our data source. * - * If an entry does not exist for a particular fileID, then it is lazily - * added on demand. The same entry might get updated multiple times, as we - * start with the thumbnail but then also update this with the original etc. + * TODO(PS): Move this elsewhere, or merge with download manager. */ - private itemDataByFileID: Map = new Map(); + private dataSource: FileViewerDataSource; + /** * 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. @@ -161,6 +103,7 @@ export class FileViewerPhotoSwipe { }: FileViewerPhotoSwipeOptions) { this.files = files; this.opts = { disableDownload }; + this.dataSource = new FileViewerDataSource(); const pswp = new PhotoSwipe({ // Opaque background. @@ -195,6 +138,13 @@ export class FileViewerPhotoSwipe { // 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, @@ -211,36 +161,27 @@ export class FileViewerPhotoSwipe { }); pswp.addFilter("itemData", (_, index) => { - const file = files[index]; + const file = files[index]!; - // We might not have anything to show immediately, though in most - // cases a cached renderable thumbnail URL will be available - // shortly. - // - // Meanwhile, - // - // 1. Return empty slide data; PhotoSwipe will not show anything in - // the image area but will otherwise render UI controls properly. - // - // 2. Insert empty data so that we don't enqueue multiple updates. - - let itemData: SlideData | undefined; - if (file) { - itemData = this.itemDataByFileID.get(file.id); - if (!itemData) { - itemData = {}; - this.itemDataByFileID.set(file.id, itemData); - this.enqueueUpdates(index, file); - } + let itemData = this.dataSource.itemDataForFile(file, () => { + this.pswp.refreshSlideContent(index); + }); + + const { videoURL, ...rest } = itemData; + if (videoURL) { + const disableDownload = !!this.opts.disableDownload; + itemData = { + ...rest, + html: videoHTML(videoURL, disableDownload), + }; } log.debug(() => ["[viewer]", { index, itemData, file }]); - if (!file) assertionFailed(); if (this.lastActivityDate != "already-hidden") this.lastActivityDate = new Date(); - return itemData ?? {}; + return itemData; }); pswp.addFilter("isContentLoading", (isLoading, content) => { @@ -301,11 +242,22 @@ export class FileViewerPhotoSwipe { }); pswp.on("contentDeactivate", (e) => { - // Pause the video tag (if any) for a slide when we move away from it. - const video = e.content?.element?.getElementsByTagName("video")[0]; + // Pause the video element (if any) on a slide when we move away + // from it. + const video = + e.content?.slide?.container?.getElementsByTagName("video")[0]; video?.pause(); }); + pswp.on("contentActivate", (e) => { + // Undo the effect of a previous "contentDeactivate". + if (e.content?.slide.data?.livePhotoVideoURL) { + e.content?.slide?.container + ?.getElementsByTagName("video")[0] + ?.play(); + } + }); + // The user did some action within the file viewer to close it. pswp.on("close", () => { // Clear intervals. @@ -314,6 +266,9 @@ export class FileViewerPhotoSwipe { onClose(); }); + const withCurrentFile = (cb: (file: EnteFile) => void) => () => + cb(this.files[this.pswp.currIndex]!); + // Add our custom UI elements to inside the PhotoSwipe dialog. // // API docs for registerElement: @@ -324,32 +279,13 @@ export class FileViewerPhotoSwipe { // - zoom: 10 // - close: 20 pswp.on("uiRegister", () => { - // const html = ; - // console.log(renderToString(html)); - // const path = - // ''; - const pathWithIDAndTransform = - ''; pswp.ui.registerElement({ name: "info", - title: "Info", - ariaLabel: "Info", + title: t("info"), order: 15, isButton: true, - html: { - isCustomSVG: true, - inner: pathWithIDAndTransform, - outlineID: "pswp__icn-info", - }, - onClick: (e, element, pswp) => { - const file = this.files[pswp.currIndex]; - if (!file) { - assertionFailed(); - return; - } - - onViewInfo(file); - }, + html: createPSRegisterElementIconHTML("info"), + onClick: withCurrentFile(onViewInfo), }); }); @@ -448,72 +384,8 @@ export class FileViewerPhotoSwipe { // TODO(PS): Commented during testing // this.pswp.element.classList.remove("pswp--ui-visible"); } - - private async enqueueUpdates(index: number, file: EnteFile) { - const update = (itemData: SlideData) => { - this.itemDataByFileID.set(file.id, itemData); - this.pswp.refreshSlideContent(index); - }; - - const thumbnailURL = await downloadManager.renderableThumbnailURL(file); - const thumbnailData = await augmentedWithDimensions(thumbnailURL); - update({ - ...thumbnailData, - isContentLoading: true, - isContentZoomable: false, - }); - - switch (file.metadata.fileType) { - case FileType.image: { - const sourceURLs = - await downloadManager.renderableSourceURLs(file); - update(await augmentedWithDimensions(sourceURLs.url)); - break; - } - - case FileType.video: { - const sourceURLs = - await downloadManager.renderableSourceURLs(file); - const disableDownload = !!this.opts.disableDownload; - update({ html: videoHTML(sourceURLs.url, disableDownload) }); - break; - } - - default: { - const sourceURLs = - await downloadManager.renderableSourceURLs(file); - const livePhotoSourceURLs = - sourceURLs.url as LivePhotoSourceURL; - const imageURL = await livePhotoSourceURLs.image(); - const imageData = await augmentedWithDimensions(imageURL); - update(imageData); - const livePhotoVideoURL = await livePhotoSourceURLs.video(); - update({ ...imageData, livePhotoVideoURL }); - break; - } - } - } } -/** - * Take a image URL, determine its dimensions using browser APIs, and return the URL - * and its dimensions in a form that can directly be passed to PhotoSwipe as - * {@link SlideData}. - */ -const augmentedWithDimensions = (imageURL: string): Promise => - new Promise((resolve) => { - let image = new Image(); - image.onload = () => { - resolve({ - src: imageURL, - width: image.naturalWidth, - height: image.naturalHeight, - }); - }; - // TODO(PS): Handle imageElement.onerror - image.src = imageURL; - }); - const videoHTML = (url: string, disableDownload: boolean) => `