mirror of
https://github.com/ente-io/ente.git
synced 2025-05-02 04:05:42 +00:00
1298 lines
44 KiB
TypeScript
1298 lines
44 KiB
TypeScript
import ArchiveOutlinedIcon from "@mui/icons-material/ArchiveOutlined";
|
|
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
|
|
import DeleteIcon from "@mui/icons-material/Delete";
|
|
import EditIcon from "@mui/icons-material/Edit";
|
|
import FileDownloadOutlinedIcon from "@mui/icons-material/FileDownloadOutlined";
|
|
import FullscreenExitOutlinedIcon from "@mui/icons-material/FullscreenExitOutlined";
|
|
import FullscreenOutlinedIcon from "@mui/icons-material/FullscreenOutlined";
|
|
import UnArchiveIcon from "@mui/icons-material/Unarchive";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogTitle,
|
|
Menu,
|
|
MenuItem,
|
|
Stack,
|
|
styled,
|
|
Typography,
|
|
type ModalProps,
|
|
} from "@mui/material";
|
|
import { isDesktop } from "ente-base/app";
|
|
import { SpacedRow } from "ente-base/components/containers";
|
|
import { InlineErrorIndicator } from "ente-base/components/ErrorIndicator";
|
|
import { TitledMiniDialog } from "ente-base/components/MiniDialog";
|
|
import { DialogCloseIconButton } from "ente-base/components/mui/DialogCloseIconButton";
|
|
import { FocusVisibleButton } from "ente-base/components/mui/FocusVisibleButton";
|
|
import { LoadingButton } from "ente-base/components/mui/LoadingButton";
|
|
import { useIsSmallWidth } from "ente-base/components/utils/hooks";
|
|
import { type ModalVisibilityProps } from "ente-base/components/utils/modal";
|
|
import { useBaseContext } from "ente-base/context";
|
|
import { lowercaseExtension } from "ente-base/file-name";
|
|
import { formattedListJoin, pt, ut } from "ente-base/i18n";
|
|
import type { LocalUser } from "ente-base/local-user";
|
|
import log from "ente-base/log";
|
|
import {
|
|
FileInfo,
|
|
type FileInfoExif,
|
|
type FileInfoProps,
|
|
} from "ente-gallery/components/FileInfo";
|
|
import type { Collection } from "ente-media/collection";
|
|
import { fileVisibility, ItemVisibility } from "ente-media/file-metadata";
|
|
import { FileType } from "ente-media/file-type";
|
|
import type { EnteFile } from "ente-media/file.js";
|
|
import { isHEICExtension, needsJPEGConversion } from "ente-media/formats";
|
|
import {
|
|
ImageEditorOverlay,
|
|
type ImageEditorOverlayProps,
|
|
} from "ente-new/photos/components/ImageEditorOverlay";
|
|
import { t } from "i18next";
|
|
import React, {
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import {
|
|
fileInfoExifForFile,
|
|
updateItemDataAlt,
|
|
type ItemData,
|
|
} from "./data-source";
|
|
import {
|
|
FileViewerPhotoSwipe,
|
|
moreButtonID,
|
|
moreMenuID,
|
|
resetMoreMenuButtonOnMenuClose,
|
|
shouldUsePlayerV2,
|
|
type FileViewerPhotoSwipeDelegate,
|
|
} from "./photoswipe";
|
|
|
|
/**
|
|
* 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;
|
|
/**
|
|
* `true` if the toggle favorite action should be shown for this file.
|
|
*/
|
|
showFavorite: boolean;
|
|
/**
|
|
* Set if the download action should be shown for this file.
|
|
*
|
|
* - When "bar", the action button is shown among the bar icons.
|
|
*
|
|
* - When "menu", the action is shown as a more menu entry.
|
|
*
|
|
* Note: "bar" should only be set if {@link haveUser} is also true.
|
|
*/
|
|
showDownload: "bar" | "menu" | undefined;
|
|
/**
|
|
* `true` if the delete action should be shown for this file.
|
|
*/
|
|
showDelete: boolean;
|
|
/**
|
|
* `true` if the toggle archive action should be shown for this file.
|
|
*/
|
|
showArchive: boolean;
|
|
/**
|
|
* `true` if the copy image action should be shown for this file.
|
|
*/
|
|
showCopyImage: boolean;
|
|
/**
|
|
* `true` if this is an image which can be edited, _and_ editing is
|
|
* possible, and the edit action should therefore be shown for this file.
|
|
*/
|
|
showEditImage: boolean;
|
|
}
|
|
|
|
/**
|
|
* A file, its annotation, and its item data, in a nice cosy box.
|
|
*/
|
|
export interface FileViewerAnnotatedFile {
|
|
file: EnteFile;
|
|
annotation: FileViewerFileAnnotation;
|
|
itemData: ItemData;
|
|
}
|
|
|
|
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;
|
|
/**
|
|
* If true then the viewer does not show controls for downloading the file.
|
|
*/
|
|
disableDownload?: boolean;
|
|
/**
|
|
* `true` when we are viewing files in an album that the user does not own.
|
|
*/
|
|
isInIncomingSharedCollection?: boolean;
|
|
/**
|
|
* `true` when we are viewing files in the Trash.
|
|
*/
|
|
isInTrashSection?: boolean;
|
|
/**
|
|
* `true` when we are viewing files in the hidden section.
|
|
*/
|
|
isInHiddenSection?: boolean;
|
|
/**
|
|
* File IDs of all the files that the user has marked as a favorite.
|
|
*
|
|
* See also {@link onToggleFavorite}.
|
|
*/
|
|
favoriteFileIDs?: Set<number>;
|
|
/**
|
|
* File IDs of for which an update of its favorite status is pending (e.g.
|
|
* due to a toggle favorite action in the file viewer).
|
|
*
|
|
* See also {@link favoriteFileIDs} and {@link onToggleFavorite}.
|
|
*/
|
|
pendingFavoriteUpdates?: Set<number>;
|
|
/**
|
|
* File IDs of for which an update of its visibility is pending (e.g. due to
|
|
* a toggle archived action in the file viewer).
|
|
*
|
|
* See also {@link onFileVisibilityUpdate}.
|
|
*/
|
|
pendingVisibilityUpdates?: Set<number>;
|
|
/**
|
|
* A mapping from file IDs to the IDs of the normal (non-hidden) collections
|
|
* that they are a part of.
|
|
*/
|
|
fileNormalCollectionIDs?: FileInfoProps["fileCollectionIDs"];
|
|
/**
|
|
* 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 user performs an action which does not otherwise have any
|
|
* immediate visual impact, to acknowledge it.
|
|
*
|
|
* See: [Note: Visual feedback to acknowledge user actions]
|
|
*/
|
|
onVisualFeedback: () => void;
|
|
/**
|
|
* Called when the favorite status of given {@link file} should be toggled
|
|
* from its current value.
|
|
*
|
|
* The favorite toggle button is shown only if all three of
|
|
* {@link favoriteFileIDs}, {@link pendingFavoriteUpdates} and
|
|
* {@link onToggleFavorite} are provided.
|
|
*/
|
|
onToggleFavorite?: (file: EnteFile) => Promise<void>;
|
|
/**
|
|
* Called when {@link visibility} of the given {@link file} should be
|
|
* updated (when the user activates toggle archived action).
|
|
*
|
|
* The toggle archived action is shown only if both
|
|
* {@link pendingVisibilityUpdates} and {@link onFileVisibilityUpdate} are
|
|
* provided.
|
|
*/
|
|
onFileVisibilityUpdate?: (
|
|
file: EnteFile,
|
|
visibility: ItemVisibility,
|
|
) => Promise<void>;
|
|
/**
|
|
* Called when the given {@link file} should be downloaded.
|
|
*
|
|
* If this is not provided then the download action will not be shown.
|
|
*/
|
|
onDownload?: (file: EnteFile) => void;
|
|
/**
|
|
* Called when the given {@link file} should be deleted.
|
|
*
|
|
* If this is not provided then the delete action will not be shown.
|
|
*/
|
|
onDelete?: (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,
|
|
"collectionNameByID" | "onSelectCollection" | "onSelectPerson"
|
|
>;
|
|
|
|
/**
|
|
* A PhotoSwipe based image, live photo and video viewer.
|
|
*/
|
|
export const FileViewer: React.FC<FileViewerProps> = ({
|
|
open,
|
|
onClose,
|
|
user,
|
|
files,
|
|
initialIndex,
|
|
disableDownload,
|
|
isInIncomingSharedCollection,
|
|
isInTrashSection,
|
|
isInHiddenSection,
|
|
favoriteFileIDs,
|
|
pendingFavoriteUpdates,
|
|
pendingVisibilityUpdates,
|
|
fileNormalCollectionIDs,
|
|
collectionNameByID,
|
|
onTriggerSyncWithRemote,
|
|
onVisualFeedback,
|
|
onToggleFavorite,
|
|
onFileVisibilityUpdate,
|
|
onDownload,
|
|
onDelete,
|
|
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);
|
|
const [openConfirmDelete, setOpenConfirmDelete] = useState(false);
|
|
const [openShortcuts, setOpenShortcuts] = useState(false);
|
|
|
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
|
|
|
// If `true`, then we need to trigger a sync with remote when we close.
|
|
const [, setNeedsSync] = useState(false);
|
|
|
|
const handleNeedsRemoteSync = useCallback(() => setNeedsSync(true), []);
|
|
|
|
const handleClose = useCallback(() => {
|
|
setNeedsSync((needSync) => {
|
|
if (needSync) onTriggerSyncWithRemote?.();
|
|
return false;
|
|
});
|
|
setOpenFileInfo(false);
|
|
// No need to `resetMoreMenuButtonOnMenuClose` since we're closing
|
|
// anyway and it'll be removed from the DOM.
|
|
setMoreMenuAnchorEl(null);
|
|
setOpenImageEditor(false);
|
|
setOpenConfirmDelete(false);
|
|
setOpenShortcuts(false);
|
|
onClose();
|
|
}, [onTriggerSyncWithRemote, onClose]);
|
|
|
|
const handleViewInfo = useCallback(
|
|
(annotatedFile: FileViewerAnnotatedFile) => {
|
|
setActiveFileExif(
|
|
fileInfoExifForFile(annotatedFile.file, (exif) =>
|
|
setActiveFileExif(exif),
|
|
),
|
|
);
|
|
setOpenFileInfo(true);
|
|
},
|
|
[],
|
|
);
|
|
|
|
const handleFileInfoClose = useCallback(() => setOpenFileInfo(false), []);
|
|
|
|
// Callback invoked when the download action is triggered by activating the
|
|
// download button in the PhotoSwipe bar.
|
|
const handleDownloadBarAction = useCallback(
|
|
(annotatedFile: FileViewerAnnotatedFile) => {
|
|
onDownload!(annotatedFile.file);
|
|
},
|
|
[onDownload],
|
|
);
|
|
|
|
// Callback invoked when the download action is triggered by activating the
|
|
// download menu item in the more menu.
|
|
//
|
|
// Not memoized since it uses the frequently changing `activeAnnotatedFile`.
|
|
const handleDownloadMenuAction = () => {
|
|
handleMoreMenuCloseIfNeeded();
|
|
onDownload!(activeAnnotatedFile!.file);
|
|
};
|
|
|
|
const handleMore = useCallback(
|
|
(buttonElement: HTMLElement) => setMoreMenuAnchorEl(buttonElement),
|
|
[],
|
|
);
|
|
|
|
const handleMoreMenuCloseIfNeeded = useCallback(() => {
|
|
setMoreMenuAnchorEl((el) => {
|
|
if (el) resetMoreMenuButtonOnMenuClose(el);
|
|
return null;
|
|
});
|
|
}, []);
|
|
|
|
const handleConfirmDelete = useMemo(() => {
|
|
return onDelete
|
|
? () => {
|
|
handleMoreMenuCloseIfNeeded();
|
|
setOpenConfirmDelete(true);
|
|
}
|
|
: undefined;
|
|
}, [onDelete, handleMoreMenuCloseIfNeeded]);
|
|
|
|
const handleConfirmDeleteClose = useCallback(
|
|
() => setOpenConfirmDelete(false),
|
|
[],
|
|
);
|
|
|
|
// Not memoized since it uses the frequently changing `activeAnnotatedFile`.
|
|
const handleDelete = async () => {
|
|
const file = activeAnnotatedFile!.file;
|
|
await onDelete!(file);
|
|
handleNeedsRemoteSync();
|
|
};
|
|
|
|
// Not memoized since it uses the frequently changing `activeAnnotatedFile`.
|
|
const handleCopyImage = useCallback(() => {
|
|
handleMoreMenuCloseIfNeeded();
|
|
const imageURL = activeAnnotatedFile?.itemData.imageURL;
|
|
// Safari does not copy if we do not call `navigator.clipboard.write`
|
|
// synchronously within the click event handler, but it does supports
|
|
// passing a promise in lieu of the blob.
|
|
void window.navigator.clipboard
|
|
.write([
|
|
new ClipboardItem({
|
|
"image/png": createImagePNGBlob(imageURL!),
|
|
}),
|
|
])
|
|
.catch(onGenericError);
|
|
}, [onGenericError, handleMoreMenuCloseIfNeeded, activeAnnotatedFile]);
|
|
|
|
const handleEditImage = useMemo(() => {
|
|
return onSaveEditedImageCopy
|
|
? () => {
|
|
handleMoreMenuCloseIfNeeded();
|
|
setOpenImageEditor(true);
|
|
}
|
|
: undefined;
|
|
}, [onSaveEditedImageCopy, handleMoreMenuCloseIfNeeded]);
|
|
|
|
const handleImageEditorClose = useCallback(
|
|
() => setOpenImageEditor(false),
|
|
[],
|
|
);
|
|
|
|
const handleSaveEditedCopy = useMemo(() => {
|
|
return onSaveEditedImageCopy
|
|
? (
|
|
editedFile: File,
|
|
collection: Collection,
|
|
enteFile: EnteFile,
|
|
) => {
|
|
onSaveEditedImageCopy(editedFile, collection, enteFile);
|
|
handleClose();
|
|
}
|
|
: undefined;
|
|
}, [onSaveEditedImageCopy, handleClose]);
|
|
|
|
const handleAnnotate = useCallback(
|
|
(file: EnteFile, itemData: ItemData): FileViewerAnnotatedFile => {
|
|
const fileID = file.id;
|
|
const isOwnFile = file.ownerID == user?.id;
|
|
|
|
const canModify =
|
|
isOwnFile && !isInTrashSection && !isInHiddenSection;
|
|
|
|
const showFavorite = canModify;
|
|
|
|
const showArchive = canModify;
|
|
|
|
const showDelete =
|
|
!!handleConfirmDelete &&
|
|
isOwnFile &&
|
|
!isInTrashSection &&
|
|
!isInIncomingSharedCollection;
|
|
|
|
const showEditImage =
|
|
!!handleEditImage && canModify && fileIsEditableImage(file);
|
|
|
|
const showDownload = (() => {
|
|
if (disableDownload) return undefined;
|
|
if (!onDownload) return undefined;
|
|
if (user) {
|
|
// Logged in users see the download option in the more menu.
|
|
return "menu";
|
|
} else {
|
|
// In public albums, the download option is shown in the bar
|
|
// buttons, in lieu of the favorite option.
|
|
return "bar";
|
|
}
|
|
})();
|
|
|
|
const showCopyImage = (() => {
|
|
if (disableDownload) return false;
|
|
switch (file.metadata.fileType) {
|
|
case FileType.image:
|
|
case FileType.livePhoto:
|
|
return true;
|
|
default:
|
|
return false;
|
|
}
|
|
})();
|
|
|
|
const annotation: FileViewerFileAnnotation = {
|
|
fileID,
|
|
isOwnFile,
|
|
showFavorite,
|
|
showDownload,
|
|
showDelete,
|
|
showArchive,
|
|
showCopyImage,
|
|
showEditImage,
|
|
};
|
|
|
|
const annotatedFile = { file, annotation, itemData };
|
|
setActiveAnnotatedFile(annotatedFile);
|
|
return annotatedFile;
|
|
},
|
|
[
|
|
user,
|
|
disableDownload,
|
|
isInIncomingSharedCollection,
|
|
isInTrashSection,
|
|
isInHiddenSection,
|
|
onDownload,
|
|
handleEditImage,
|
|
handleConfirmDelete,
|
|
],
|
|
);
|
|
|
|
const handleSelectCollection = useMemo(() => {
|
|
return onSelectCollection
|
|
? (collectionID: number) => {
|
|
onSelectCollection(collectionID);
|
|
handleClose();
|
|
}
|
|
: undefined;
|
|
}, [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 ||
|
|
!pendingFavoriteUpdates ||
|
|
!onToggleFavorite
|
|
) {
|
|
return undefined;
|
|
}
|
|
return favoriteFileIDs.has(file.id);
|
|
},
|
|
[haveUser, favoriteFileIDs, pendingFavoriteUpdates, onToggleFavorite],
|
|
);
|
|
|
|
const isFavoritePending = useCallback(
|
|
({ file }: FileViewerAnnotatedFile) =>
|
|
!!pendingFavoriteUpdates?.has(file.id),
|
|
[pendingFavoriteUpdates],
|
|
);
|
|
|
|
const toggleFavorite = useCallback(
|
|
({ file }: FileViewerAnnotatedFile) =>
|
|
onToggleFavorite!(file)
|
|
.catch(onGenericError)
|
|
.finally(handleNeedsRemoteSync),
|
|
[onToggleFavorite, onGenericError, handleNeedsRemoteSync],
|
|
);
|
|
|
|
const updateFullscreenStatus = useCallback(() => {
|
|
setIsFullscreen(!!document.fullscreenElement);
|
|
}, []);
|
|
|
|
const handleToggleFullscreen = useCallback(() => {
|
|
handleMoreMenuCloseIfNeeded();
|
|
void (
|
|
document.fullscreenElement
|
|
? document.exitFullscreen()
|
|
: document.body.requestFullscreen()
|
|
).then(updateFullscreenStatus);
|
|
}, [handleMoreMenuCloseIfNeeded, updateFullscreenStatus]);
|
|
|
|
const handleShortcuts = useCallback(() => {
|
|
handleMoreMenuCloseIfNeeded();
|
|
setOpenShortcuts(true);
|
|
}, [handleMoreMenuCloseIfNeeded]);
|
|
|
|
const handleShortcutsClose = useCallback(() => setOpenShortcuts(false), []);
|
|
|
|
// TODO: Unused translation t("convert") - can be removed post the upcoming
|
|
// streaming changes as they'll provides the equiv.
|
|
|
|
const shouldIgnoreKeyboardEvent = useCallback(() => {
|
|
// Don't handle keydowns if any of the modals are open.
|
|
return (
|
|
openFileInfo ||
|
|
!!moreMenuAnchorEl ||
|
|
openImageEditor ||
|
|
openConfirmDelete ||
|
|
openShortcuts
|
|
);
|
|
}, [
|
|
openFileInfo,
|
|
moreMenuAnchorEl,
|
|
openImageEditor,
|
|
openConfirmDelete,
|
|
openShortcuts,
|
|
]);
|
|
|
|
const canCopyImage = useCallback(
|
|
() =>
|
|
activeAnnotatedFile?.annotation.showCopyImage &&
|
|
activeAnnotatedFile.itemData.imageURL,
|
|
[activeAnnotatedFile],
|
|
);
|
|
|
|
const { isArchived, isPendingToggleArchive, toggleArchived } =
|
|
useMemo(() => {
|
|
let isArchived: boolean | undefined;
|
|
let isPendingToggleArchive: boolean | undefined;
|
|
let toggleArchived: (() => void) | undefined;
|
|
|
|
const file = activeAnnotatedFile?.file;
|
|
|
|
if (
|
|
pendingVisibilityUpdates &&
|
|
onFileVisibilityUpdate &&
|
|
file &&
|
|
activeAnnotatedFile.annotation.showArchive
|
|
) {
|
|
switch (fileVisibility(file)) {
|
|
case undefined:
|
|
case ItemVisibility.visible:
|
|
isArchived = false;
|
|
break;
|
|
case ItemVisibility.archived:
|
|
isArchived = true;
|
|
break;
|
|
}
|
|
|
|
isPendingToggleArchive = pendingVisibilityUpdates.has(file.id);
|
|
|
|
toggleArchived = () => {
|
|
handleMoreMenuCloseIfNeeded();
|
|
void onFileVisibilityUpdate(
|
|
file,
|
|
isArchived
|
|
? ItemVisibility.visible
|
|
: ItemVisibility.archived,
|
|
)
|
|
.then(handleNeedsRemoteSync)
|
|
.catch(onGenericError);
|
|
};
|
|
}
|
|
|
|
return { isArchived, isPendingToggleArchive, toggleArchived };
|
|
}, [
|
|
pendingVisibilityUpdates,
|
|
onFileVisibilityUpdate,
|
|
onGenericError,
|
|
handleNeedsRemoteSync,
|
|
handleMoreMenuCloseIfNeeded,
|
|
activeAnnotatedFile,
|
|
]);
|
|
|
|
const performKeyAction = useCallback<
|
|
FileViewerPhotoSwipeDelegate["performKeyAction"]
|
|
>(
|
|
(action) => {
|
|
switch (action) {
|
|
case "delete":
|
|
if (activeAnnotatedFile?.annotation.showDelete)
|
|
handleConfirmDelete?.();
|
|
break;
|
|
case "toggle-archive":
|
|
if (!isPendingToggleArchive) {
|
|
// Provide extra visual feedback when the toggle archive
|
|
// action is invoked via a keyboard shortcut since there
|
|
// is no corresponding screen control visible (the more
|
|
// menu might not be visible).
|
|
onVisualFeedback();
|
|
toggleArchived?.();
|
|
}
|
|
break;
|
|
case "copy":
|
|
if (canCopyImage()) handleCopyImage();
|
|
break;
|
|
case "toggle-fullscreen":
|
|
handleToggleFullscreen();
|
|
break;
|
|
case "help":
|
|
handleShortcuts();
|
|
break;
|
|
}
|
|
},
|
|
[
|
|
onVisualFeedback,
|
|
handleConfirmDelete,
|
|
handleCopyImage,
|
|
handleToggleFullscreen,
|
|
handleShortcuts,
|
|
activeAnnotatedFile,
|
|
isPendingToggleArchive,
|
|
toggleArchived,
|
|
canCopyImage,
|
|
],
|
|
);
|
|
|
|
// Initial value of delegate.
|
|
if (!delegateRef.current) {
|
|
delegateRef.current = {
|
|
getFiles,
|
|
isFavorite,
|
|
isFavoritePending,
|
|
toggleFavorite,
|
|
shouldIgnoreKeyboardEvent,
|
|
performKeyAction,
|
|
};
|
|
}
|
|
|
|
// Updates to delegate callbacks.
|
|
useEffect(() => {
|
|
const delegate = delegateRef.current!;
|
|
delegate.getFiles = getFiles;
|
|
delegate.isFavorite = isFavorite;
|
|
delegate.isFavoritePending = isFavoritePending;
|
|
delegate.toggleFavorite = toggleFavorite;
|
|
delegate.shouldIgnoreKeyboardEvent = shouldIgnoreKeyboardEvent;
|
|
delegate.performKeyAction = performKeyAction;
|
|
}, [
|
|
getFiles,
|
|
isFavorite,
|
|
isFavoritePending,
|
|
toggleFavorite,
|
|
shouldIgnoreKeyboardEvent,
|
|
performKeyAction,
|
|
]);
|
|
|
|
// Update the active annotated file, if needed, on updates to files or
|
|
// favoriteFileIDs.
|
|
//
|
|
// If the active annotated file is no longer being shown, move to the next
|
|
// slide if possible, or close the viewer otherwise.
|
|
useEffect(() => {
|
|
setActiveAnnotatedFile((af) => {
|
|
// Do nothing if we're not showing a file (we might not be open).
|
|
if (!af) return af;
|
|
|
|
if (files.length) {
|
|
const updatedFile = files.find(({ id }) => id == af?.file.id);
|
|
if (updatedFile) {
|
|
// Modify the active annotated file if we found a file with
|
|
// the same ID in the (possibly) updated files array.
|
|
//
|
|
// Note the omission of the PhotoSwipe refresh: we don't
|
|
// refresh the PhotoSwipe dialog itself since that would
|
|
// cause the user to lose their pan / zoom etc.
|
|
//
|
|
// This is not correct in its full generality, but it works
|
|
// fine in the specific cases we would need to handle:
|
|
//
|
|
// - In case of delete, we'll not get to this code branch.
|
|
//
|
|
// - In case of toggling archive, just updating the file
|
|
// attribute is enough, the UI state is derived from it;
|
|
// none of the other attributes of the annotated file
|
|
// currently depend on the archive status change.
|
|
af = { ...af, file: updatedFile };
|
|
} else {
|
|
// Refreshing the current slide after the current file has
|
|
// gone will show the subsequent slide (since that would've
|
|
// now moved down to the current index).
|
|
//
|
|
// However, we might've been the last slide, in which case
|
|
// we need to go back one slide first. To determine this,
|
|
// also pass the expected count of files to our PhotoSwipe
|
|
// wrapper.
|
|
psRef.current?.refreshCurrentSlideContent(files.length);
|
|
}
|
|
} else {
|
|
// If there are no more files left, close the viewer.
|
|
handleClose();
|
|
}
|
|
|
|
return af;
|
|
});
|
|
}, [handleClose, files]);
|
|
|
|
useEffect(() => {
|
|
if (open) {
|
|
psRef.current?.refreshCurrentSlideFavoriteButtonIfNeeded();
|
|
}
|
|
}, [favoriteFileIDs, pendingFavoriteUpdates, open]);
|
|
|
|
useEffect(() => {
|
|
if (open) {
|
|
// We're open. Create psRef. This will show the file viewer dialog.
|
|
log.debug(() => "Opening file viewer");
|
|
|
|
const pswp = new FileViewerPhotoSwipe({
|
|
initialIndex,
|
|
disableDownload,
|
|
haveUser,
|
|
delegate: delegateRef.current!,
|
|
onClose: () => {
|
|
if (psRef.current) handleClose();
|
|
},
|
|
onAnnotate: handleAnnotate,
|
|
onViewInfo: handleViewInfo,
|
|
onDownload: handleDownloadBarAction,
|
|
onMore: handleMore,
|
|
});
|
|
|
|
psRef.current = pswp;
|
|
|
|
return () => {
|
|
// Close dialog in the effect callback.
|
|
log.debug(() => "Closing file viewer");
|
|
pswp.closeIfNeeded();
|
|
};
|
|
} else {
|
|
return undefined;
|
|
}
|
|
}, [
|
|
// Be careful with adding new dependencies here, or changing the source
|
|
// of existing ones. If any of these dependencies change unnecessarily,
|
|
// then the file viewer will start getting reloaded even when it is
|
|
// already open.
|
|
open,
|
|
onClose,
|
|
user,
|
|
initialIndex,
|
|
disableDownload,
|
|
haveUser,
|
|
handleClose,
|
|
handleAnnotate,
|
|
handleViewInfo,
|
|
handleDownloadBarAction,
|
|
handleMore,
|
|
]);
|
|
|
|
const handleUpdateCaption = useCallback((updatedFile: EnteFile) => {
|
|
updateItemDataAlt(updatedFile);
|
|
psRef.current!.refreshCurrentSlideContent();
|
|
}, []);
|
|
|
|
useEffect(updateFullscreenStatus, [updateFullscreenStatus]);
|
|
|
|
if (!activeAnnotatedFile) {
|
|
return <></>;
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<FileInfo
|
|
open={openFileInfo}
|
|
onClose={handleFileInfoClose}
|
|
file={activeAnnotatedFile.file}
|
|
exif={activeFileExif}
|
|
allowEdits={!!activeAnnotatedFile.annotation.isOwnFile}
|
|
allowMap={haveUser}
|
|
showCollections={haveUser && !isInHiddenSection}
|
|
fileCollectionIDs={fileNormalCollectionIDs}
|
|
collectionNameByID={collectionNameByID}
|
|
onNeedsRemoteSync={handleNeedsRemoteSync}
|
|
onUpdateCaption={handleUpdateCaption}
|
|
onSelectCollection={handleSelectCollection}
|
|
onSelectPerson={handleSelectPerson}
|
|
/>
|
|
<MoreMenu
|
|
open={!!moreMenuAnchorEl}
|
|
onClose={handleMoreMenuCloseIfNeeded}
|
|
anchorEl={moreMenuAnchorEl}
|
|
id={moreMenuID}
|
|
slotProps={{ list: { "aria-labelledby": moreButtonID } }}
|
|
>
|
|
{activeAnnotatedFile.annotation.showDownload == "menu" && (
|
|
<MoreMenuItem onClick={handleDownloadMenuAction}>
|
|
<MoreMenuItemTitle>{t("download")}</MoreMenuItemTitle>
|
|
<FileDownloadOutlinedIcon />
|
|
</MoreMenuItem>
|
|
)}
|
|
{activeAnnotatedFile.annotation.showDelete && (
|
|
<MoreMenuItem onClick={handleConfirmDelete}>
|
|
<MoreMenuItemTitle>{t("delete")}</MoreMenuItemTitle>
|
|
<DeleteIcon />
|
|
</MoreMenuItem>
|
|
)}
|
|
{isArchived !== undefined && (
|
|
<MoreMenuItem
|
|
onClick={toggleArchived}
|
|
disabled={isPendingToggleArchive}
|
|
>
|
|
<MoreMenuItemTitle>
|
|
{isArchived ? t("unarchive") : t("archive")}
|
|
</MoreMenuItemTitle>
|
|
{isArchived ? (
|
|
<UnArchiveIcon />
|
|
) : (
|
|
<ArchiveOutlinedIcon />
|
|
)}
|
|
</MoreMenuItem>
|
|
)}
|
|
{canCopyImage() && (
|
|
<MoreMenuItem onClick={handleCopyImage}>
|
|
<MoreMenuItemTitle>
|
|
{t("copy_as_png")}
|
|
</MoreMenuItemTitle>
|
|
{/* Tweak icon size to visually fit better with neighbours */}
|
|
<ContentCopyIcon sx={{ "&&": { fontSize: "18px" } }} />
|
|
</MoreMenuItem>
|
|
)}
|
|
{activeAnnotatedFile.annotation.showEditImage && (
|
|
<MoreMenuItem onClick={handleEditImage}>
|
|
<MoreMenuItemTitle>{t("edit_image")}</MoreMenuItemTitle>
|
|
<EditIcon />
|
|
</MoreMenuItem>
|
|
)}
|
|
<MoreMenuItem
|
|
onClick={handleToggleFullscreen}
|
|
divider
|
|
sx={{
|
|
borderColor: "fixed.dark.divider",
|
|
/* 12px + 2px */
|
|
pb: "14px",
|
|
}}
|
|
>
|
|
<MoreMenuItemTitle>
|
|
{isFullscreen
|
|
? t("exit_fullscreen")
|
|
: t("go_fullscreen")}
|
|
</MoreMenuItemTitle>
|
|
{isFullscreen ? (
|
|
<FullscreenExitOutlinedIcon />
|
|
) : (
|
|
<FullscreenOutlinedIcon />
|
|
)}
|
|
</MoreMenuItem>
|
|
<MoreMenuItem onClick={handleShortcuts} sx={{ mt: "2px" }}>
|
|
<Typography sx={{ color: "fixed.dark.text.faint" }}>
|
|
{t("shortcuts")}
|
|
</Typography>
|
|
</MoreMenuItem>
|
|
</MoreMenu>
|
|
<ConfirmDeleteFileDialog
|
|
open={openConfirmDelete}
|
|
onClose={handleConfirmDeleteClose}
|
|
onConfirm={handleDelete}
|
|
/>
|
|
{handleSaveEditedCopy && (
|
|
<ImageEditorOverlay
|
|
open={openImageEditor}
|
|
onClose={handleImageEditorClose}
|
|
file={activeAnnotatedFile.file}
|
|
onSaveEditedCopy={handleSaveEditedCopy}
|
|
/>
|
|
)}
|
|
<Shortcuts
|
|
open={openShortcuts}
|
|
onClose={handleShortcutsClose}
|
|
{...{ disableDownload, haveUser }}
|
|
/>
|
|
</>
|
|
);
|
|
};
|
|
|
|
const MoreMenu = styled(Menu)(
|
|
({ theme }) => `
|
|
& .MuiPaper-root {
|
|
background-color: ${theme.vars.palette.fixed.dark.background.paper};
|
|
}
|
|
& .MuiList-root {
|
|
padding-block: 2px;
|
|
}
|
|
`,
|
|
);
|
|
|
|
const MoreMenuItem = styled(MenuItem)(
|
|
({ theme }) => `
|
|
min-width: 210px;
|
|
|
|
/* MUI MenuItem default implementation has a minHeight of "48px" below the
|
|
"sm" breakpoint, and auto after it. We always want the same height, so
|
|
set minHeight auto and use an explicit padding always to come out to 44px
|
|
(20px (icon or Typography height + 12 + 12) */
|
|
padding-block: 12px;
|
|
min-height: auto;
|
|
|
|
gap: 1;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
|
|
/* Same as other controls on the PhotoSwipe UI */
|
|
color: rgba(255 255 255 / 0.85);
|
|
&:hover {
|
|
color: rgba(255 255 255 / 1);
|
|
background-color: ${theme.vars.palette.fixed.dark.background.paper2}
|
|
}
|
|
|
|
.MuiSvgIcon-root {
|
|
font-size: 20px;
|
|
}
|
|
`,
|
|
);
|
|
|
|
const MoreMenuItemTitle: React.FC<React.PropsWithChildren> = ({ children }) => (
|
|
<Typography sx={{ fontWeight: "medium" }}>{children}</Typography>
|
|
);
|
|
|
|
type ConfirmDeleteFileDialogProps = ModalVisibilityProps & {
|
|
/**
|
|
* Called when the user confirms the deletion.
|
|
*
|
|
* The delete button will show an activity indicator until this async
|
|
* operation completes.
|
|
*/
|
|
onConfirm: () => Promise<void>;
|
|
};
|
|
|
|
/**
|
|
* A bespoke variant of AttributedMiniDialog for use by the delete file
|
|
* confirmation prompt that we show in the file viewer.
|
|
*
|
|
* - It auto focuses the primary action.
|
|
* - It uses a lighter backdrop in light mode.
|
|
*/
|
|
const ConfirmDeleteFileDialog: React.FC<ConfirmDeleteFileDialogProps> = ({
|
|
open,
|
|
onClose,
|
|
onConfirm,
|
|
}) => {
|
|
const [phase, setPhase] = useState<"loading" | "failed" | undefined>();
|
|
|
|
const resetPhaseAndClose = () => {
|
|
setPhase(undefined);
|
|
onClose();
|
|
};
|
|
|
|
const handleClick = async () => {
|
|
setPhase("loading");
|
|
try {
|
|
await onConfirm();
|
|
resetPhaseAndClose();
|
|
} catch (e) {
|
|
log.error(e);
|
|
setPhase("failed");
|
|
}
|
|
};
|
|
|
|
const handleClose: ModalProps["onClose"] = (_, reason) => {
|
|
// Ignore backdrop clicks when we're processing the user request.
|
|
if (reason == "backdropClick" && phase == "loading") return;
|
|
resetPhaseAndClose();
|
|
};
|
|
|
|
return (
|
|
<TitledMiniDialog
|
|
open={open}
|
|
onClose={handleClose}
|
|
title={t("trash_file_title")}
|
|
sx={(theme) => ({
|
|
// See: [Note: Lighter backdrop for overlays on photo viewer]
|
|
...theme.applyStyles("light", {
|
|
".MuiBackdrop-root": {
|
|
backgroundColor: theme.vars.palette.backdrop.faint,
|
|
},
|
|
}),
|
|
})}
|
|
>
|
|
<Typography sx={{ color: "text.muted" }}>
|
|
{t("trash_file_message")}
|
|
</Typography>
|
|
<Stack sx={{ pt: 3, gap: 1 }}>
|
|
{phase == "failed" && <InlineErrorIndicator />}
|
|
<LoadingButton
|
|
loading={phase == "loading"}
|
|
fullWidth
|
|
color="critical"
|
|
autoFocus
|
|
onClick={handleClick}
|
|
>
|
|
{t("move_to_trash")}
|
|
</LoadingButton>
|
|
<FocusVisibleButton
|
|
fullWidth
|
|
color="secondary"
|
|
disabled={phase == "loading"}
|
|
onClick={resetPhaseAndClose}
|
|
>
|
|
{t("cancel")}
|
|
</FocusVisibleButton>
|
|
</Stack>
|
|
</TitledMiniDialog>
|
|
);
|
|
};
|
|
|
|
type ShortcutsProps = ModalVisibilityProps &
|
|
Pick<FileViewerProps, "disableDownload"> & {
|
|
/**
|
|
* `true` if we're running in a context where there is a logged in user.
|
|
*/
|
|
haveUser: boolean;
|
|
};
|
|
|
|
const Shortcuts: React.FC<ShortcutsProps> = ({
|
|
open,
|
|
onClose,
|
|
disableDownload,
|
|
haveUser,
|
|
}) => (
|
|
<Dialog
|
|
{...{ open, onClose }}
|
|
fullWidth
|
|
fullScreen={useIsSmallWidth()}
|
|
slotProps={{ backdrop: { sx: { backdropFilter: "blur(30px)" } } }}
|
|
>
|
|
<SpacedRow sx={{ pt: 2, px: 2.5 }}>
|
|
<DialogTitle>{t("shortcuts")}</DialogTitle>
|
|
<DialogCloseIconButton {...{ onClose }} />
|
|
</SpacedRow>
|
|
<ShortcutsContent>
|
|
<Shortcut action={t("close")} shortcut={ut("Esc")} />
|
|
<Shortcut
|
|
action={formattedListJoin([t("previous"), t("next")])}
|
|
shortcut={
|
|
// TODO(HLS):
|
|
shouldUsePlayerV2()
|
|
? `${formattedListJoin([ut("←"), ut("→")])} (Option/Alt)`
|
|
: formattedListJoin([ut("←"), ut("→")])
|
|
}
|
|
/>
|
|
{
|
|
/* TODO(HLS): */
|
|
shouldUsePlayerV2() && (
|
|
<Shortcut
|
|
action={pt("Video seek")}
|
|
shortcut={formattedListJoin([ut("←"), ut("→")])}
|
|
/>
|
|
)
|
|
}
|
|
<Shortcut
|
|
action={t("zoom")}
|
|
shortcut={formattedListJoin([t("mouse_scroll"), t("pinch")])}
|
|
/>
|
|
<Shortcut
|
|
action={t("zoom_preset")}
|
|
shortcut={formattedListJoin([ut("Z"), t("tap_inside_image")])}
|
|
/>
|
|
<Shortcut
|
|
action={t("toggle_controls")}
|
|
shortcut={formattedListJoin([ut("H"), t("tap_outside_image")])}
|
|
/>
|
|
<Shortcut
|
|
action={t("pan")}
|
|
shortcut={formattedListJoin([ut("W A S D"), t("drag")])}
|
|
/>
|
|
{
|
|
/* TODO(HLS): */
|
|
shouldUsePlayerV2() && (
|
|
<Shortcut
|
|
action={pt("Play, Pause")}
|
|
shortcut={ut("Space")}
|
|
/>
|
|
)
|
|
}
|
|
<Shortcut action={t("toggle_live")} shortcut={ut("Space")} />
|
|
<Shortcut action={t("toggle_audio")} shortcut={ut("M")} />
|
|
{haveUser && (
|
|
<Shortcut action={t("toggle_favorite")} shortcut={ut("L")} />
|
|
)}
|
|
<Shortcut action={t("view_info")} shortcut={ut("I")} />
|
|
{!disableDownload && (
|
|
<Shortcut action={t("download")} shortcut={ut("K")} />
|
|
)}
|
|
{haveUser && (
|
|
<Shortcut
|
|
action={t("delete")}
|
|
shortcut={formattedListJoin([
|
|
ut("Delete"),
|
|
ut("Backspace"),
|
|
])}
|
|
/>
|
|
)}
|
|
{haveUser && (
|
|
<Shortcut action={t("toggle_archive")} shortcut={ut("X")} />
|
|
)}
|
|
{!disableDownload && (
|
|
<Shortcut action={t("copy_as_png")} shortcut={ut("^C / ⌘C")} />
|
|
)}
|
|
<Shortcut action={t("toggle_fullscreen")} shortcut={ut("F")} />
|
|
<Shortcut action={t("show_shortcuts")} shortcut={ut("?")} />
|
|
</ShortcutsContent>
|
|
</Dialog>
|
|
);
|
|
|
|
const ShortcutsContent: React.FC<React.PropsWithChildren> = ({ children }) => (
|
|
<DialogContent sx={{ "&&": { pt: 1, pb: 5, px: 5 } }}>
|
|
<ShortcutsTable>
|
|
<tbody>{children}</tbody>
|
|
</ShortcutsTable>
|
|
</DialogContent>
|
|
);
|
|
|
|
const ShortcutsTable = styled("table")`
|
|
border-collapse: separate;
|
|
border-spacing: 0 14px;
|
|
`;
|
|
|
|
interface ShortcutProps {
|
|
action: string;
|
|
shortcut: string;
|
|
}
|
|
|
|
const Shortcut: React.FC<ShortcutProps> = ({ action, shortcut }) => (
|
|
<tr>
|
|
<Typography
|
|
component="td"
|
|
sx={{ color: "text.muted", width: "min(20ch, 40svw)" }}
|
|
>
|
|
{action}
|
|
</Typography>
|
|
|
|
<Typography component="td" sx={{ fontWeight: "medium" }}>
|
|
{shortcut}
|
|
</Typography>
|
|
</tr>
|
|
);
|
|
|
|
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;
|
|
};
|
|
|
|
/**
|
|
* Return a promise that resolves with a "image/png" blob derived from the given
|
|
* {@link imageURL} that can be written to the navigator's clipboard.
|
|
*/
|
|
const createImagePNGBlob = async (imageURL: string): Promise<Blob> =>
|
|
new Promise((resolve, reject) => {
|
|
const image = new Image();
|
|
image.onload = () => {
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = image.width;
|
|
canvas.height = image.height;
|
|
canvas.getContext("2d")!.drawImage(image, 0, 0);
|
|
canvas.toBlob(
|
|
(blob) =>
|
|
blob ? resolve(blob) : reject(new Error("toBlob failed")),
|
|
"image/png",
|
|
);
|
|
};
|
|
image.onerror = reject;
|
|
image.src = imageURL;
|
|
});
|