[web] Use upstream PhotoSwipe (Much WIP) (#5066)

This commit is contained in:
Manav Rathi
2025-02-14 08:54:25 +05:30
committed by GitHub
3 changed files with 207 additions and 70 deletions

View File

@@ -114,3 +114,16 @@ body {
.pswp__caption--empty {
display: none;
}
/*
Make the controllable video elements we render as custom PhotoSwipe content
take up the entire container.
*/
.pswp-ente video[controls] {
width: 100%;
height: 100%;
}
.pswp-ente .pswp__preloader--active .pswp__icn {
opacity: 0.4;
}

View File

@@ -15,7 +15,7 @@ if (process.env.NEXT_PUBLIC_ENTE_WIP_PS5) {
import type { EnteFile } from "@/media/file.js";
import { Button, styled } from "@mui/material";
import { useEffect, useRef } from "react";
import { useCallback, useEffect, useRef } from "react";
import { FileViewerPhotoSwipe } from "./FileViewerPhotoSwipe";
export interface FileViewerProps {
@@ -57,6 +57,10 @@ const FileViewer: React.FC<FileViewerProps> = ({
}) => {
const pswpRef = useRef<FileViewerPhotoSwipe | undefined>();
const handleViewInfo = useCallback((file: EnteFile) => {
console.log("view-info", file);
}, []);
useEffect(() => {
if (!open) {
// The close state will be handled by the cleanup function.
@@ -66,8 +70,9 @@ const FileViewer: React.FC<FileViewerProps> = ({
const pswp = new FileViewerPhotoSwipe({
files,
initialIndex,
onClose,
disableDownload,
onClose,
onViewInfo: handleViewInfo,
});
pswpRef.current = pswp;

View File

@@ -11,6 +11,8 @@ import type { EnteFile } from "@/media/file";
import { FileType } from "@/media/file-type";
import type { FileViewerProps } from "./FileViewer5";
// import { renderToString } from "react-dom/server";
// TODO(PS): WIP gallery using upstream photoswipe
//
// Needs (not committed yet):
@@ -86,6 +88,10 @@ type FileViewerPhotoSwipeOptions = FileViewerProps & {
* Called when the file viewer is closed.
*/
onClose: () => void;
/**
* Called when the user activates the info action on a file.
*/
onViewInfo: (file: EnteFile) => void;
};
/**
@@ -149,8 +155,9 @@ export class FileViewerPhotoSwipe {
constructor({
files,
initialIndex,
onClose,
disableDownload,
onClose,
onViewInfo,
}: FileViewerPhotoSwipeOptions) {
this.files = files;
this.opts = { disableDownload };
@@ -158,6 +165,10 @@ export class FileViewerPhotoSwipe {
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
@@ -189,7 +200,7 @@ export class FileViewerPhotoSwipe {
index: initialIndex,
// TODO(PS): padding option? for handling custom title bar.
// TODO(PS): will we need this?
mainClass: "our-extra-pswp-main-class",
mainClass: "pswp-ente",
});
// Provide data about slides to PhotoSwipe via callbacks
@@ -202,29 +213,28 @@ export class FileViewerPhotoSwipe {
pswp.addFilter("itemData", (_, 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) {
// We don't 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
// the surrounding UI properly.
//
// 2. Insert empty data so that we don't enqueue multiple
// updates.
itemData = {};
this.itemDataByFileID.set(file.id, itemData);
this.enqueueUpdates(index, file);
}
}
log.debug(() => ["[ps]", { itemData, index, file, itemData }]);
log.debug(() => ["[viewer]", { index, itemData, file }]);
if (!file) assertionFailed();
if (this.lastActivityDate != "already-hidden")
@@ -233,45 +243,125 @@ export class FileViewerPhotoSwipe {
return itemData ?? {};
});
pswp.addFilter("preventPointerEvent", (originalResult) => {
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 UI again (if needed) and update our last
// activity date.
// this as a hook to show the UI again (if needed), and update our
// last activity date.
this.onPointerActivity();
return originalResult;
return preventPointerEvent;
});
pswp.on("contentLoad", (e) => {
console.log("contentLoad", e);
if (e.content.data.videoURL) {
const holderEl = e.content.slide.holderElement;
const vid = document.createElement("h1");
vid.innerText = "Test 1";
holderEl.appendChild(vid);
}
});
pswp.on("contentAppend", (e) => {
const containerEl = e.content.slide.container;
console.log("contentAppend", containerEl);
if (e.content.data.videoURL) {
const vid = document.createElement("div");
vid.innerHTML = livePhotoVideoHTML(e.content.data.videoURL);
// vid.innerText = "Test 2";
containerEl.appendChild(vid);
vid.style =
"position: absolute; left: 0; right: 0; width: 100%; height: 100%; z-index: 1; pointer-events: none;";
}
const videoURL = e.content.data.livePhotoVideoURL;
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.livePhotoVideoURL) 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) => {
// Pause the video tag (if any) for a slide when we move away from it.
const video = e.content?.element?.getElementsByTagName("video")[0];
video?.pause();
});
// The user did some action within the file viewer to close it.
pswp.on("close", () => {
// The user did some action within the file viewer to close it.
//
// Clear intervals.
clearIntervals();
this.clearAutoHideIntervalIfNeeded();
// 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
// - zoom: 10
// - close: 20
pswp.on("uiRegister", () => {
// const html = <InfoOutlinedIcon fontSize="32" />;
// console.log(renderToString(html));
// const path =
// '<path d="M11 7h2v2h-2zm0 4h2v6h-2zm1-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2m0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8"></path>';
const pathWithIDAndTransform =
'<path d="M11 7h2v2h-2zm0 4h2v6h-2zm1-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2m0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8" transform="translate(3.5, 3.5)" id="pswp__icn-info" />';
pswp.ui.registerElement({
name: "info",
title: "Info",
ariaLabel: "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);
},
});
});
// 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();
@@ -324,7 +414,7 @@ export class FileViewerPhotoSwipe {
if (this.lastActivityDate == "auto-hidden") return;
if (Date.now() - this.lastActivityDate.getTime() > 3000) {
if (this.areUIControlsVisible()) {
this.hideUIControls();
this.hideUIControlsIfNotFocused();
this.lastActivityDate = "auto-hidden";
} else {
this.lastActivityDate = "already-hidden";
@@ -340,8 +430,23 @@ export class FileViewerPhotoSwipe {
this.pswp.element.classList.add("pswp--ui-visible");
}
private hideUIControls() {
this.pswp.element.classList.remove("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");
}
private async enqueueUpdates(index: number, file: EnteFile) {
@@ -351,21 +456,18 @@ export class FileViewerPhotoSwipe {
};
const thumbnailURL = await downloadManager.renderableThumbnailURL(file);
// We don't have the dimensions of the thumbnail. We could try to deduce
// something from the file's aspect ratio etc, but that's not needed:
// PhotoSwipe already correctly (for our purposes) handles just a source
// URL being present.
update({ src: thumbnailURL });
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({
src: sourceURLs.url,
width: file.pubMagicMetadata?.data?.w,
height: file.pubMagicMetadata?.data?.h,
});
update(await augmentedWithDimensions(sourceURLs.url));
break;
}
@@ -383,26 +485,35 @@ export class FileViewerPhotoSwipe {
const livePhotoSourceURLs =
sourceURLs.url as LivePhotoSourceURL;
const imageURL = await livePhotoSourceURLs.image();
update({
src: imageURL,
width: file.pubMagicMetadata?.data?.w,
height: file.pubMagicMetadata?.data?.h,
});
const videoURL = await livePhotoSourceURLs.video();
console.log(videoURL);
// update({ html: livePhotoVideoHTML(videoURL) });
update({
src: imageURL,
width: file.pubMagicMetadata?.data?.w,
height: file.pubMagicMetadata?.data?.h,
videoURL,
});
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<SlideData> =>
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) => `
<video controls ${disableDownload && "controlsList=nodownload"} oncontextmenu="return false;">
<source src="${url}" />
@@ -415,3 +526,11 @@ const livePhotoVideoHTML = (videoURL: string) => `
<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;
};