mirror of
https://github.com/ente-io/ente.git
synced 2025-07-04 14:36:53 +00:00
467 lines
16 KiB
TypeScript
467 lines
16 KiB
TypeScript
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
|
// @ts-nocheck
|
|
|
|
// 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");
|
|
}
|
|
|
|
import { isDesktop } from "@/base/app";
|
|
import { type ModalVisibilityProps } from "@/base/components/utils/modal";
|
|
import { useBaseContext } from "@/base/context";
|
|
import { lowercaseExtension } from "@/base/file-name";
|
|
import { pt } from "@/base/i18n";
|
|
import type { LocalUser } from "@/base/local-user";
|
|
import log from "@/base/log";
|
|
import {
|
|
FileInfo,
|
|
type FileInfoExif,
|
|
type FileInfoProps,
|
|
} from "@/gallery/components/FileInfo";
|
|
import type { Collection } from "@/media/collection";
|
|
import { FileType } from "@/media/file-type";
|
|
import type { EnteFile } from "@/media/file.js";
|
|
import { isHEICExtension, needsJPEGConversion } from "@/media/formats";
|
|
import {
|
|
ImageEditorOverlay,
|
|
type ImageEditorOverlayProps,
|
|
} from "@/new/photos/components/ImageEditorOverlay";
|
|
import { Button, Menu, MenuItem, styled } from "@mui/material";
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { fileInfoExifForFile } from "./data-source";
|
|
import {
|
|
FileViewerPhotoSwipe,
|
|
moreButtonID,
|
|
moreMenuID,
|
|
resetMoreMenuButtonOnMenuClose,
|
|
type FileViewerAnnotatedFile,
|
|
type FileViewerFileAnnotation,
|
|
type FileViewerPhotoSwipeDelegate,
|
|
} from "./photoswipe";
|
|
|
|
export type FileViewerProps = ModalVisibilityProps & {
|
|
/**
|
|
* The currently logged in user, if any.
|
|
*
|
|
* - If we're running in the context of the photos app, then this should be
|
|
* set to the currently logged in user.
|
|
*
|
|
* - If we're running in the context of the public albums app, then this
|
|
* should not be set.
|
|
*
|
|
* See: [Note: Gallery children can assume user]
|
|
*/
|
|
user?: LocalUser;
|
|
/**
|
|
* The list of files that are currently being displayed in the context in
|
|
* which the file viewer was invoked.
|
|
*
|
|
* Although the file viewer is called on to display a particular file
|
|
* (specified by the {@link initialIndex} prop), the viewer is always used
|
|
* in the context of a an album, or search results, or some other arbitrary
|
|
* list of files. The {@link files} prop sets this underlying list of files.
|
|
*
|
|
* After the initial file has been shown, the user can navigate through the
|
|
* other files from within the viewer by using the arrow buttons.
|
|
*/
|
|
files: EnteFile[];
|
|
/**
|
|
* The index of the file that should be initially shown.
|
|
*
|
|
* Subsequently the user may navigate between files by using the controls
|
|
* provided within the file viewer itself.
|
|
*/
|
|
initialIndex: number;
|
|
/**
|
|
* `true` when we are viewing files in the Trash.
|
|
*/
|
|
isInTrashSection?: boolean;
|
|
/**
|
|
* `true` when we are viewing files in the hidden section.
|
|
*/
|
|
isInHiddenSection?: boolean;
|
|
/**
|
|
* If true then the viewer does not show controls for downloading the file.
|
|
*/
|
|
disableDownload?: boolean;
|
|
/**
|
|
* File IDs of all the files that the user has marked as a favorite.
|
|
*
|
|
* If this is not provided then the favorite toggle button will not be shown
|
|
* in the file actions.
|
|
*/
|
|
favoriteFileIDs?: Set<number>;
|
|
/**
|
|
* Called when there was some update performed within the file viewer that
|
|
* necessitates us to sync with remote again to fetch the latest updates.
|
|
*
|
|
* This is called lazily, and at most once, when the file viewer is closing
|
|
* if any changes were made in the file info panel of the file viewer for
|
|
* any of the files that the user was viewing (e.g. if they changed the
|
|
* caption). Those changes have already been applied to both remote and to
|
|
* the in-memory file object used by the file viewer; this callback is to
|
|
* trigger a sync so that our local database also gets up to speed.
|
|
*
|
|
* If we're in a context where edits are not possible, e.g. {@link user} is
|
|
* not defined, then this prop is not used.
|
|
*/
|
|
onTriggerSyncWithRemote?: () => void;
|
|
/**
|
|
* Called when the favorite status of given {@link file} should be toggled
|
|
* from its current value.
|
|
*
|
|
* If this is not provided then the favorite toggle button will not be shown
|
|
* in the file actions.
|
|
*
|
|
* See also {@link favoriteFileIDs}.
|
|
*/
|
|
onToggleFavorite?: (file: EnteFile) => Promise<void>;
|
|
/**
|
|
* Called when the user edits an image in the image editor and asks us to
|
|
* save their edits as a copy.
|
|
*
|
|
* Editing is disabled if this is not provided.
|
|
*
|
|
* See {@link onSaveEditedCopy} in the {@link ImageEditorOverlay} props for
|
|
* documentation about the parameters.
|
|
*/
|
|
onSaveEditedImageCopy?: ImageEditorOverlayProps["onSaveEditedCopy"];
|
|
} & Pick<
|
|
FileInfoProps,
|
|
| "fileCollectionIDs"
|
|
| "allCollectionsNameByID"
|
|
| "onSelectCollection"
|
|
| "onSelectPerson"
|
|
>;
|
|
|
|
/**
|
|
* A PhotoSwipe based image and video viewer.
|
|
*/
|
|
const FileViewer: React.FC<FileViewerProps> = ({
|
|
open,
|
|
onClose,
|
|
user,
|
|
files,
|
|
initialIndex,
|
|
isInTrashSection,
|
|
isInHiddenSection,
|
|
disableDownload,
|
|
favoriteFileIDs,
|
|
fileCollectionIDs,
|
|
allCollectionsNameByID,
|
|
onTriggerSyncWithRemote,
|
|
onToggleFavorite,
|
|
onSelectCollection,
|
|
onSelectPerson,
|
|
onSaveEditedImageCopy,
|
|
}) => {
|
|
const { onGenericError } = useBaseContext();
|
|
|
|
// There are 3 things involved in this dance:
|
|
//
|
|
// 1. Us, "FileViewer". We're a React component.
|
|
// 2. The custom PhotoSwipe wrapper, "FileViewerPhotoSwipe". It is a class.
|
|
// 3. The delegate, "FileViewerPhotoSwipeDelegate".
|
|
//
|
|
// The delegate acts as a bridge between us and (our custom) photoswipe
|
|
// class, to avoid recreating the class each time a "dynamic" prop changes.
|
|
// The delegate has a stable identity, we just keep updating the callback
|
|
// functions that it holds.
|
|
//
|
|
// The word "dynamic" here means a prop on whose change we should not
|
|
// recreate the photoswipe dialog.
|
|
const delegateRef = useRef<FileViewerPhotoSwipeDelegate | undefined>(
|
|
undefined,
|
|
);
|
|
|
|
// We also need to maintain a ref to the currently displayed dialog since we
|
|
// might need to ask it to refresh its contents.
|
|
const psRef = useRef<FileViewerPhotoSwipe | undefined>(undefined);
|
|
|
|
// 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. We
|
|
// save it as the `activeAnnotatedFile` state 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 [activeAnnotatedFile, setActiveAnnotatedFile] = useState<
|
|
FileViewerAnnotatedFile | undefined
|
|
>(undefined);
|
|
|
|
// With semantics similar to `activeAnnotatedFile`, this is the exif data
|
|
// associated with the `activeAnnotatedFile`, if any.
|
|
const [activeFileExif, setActiveFileExif] = useState<
|
|
FileInfoExif | undefined
|
|
>(undefined);
|
|
|
|
const [openFileInfo, setOpenFileInfo] = useState(false);
|
|
const [moreMenuAnchorEl, setMoreMenuAnchorEl] =
|
|
useState<HTMLElement | null>(null);
|
|
const [openImageEditor, setOpenImageEditor] = useState(false);
|
|
|
|
// If `true`, then we need to trigger a sync with remote when we close.
|
|
const [, setNeedsSync] = useState(false);
|
|
|
|
const handleClose = useCallback(() => {
|
|
setNeedsSync((needSync) => {
|
|
console.log("needs sync", needSync);
|
|
if (needSync) onTriggerSyncWithRemote?.();
|
|
return false;
|
|
});
|
|
setOpenFileInfo(false);
|
|
setOpenImageEditor(false);
|
|
// No need to `resetMoreMenuButtonOnMenuClose` since we're closing
|
|
// anyway and it'll be removed from the DOM.
|
|
setMoreMenuAnchorEl(null);
|
|
onClose();
|
|
}, [onTriggerSyncWithRemote, onClose]);
|
|
|
|
const handleViewInfo = useCallback(
|
|
(annotatedFile: FileViewerAnnotatedFile) => {
|
|
setActiveAnnotatedFile(annotatedFile);
|
|
setActiveFileExif(
|
|
fileInfoExifForFile(annotatedFile.file, (exif) =>
|
|
setActiveFileExif(exif),
|
|
),
|
|
);
|
|
setOpenFileInfo(true);
|
|
},
|
|
[],
|
|
);
|
|
|
|
const handleFileInfoClose = useCallback(() => setOpenFileInfo(false), []);
|
|
|
|
const handleMore = useCallback(
|
|
(
|
|
annotatedFile: FileViewerAnnotatedFile,
|
|
buttonElement: HTMLElement,
|
|
) => {
|
|
setActiveAnnotatedFile(annotatedFile);
|
|
setMoreMenuAnchorEl(buttonElement);
|
|
},
|
|
[],
|
|
);
|
|
|
|
const handleMoreMenuClose = useCallback(() => {
|
|
setMoreMenuAnchorEl((el) => {
|
|
resetMoreMenuButtonOnMenuClose(el);
|
|
return null;
|
|
});
|
|
}, []);
|
|
|
|
const handleEditImage = useMemo(() => {
|
|
return onSaveEditedImageCopy
|
|
? () => {
|
|
handleMoreMenuClose();
|
|
setOpenImageEditor(true);
|
|
}
|
|
: undefined;
|
|
}, [onSaveEditedImageCopy, handleMoreMenuClose]);
|
|
|
|
const handleImageEditorClose = useCallback(
|
|
() => setOpenImageEditor(false),
|
|
[],
|
|
);
|
|
|
|
const handleSaveEditedCopy = useCallback(
|
|
(editedFile: File, collection: Collection, enteFile: EnteFile) => {
|
|
onSaveEditedImageCopy(editedFile, collection, enteFile);
|
|
handleClose();
|
|
},
|
|
[onSaveEditedImageCopy, handleClose],
|
|
);
|
|
|
|
const handleAnnotate = useCallback(
|
|
(file: EnteFile): FileViewerFileAnnotation => {
|
|
log.debug(() => ["viewer", { action: "annotate", file }]);
|
|
const fileID = file.id;
|
|
const isOwnFile = file.ownerID == user?.id;
|
|
const canModify =
|
|
isOwnFile && !isInTrashSection && !isInHiddenSection;
|
|
const showFavorite = canModify;
|
|
const isEditableImage =
|
|
handleEditImage && canModify
|
|
? fileIsEditableImage(file)
|
|
: undefined;
|
|
return { fileID, isOwnFile, showFavorite, isEditableImage };
|
|
},
|
|
[user, isInTrashSection, isInHiddenSection, handleEditImage],
|
|
);
|
|
|
|
const handleScheduleUpdate = useCallback(() => setNeedsSync(true), []);
|
|
|
|
const handleSelectCollection = useCallback(
|
|
(collectionID: number) => {
|
|
onSelectCollection(collectionID);
|
|
handleClose();
|
|
},
|
|
[onSelectCollection, handleClose],
|
|
);
|
|
|
|
const handleSelectPerson = useMemo(() => {
|
|
return onSelectPerson
|
|
? (personID: string) => {
|
|
onSelectPerson(personID);
|
|
handleClose();
|
|
}
|
|
: undefined;
|
|
}, [onSelectPerson, handleClose]);
|
|
|
|
const haveUser = !!user;
|
|
|
|
const getFiles = useCallback(() => files, [files]);
|
|
|
|
const isFavorite = useCallback(
|
|
({ file }: FileViewerAnnotatedFile) => {
|
|
if (!haveUser || !favoriteFileIDs || !onToggleFavorite) {
|
|
return undefined;
|
|
}
|
|
return favoriteFileIDs.has(file.id);
|
|
},
|
|
[haveUser, favoriteFileIDs, onToggleFavorite],
|
|
);
|
|
|
|
const toggleFavorite = useCallback(
|
|
({ file }: FileViewerAnnotatedFile) =>
|
|
onToggleFavorite!(file)
|
|
.then(handleScheduleUpdate)
|
|
.catch(onGenericError),
|
|
[onToggleFavorite, handleScheduleUpdate, onGenericError],
|
|
);
|
|
|
|
// Initial value of delegate.
|
|
if (!delegateRef.current) {
|
|
delegateRef.current = { getFiles, isFavorite, toggleFavorite };
|
|
}
|
|
|
|
// Updates to delegate callbacks.
|
|
useEffect(() => {
|
|
const delegate = delegateRef.current!;
|
|
delegate.getFiles = getFiles;
|
|
delegate.isFavorite = isFavorite;
|
|
delegate.toggleFavorite = toggleFavorite;
|
|
}, [getFiles, isFavorite, toggleFavorite]);
|
|
|
|
useEffect(() => {
|
|
if (open) {
|
|
// We're open. Create psRef. This will show the file viewer dialog.
|
|
log.debug(() => ["viewer", { action: "open" }]);
|
|
|
|
const pswp = new FileViewerPhotoSwipe({
|
|
initialIndex,
|
|
disableDownload,
|
|
haveUser,
|
|
delegate: delegateRef.current!,
|
|
onClose: handleClose,
|
|
onAnnotate: handleAnnotate,
|
|
onViewInfo: handleViewInfo,
|
|
onMore: handleMore,
|
|
});
|
|
|
|
psRef.current = pswp;
|
|
|
|
return () => {
|
|
// Close dialog in the effect callback.
|
|
log.debug(() => ["viewer", { action: "close" }]);
|
|
pswp.closeIfNeeded();
|
|
};
|
|
}
|
|
}, [
|
|
open,
|
|
onClose,
|
|
user,
|
|
initialIndex,
|
|
disableDownload,
|
|
haveUser,
|
|
handleClose,
|
|
handleAnnotate,
|
|
handleViewInfo,
|
|
handleMore,
|
|
]);
|
|
|
|
const handleRefreshPhotoswipe = useCallback(() => {
|
|
psRef.current!.refreshCurrentSlideContent();
|
|
}, []);
|
|
|
|
log.debug(() => ["viewer", { action: "render", psRef: psRef.current }]);
|
|
|
|
return (
|
|
<Container>
|
|
<Button>Test</Button>
|
|
<FileInfo
|
|
open={openFileInfo}
|
|
onClose={handleFileInfoClose}
|
|
file={activeAnnotatedFile?.file}
|
|
exif={activeFileExif}
|
|
allowEdits={!!activeAnnotatedFile?.annotation.isOwnFile}
|
|
allowMap={haveUser}
|
|
showCollections={haveUser}
|
|
scheduleUpdate={handleScheduleUpdate}
|
|
refreshPhotoswipe={handleRefreshPhotoswipe}
|
|
onSelectCollection={handleSelectCollection}
|
|
onSelectPerson={handleSelectPerson}
|
|
{...{ fileCollectionIDs, allCollectionsNameByID }}
|
|
/>
|
|
<Menu
|
|
open={!!moreMenuAnchorEl}
|
|
onClose={handleMoreMenuClose}
|
|
anchorEl={moreMenuAnchorEl}
|
|
id={moreMenuID}
|
|
slotProps={{
|
|
list: { "aria-labelledby": moreButtonID },
|
|
}}
|
|
>
|
|
{activeAnnotatedFile?.annotation.isEditableImage && (
|
|
<MenuItem onClick={handleEditImage}>
|
|
{/*TODO */ pt("Edit image")}
|
|
</MenuItem>
|
|
)}
|
|
</Menu>
|
|
<ImageEditorOverlay
|
|
open={openImageEditor}
|
|
onClose={handleImageEditorClose}
|
|
file={activeAnnotatedFile?.file}
|
|
onSaveEditedCopy={handleSaveEditedCopy}
|
|
/>
|
|
</Container>
|
|
);
|
|
};
|
|
|
|
export default FileViewer;
|
|
|
|
const Container = styled("div")`
|
|
border: 1px solid red;
|
|
|
|
#test-gallery {
|
|
border: 1px solid red;
|
|
min-height: 10px;
|
|
}
|
|
`;
|
|
|
|
const fileIsEditableImage = (file: EnteFile) => {
|
|
// Only images are editable.
|
|
if (file.metadata.fileType !== FileType.image) return false;
|
|
|
|
const extension = lowercaseExtension(file.metadata.title);
|
|
// Assume it is editable;
|
|
let isRenderable = true;
|
|
if (extension && needsJPEGConversion(extension)) {
|
|
// See if the file is on the whitelist of extensions that we know
|
|
// will not be directly renderable.
|
|
if (!isDesktop) {
|
|
// On the web, we only support HEIC conversion.
|
|
isRenderable = isHEICExtension(extension);
|
|
}
|
|
}
|
|
return isRenderable;
|
|
};
|