mirror of
https://github.com/mudita/mudita-center.git
synced 2025-12-23 14:20:40 -05:00
[CP-3663] Refactored image preview (#2638)
Co-authored-by: slawomir-werner <slawomir.werner@mudita.com>
This commit is contained in:
committed by
GitHub
parent
f5bf7291ba
commit
4ab382ec6f
@@ -1056,7 +1056,6 @@
|
||||
"module.genericViews.filesManager.preview.unknownError": "Failed to load photo.\nPlease try again.",
|
||||
"module.genericViews.filesManager.preview.backButton": "Back",
|
||||
"module.genericViews.filesManager.preview.retryButton": "Try again",
|
||||
|
||||
"module.genericViews.appInstallation.progress.modalTitle": "Installing the App...",
|
||||
"module.genericViews.appInstallation.error.modalTitle": "Installation failed",
|
||||
"module.genericViews.appInstallation.error.globalMessage": "The installation process was interrupted.",
|
||||
|
||||
@@ -15,22 +15,28 @@ import localeEn from "Core/__deprecated__/renderer/locales/default/en-US.json"
|
||||
import { ModalProvider } from "Core/__deprecated__/renderer/components/core/modal/modal.service"
|
||||
import modalService from "Core/__deprecated__/renderer/components/core/modal/modal.service"
|
||||
import AppsSwitch from "Core/core/components/apps-switch"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools"
|
||||
|
||||
const RootWrapper: FunctionComponent = () => {
|
||||
const queryClient = new QueryClient()
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<IntlProvider
|
||||
defaultLocale={translationConfig.defaultLanguage}
|
||||
locale={translationConfig.defaultLanguage}
|
||||
messages={localeEn}
|
||||
>
|
||||
<ModalProvider service={modalService}>
|
||||
<Normalize />
|
||||
<GlobalStyle />
|
||||
<AppsSwitch />
|
||||
</ModalProvider>
|
||||
</IntlProvider>
|
||||
</ThemeProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
<ThemeProvider theme={theme}>
|
||||
<IntlProvider
|
||||
defaultLocale={translationConfig.defaultLanguage}
|
||||
locale={translationConfig.defaultLanguage}
|
||||
messages={localeEn}
|
||||
>
|
||||
<ModalProvider service={modalService}>
|
||||
<Normalize />
|
||||
<GlobalStyle />
|
||||
<AppsSwitch />
|
||||
</ModalProvider>
|
||||
</IntlProvider>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -22,10 +22,10 @@ export const generateFilesExportButtonActions = (
|
||||
{
|
||||
singleEntityId,
|
||||
entityType,
|
||||
exportActionId,
|
||||
exportActionId = entityType + "Export",
|
||||
}: Pick<McFileManagerConfig["categories"][number], "entityType"> & {
|
||||
singleEntityId?: string
|
||||
exportActionId: string
|
||||
exportActionId?: string
|
||||
}
|
||||
): ButtonTextConfig["actions"] => {
|
||||
return [
|
||||
@@ -128,9 +128,17 @@ export const generateFileExportProcessButton: ComponentGenerator<
|
||||
"entityType" | "directoryPath"
|
||||
> & {
|
||||
singleEntityId?: string
|
||||
exportActionId: string
|
||||
exportActionId?: string
|
||||
}
|
||||
> = (key, { directoryPath, entityType, singleEntityId, exportActionId }) => {
|
||||
> = (
|
||||
key,
|
||||
{
|
||||
directoryPath,
|
||||
entityType,
|
||||
singleEntityId,
|
||||
exportActionId = entityType + "Export",
|
||||
}
|
||||
) => {
|
||||
return {
|
||||
[generateFilesExportProcessButtonKey(key)]: {
|
||||
component: "button-text",
|
||||
|
||||
@@ -39,7 +39,9 @@ const generateFileList: ComponentGenerator<
|
||||
features,
|
||||
}
|
||||
) => {
|
||||
const isExportEnabled = features?.includes("export")
|
||||
const isExportEnabled = Boolean(features?.includes("export"))
|
||||
const isPreviewEnabled =
|
||||
Boolean(features?.includes("preview")) && entityType === "imageFiles"
|
||||
return {
|
||||
[`${key}${id}fileListContainer`]: {
|
||||
component: "conditional-renderer",
|
||||
@@ -297,7 +299,6 @@ const generateFileList: ComponentGenerator<
|
||||
...generateFileExportProcessButton(`${key}${id}`, {
|
||||
directoryPath,
|
||||
entityType,
|
||||
exportActionId: entityType + "Export",
|
||||
}),
|
||||
[`${key}${id}deleteButton`]: {
|
||||
component: "button-text",
|
||||
@@ -355,7 +356,7 @@ const generateFileList: ComponentGenerator<
|
||||
allIdsFieldName: "allItems",
|
||||
},
|
||||
previewOptions: {
|
||||
enabled: entityType === "imageFiles",
|
||||
enabled: isPreviewEnabled,
|
||||
entitiesType: entityType,
|
||||
entityIdFieldName: "id",
|
||||
entityPathFieldName: "filePath",
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Copyright (c) Mudita sp. z o.o. All rights reserved.
|
||||
* For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
export enum FilePreviewErrorType {
|
||||
UnsupportedFileType = "unsupported-file-type",
|
||||
UnsupportedTransferMode = "unsupported-transfer-mode",
|
||||
FileNotFound = "file-not-found",
|
||||
Unknown = "unknown",
|
||||
}
|
||||
|
||||
export type FilePreviewError = {
|
||||
type: FilePreviewErrorType
|
||||
details?: string
|
||||
}
|
||||
|
||||
export const isFilePreviewError = (
|
||||
error: unknown
|
||||
): error is FilePreviewError => {
|
||||
return (
|
||||
typeof error === "object" &&
|
||||
"type" in (error as FilePreviewError) &&
|
||||
Object.values(FilePreviewErrorType).includes(
|
||||
(error as FilePreviewError).type
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) Mudita sp. z o.o. All rights reserved.
|
||||
* For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
export enum FilePreviewErrorType {
|
||||
UnsupportedFileType = "unsupported-file-type",
|
||||
UnsupportedTransferType = "unsupported-transfer-type",
|
||||
Unknown = "unknown",
|
||||
}
|
||||
|
||||
export type FilePreviewError = {
|
||||
type: FilePreviewErrorType
|
||||
details?: string
|
||||
}
|
||||
|
||||
export type FilePreviewErrorHandler = (error: FilePreviewError | undefined) => void
|
||||
@@ -8,35 +8,26 @@ import React, {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
import { useSelector } from "react-redux"
|
||||
import styled, { css } from "styled-components"
|
||||
import { selectEntityData, selectFilesTransferMode } from "generic-view/store"
|
||||
import { IconType } from "generic-view/utils"
|
||||
import { Modal } from "../../interactive/modal/modal"
|
||||
import { IconButton } from "../../shared/button"
|
||||
import { Icon } from "../../icon/icon"
|
||||
import { Typography } from "../../typography"
|
||||
import { SpinnerLoader } from "../../shared/spinner-loader"
|
||||
import { ImagePreview } from "./image-preview"
|
||||
import { AnimatePresence, motion } from "motion/react"
|
||||
import { useFilesPreview, UseFilesPreviewParams } from "./use-files-preview"
|
||||
import { activeDeviceIdSelector } from "active-device-registry/feature"
|
||||
import { ReduxRootState } from "Core/__deprecated__/renderer/store"
|
||||
import { FilePreviewEntitiesConfig, useFilePreview } from "./use-file-preview"
|
||||
import { ButtonIcon } from "../../buttons/button-icon"
|
||||
import { generateFilesExportButtonActions } from "../../generated/mc-file-manager/file-export-button"
|
||||
import { generateDeleteFilesButtonActions } from "../../generated/mc-file-manager/delete-files"
|
||||
import { ModalLayers } from "Core/modals-manager/constants/modal-layers.enum"
|
||||
import {
|
||||
FilePreviewError,
|
||||
FilePreviewErrorType,
|
||||
} from "./file-preview-error.types"
|
||||
import { defineMessages } from "react-intl"
|
||||
import { intl } from "Core/__deprecated__/renderer/utils/intl"
|
||||
import { ButtonSecondary } from "../../buttons/button-secondary"
|
||||
import { intl } from "Core/__deprecated__/renderer/utils/intl"
|
||||
import { ImagePreview } from "./image-preview"
|
||||
import { FilePreviewErrorType } from "./file-preview-error-types"
|
||||
import { FilePreviewLoader } from "./shared-components"
|
||||
|
||||
const messages = defineMessages({
|
||||
unsupportedFileType: {
|
||||
@@ -54,121 +45,128 @@ const messages = defineMessages({
|
||||
})
|
||||
|
||||
interface Props {
|
||||
componentKey: string
|
||||
entitiesConfig: UseFilesPreviewParams["entitiesConfig"]
|
||||
items: string[]
|
||||
activeItem: string | undefined
|
||||
initialItem: string | undefined
|
||||
opened: boolean
|
||||
onActiveItemChange: (item: string | undefined) => void
|
||||
actions?: React.ReactNode
|
||||
onClose: VoidFunction
|
||||
componentKey: string
|
||||
entitiesConfig: FilePreviewEntitiesConfig
|
||||
}
|
||||
|
||||
export const FilePreview: FunctionComponent<Props> = memo(
|
||||
({ items, activeItem, onActiveItemChange, entitiesConfig, componentKey }) => {
|
||||
const deviceId = useSelector(activeDeviceIdSelector)
|
||||
const entity = useSelector((state: ReduxRootState) => {
|
||||
if (!activeItem || !deviceId) return undefined
|
||||
return selectEntityData(state, {
|
||||
deviceId,
|
||||
entitiesType: entitiesConfig.type,
|
||||
entityId: activeItem,
|
||||
})
|
||||
})
|
||||
const fileTransferMode = useSelector(selectFilesTransferMode)
|
||||
const nextIdReference = useRef<string>()
|
||||
({
|
||||
opened,
|
||||
items,
|
||||
initialItem,
|
||||
onClose,
|
||||
onActiveItemChange,
|
||||
entitiesConfig,
|
||||
componentKey,
|
||||
}) => {
|
||||
const [currentItemId, setCurrentItemId] = useState(initialItem)
|
||||
const [previewLoadingError, setPreviewLoadingError] = useState(false)
|
||||
const [entitiesIds, setEntitiesIds] = useState(items)
|
||||
|
||||
useEffect(() => {
|
||||
setEntitiesIds(items)
|
||||
}, [items])
|
||||
|
||||
const {
|
||||
data: fileInfo,
|
||||
nextId,
|
||||
previousId,
|
||||
refetch,
|
||||
data,
|
||||
isLoading,
|
||||
} = useFilesPreview({
|
||||
items,
|
||||
activeItem,
|
||||
isPending,
|
||||
isFetching,
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
cancel,
|
||||
dataUpdatedAt,
|
||||
fileName,
|
||||
getNextFileId,
|
||||
getPrevFileId,
|
||||
} = useFilePreview({
|
||||
entitiesIds,
|
||||
entitiesType: entitiesConfig.type,
|
||||
entityId: currentItemId,
|
||||
entitiesConfig,
|
||||
})
|
||||
|
||||
const [fileUid, setFileUid] = useState(Date.now())
|
||||
const [error, setError] = useState<FilePreviewError>()
|
||||
const isFinished = (!isLoading && !!fileInfo) || !!error
|
||||
|
||||
const entityType = useMemo(() => {
|
||||
if (!entity) return ""
|
||||
return entity[entitiesConfig.mimeTypeField] as string
|
||||
}, [entitiesConfig.mimeTypeField, entity])
|
||||
|
||||
const entityName = useMemo(() => {
|
||||
if (!entity) return ""
|
||||
return entity[entitiesConfig.titleField] as string
|
||||
}, [entitiesConfig.titleField, entity])
|
||||
const inProgress = isLoading || isPending || isFetching
|
||||
const hasError = isError || previewLoadingError
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
onActiveItemChange(undefined)
|
||||
setError(undefined)
|
||||
nextIdReference.current = undefined
|
||||
}, [onActiveItemChange])
|
||||
onClose()
|
||||
}, [onClose])
|
||||
|
||||
const handlePreviousFile = useCallback(() => {
|
||||
setError(undefined)
|
||||
onActiveItemChange(previousId)
|
||||
setFileUid(Date.now())
|
||||
}, [onActiveItemChange, previousId])
|
||||
const handlePreviousFile = useCallback(async () => {
|
||||
await cancel()
|
||||
setPreviewLoadingError(false)
|
||||
setCurrentItemId(getPrevFileId())
|
||||
}, [cancel, getPrevFileId])
|
||||
|
||||
const handleNextFile = useCallback(() => {
|
||||
setError(undefined)
|
||||
onActiveItemChange(nextIdReference.current || nextId)
|
||||
setFileUid(Date.now())
|
||||
}, [nextId, onActiveItemChange])
|
||||
const handleNextFile = useCallback(async () => {
|
||||
await cancel()
|
||||
setPreviewLoadingError(false)
|
||||
setCurrentItemId(getNextFileId())
|
||||
}, [cancel, getNextFileId])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (event.key === "ArrowLeft") {
|
||||
handlePreviousFile()
|
||||
void handlePreviousFile()
|
||||
} else if (event.key === "ArrowRight") {
|
||||
handleNextFile()
|
||||
void handleNextFile()
|
||||
}
|
||||
},
|
||||
[handleNextFile, handlePreviousFile]
|
||||
)
|
||||
|
||||
const handleRetry = useCallback(async () => {
|
||||
if (!activeItem) return
|
||||
setError(undefined)
|
||||
await refetch()
|
||||
setFileUid(Date.now())
|
||||
}, [activeItem, refetch])
|
||||
const handlePreviewError = useCallback(() => {
|
||||
setPreviewLoadingError(true)
|
||||
}, [])
|
||||
|
||||
const handleRetry = useCallback(() => {
|
||||
setPreviewLoadingError(false)
|
||||
void refetch()
|
||||
}, [refetch])
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener("keydown", handleKeyDown)
|
||||
if (!activeItem) {
|
||||
void (async () => {
|
||||
// Open next file preview if current file was deleted
|
||||
if (currentItemId && !items.includes(currentItemId)) {
|
||||
await handleNextFile()
|
||||
setEntitiesIds(items)
|
||||
}
|
||||
})()
|
||||
}, [currentItemId, handleNextFile, items])
|
||||
|
||||
useEffect(() => {
|
||||
// Set initial state when opening preview modal
|
||||
setCurrentItemId(initialItem)
|
||||
}, [initialItem])
|
||||
|
||||
useEffect(() => {
|
||||
// Notify parent component about active file change
|
||||
onActiveItemChange(currentItemId)
|
||||
}, [currentItemId, getNextFileId, onActiveItemChange])
|
||||
|
||||
useEffect(() => {
|
||||
if (initialItem) {
|
||||
document.addEventListener("keydown", handleKeyDown)
|
||||
} else {
|
||||
document.removeEventListener("keydown", handleKeyDown)
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown)
|
||||
}
|
||||
}, [activeItem, handleKeyDown])
|
||||
|
||||
useEffect(() => {
|
||||
if (fileTransferMode !== "mtp") {
|
||||
setError({
|
||||
type: FilePreviewErrorType.UnsupportedTransferType,
|
||||
})
|
||||
} else if (error?.type === FilePreviewErrorType.UnsupportedTransferType) {
|
||||
void handleRetry()
|
||||
}
|
||||
}, [error?.type, fileTransferMode, handleRetry])
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeItem) {
|
||||
setError(undefined)
|
||||
}
|
||||
}, [activeItem])
|
||||
}, [handleKeyDown, initialItem])
|
||||
|
||||
return (
|
||||
<Modal
|
||||
componentKey={"file-preview-modal"}
|
||||
config={{
|
||||
defaultOpened: Boolean(activeItem),
|
||||
defaultOpened: opened,
|
||||
width: 960,
|
||||
maxHeight: 640,
|
||||
padding: 0,
|
||||
@@ -177,8 +175,8 @@ export const FilePreview: FunctionComponent<Props> = memo(
|
||||
>
|
||||
<ModalContent>
|
||||
<Header>
|
||||
<Typography.P1 config={{ color: "white" }}>
|
||||
{entityName}
|
||||
<Typography.P1 config={{ color: "white" }} id={"file-preview-name"}>
|
||||
{fileName}
|
||||
</Typography.P1>
|
||||
<IconButton onClick={handleClose}>
|
||||
<Icon
|
||||
@@ -191,94 +189,80 @@ export const FilePreview: FunctionComponent<Props> = memo(
|
||||
</IconButton>
|
||||
</Header>
|
||||
<Main>
|
||||
<AnimatePresence initial={true} mode={"wait"}>
|
||||
{!error && (
|
||||
<Loader
|
||||
<AnimatePresence initial={true} mode={"popLayout"}>
|
||||
{hasError ? (
|
||||
<ErrorWrapper
|
||||
key={"error"}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<SpinnerLoader />
|
||||
</Loader>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<AnimatePresence initial={true} mode={"wait"}>
|
||||
{isFinished && (
|
||||
<ErrorIcon
|
||||
config={{
|
||||
type: IconType.Exclamation,
|
||||
size: "large",
|
||||
color: "white",
|
||||
}}
|
||||
/>
|
||||
{error?.type === FilePreviewErrorType.UnsupportedFileType ? (
|
||||
<>
|
||||
<Typography.P1>
|
||||
{intl.formatMessage(messages.unsupportedFileType, {
|
||||
type: error.details,
|
||||
})}
|
||||
</Typography.P1>
|
||||
<ButtonSecondary
|
||||
config={{
|
||||
text: intl.formatMessage(messages.backButton),
|
||||
actions: [
|
||||
{
|
||||
type: "custom",
|
||||
callback: handleClose,
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Typography.P1>
|
||||
{intl.formatMessage(messages.unknownError)}
|
||||
</Typography.P1>
|
||||
<ButtonSecondary
|
||||
config={{
|
||||
text: intl.formatMessage(messages.retryButton),
|
||||
actions: [
|
||||
{
|
||||
type: "custom",
|
||||
callback: handleRetry,
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</ErrorWrapper>
|
||||
) : inProgress ? (
|
||||
<FilePreviewLoader />
|
||||
) : (
|
||||
<PreviewWrapper
|
||||
key={fileInfo?.path}
|
||||
key={data?.filePath}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
{entityType.startsWith("image") && (
|
||||
{data?.fileType?.startsWith("image") && (
|
||||
<ImagePreview
|
||||
src={fileInfo?.path}
|
||||
fileUid={fileUid}
|
||||
onError={setError}
|
||||
src={
|
||||
data.filePath
|
||||
? `${data.filePath}?t=${dataUpdatedAt}`
|
||||
: ""
|
||||
}
|
||||
onError={handlePreviewError}
|
||||
/>
|
||||
)}
|
||||
<AnimatePresence initial={true} mode={"wait"}>
|
||||
{Boolean(error) && (
|
||||
<ErrorWrapper
|
||||
key={fileInfo?.path}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<ErrorIcon
|
||||
config={{
|
||||
type: IconType.Exclamation,
|
||||
size: "large",
|
||||
color: "white",
|
||||
}}
|
||||
/>
|
||||
{error?.type ===
|
||||
FilePreviewErrorType.UnsupportedFileType ? (
|
||||
<>
|
||||
<Typography.P1>
|
||||
{intl.formatMessage(
|
||||
messages.unsupportedFileType,
|
||||
{
|
||||
type: error.details,
|
||||
}
|
||||
)}
|
||||
</Typography.P1>
|
||||
<ButtonSecondary
|
||||
config={{
|
||||
text: intl.formatMessage(messages.backButton),
|
||||
actions: [
|
||||
{
|
||||
type: "custom",
|
||||
callback: handleClose,
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Typography.P1>
|
||||
{intl.formatMessage(messages.unknownError)}
|
||||
</Typography.P1>
|
||||
<ButtonSecondary
|
||||
config={{
|
||||
text: intl.formatMessage(messages.retryButton),
|
||||
actions: [
|
||||
{
|
||||
type: "custom",
|
||||
callback: handleRetry,
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</ErrorWrapper>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</PreviewWrapper>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
@@ -312,8 +296,7 @@ export const FilePreview: FunctionComponent<Props> = memo(
|
||||
iconSize: "large",
|
||||
actions: [
|
||||
...generateFilesExportButtonActions(componentKey, {
|
||||
exportActionId: "previewExport",
|
||||
singleEntityId: activeItem,
|
||||
singleEntityId: currentItemId,
|
||||
entityType: entitiesConfig.type,
|
||||
}),
|
||||
],
|
||||
@@ -324,7 +307,7 @@ export const FilePreview: FunctionComponent<Props> = memo(
|
||||
icon: IconType.Delete,
|
||||
iconSize: "large",
|
||||
actions: generateDeleteFilesButtonActions(componentKey, {
|
||||
singleEntityId: activeItem,
|
||||
singleEntityId: currentItemId,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
@@ -454,14 +437,6 @@ const ModalContent = styled.section`
|
||||
}
|
||||
`
|
||||
|
||||
const Loader = styled(motion.div)`
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 1;
|
||||
`
|
||||
|
||||
const PreviewWrapper = styled(motion.div)`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
@@ -469,19 +444,7 @@ const PreviewWrapper = styled(motion.div)`
|
||||
z-index: 2;
|
||||
`
|
||||
|
||||
const Main = styled.main`
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
height: 64rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: ${({ theme }) => theme.color.grey0};
|
||||
`
|
||||
|
||||
const ErrorWrapper = styled(motion.div)`
|
||||
background-color: ${({ theme }) => theme.color.grey0};
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
@@ -522,3 +485,14 @@ const ErrorIcon = styled(Icon)`
|
||||
border-radius: 50%;
|
||||
background-color: ${({ theme }) => theme.color.white};
|
||||
`
|
||||
|
||||
const Main = styled.main`
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
height: 64rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: ${({ theme }) => theme.color.grey0};
|
||||
`
|
||||
|
||||
@@ -11,63 +11,55 @@ import React, {
|
||||
useState,
|
||||
} from "react"
|
||||
import styled from "styled-components"
|
||||
import {
|
||||
FilePreviewErrorHandler,
|
||||
FilePreviewErrorType,
|
||||
} from "./file-preview-error.types"
|
||||
import { AnimatePresence } from "motion/react"
|
||||
import { FilePreviewLoader } from "./shared-components"
|
||||
|
||||
interface Props {
|
||||
src?: string
|
||||
fileUid?: string | number
|
||||
onError?: FilePreviewErrorHandler
|
||||
onError?: () => void
|
||||
}
|
||||
|
||||
export const ImagePreview: FunctionComponent<Props> = ({
|
||||
src,
|
||||
fileUid,
|
||||
onError,
|
||||
}) => {
|
||||
const loadedTimeoutRef = useRef<NodeJS.Timeout>()
|
||||
export const ImagePreview: FunctionComponent<Props> = ({ src, onError }) => {
|
||||
const imgRef = useRef<HTMLImageElement | null>(null)
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const uniqueSrc = `${src}?u=${fileUid}`
|
||||
|
||||
const handleLoad = useCallback(() => {
|
||||
setLoaded(true)
|
||||
onError?.(undefined)
|
||||
const onLoad = useCallback(async () => {
|
||||
const img = imgRef.current
|
||||
|
||||
try {
|
||||
if (!img) {
|
||||
throw new Error("Image not found")
|
||||
}
|
||||
await img.decode()
|
||||
setLoaded(true)
|
||||
} catch {
|
||||
onError?.()
|
||||
}
|
||||
}, [onError])
|
||||
|
||||
const handleError = useCallback(() => {
|
||||
if (src?.endsWith(".heic")) {
|
||||
onError?.({
|
||||
type: FilePreviewErrorType.UnsupportedFileType,
|
||||
details: "HEIC",
|
||||
})
|
||||
} else if (src?.endsWith(".heif")) {
|
||||
onError?.({
|
||||
type: FilePreviewErrorType.UnsupportedFileType,
|
||||
details: "HEIF",
|
||||
})
|
||||
} else {
|
||||
onError?.({ type: FilePreviewErrorType.Unknown })
|
||||
}
|
||||
}, [onError, src])
|
||||
|
||||
useEffect(() => {
|
||||
clearTimeout(loadedTimeoutRef.current)
|
||||
setLoaded(false)
|
||||
}, [src])
|
||||
|
||||
return (
|
||||
<Wrapper $loaded={loaded}>
|
||||
<BackgroundImage style={{ backgroundImage: `url("${uniqueSrc}")` }} />
|
||||
<MainImage
|
||||
key={src}
|
||||
src={uniqueSrc}
|
||||
alt={""}
|
||||
onLoad={handleLoad}
|
||||
onError={handleError}
|
||||
/>
|
||||
</Wrapper>
|
||||
<>
|
||||
<Wrapper $loaded={loaded}>
|
||||
<BackgroundImage style={{ backgroundImage: `url("${src}")` }} />
|
||||
<MainImage
|
||||
ref={imgRef}
|
||||
key={src}
|
||||
src={src}
|
||||
alt={""}
|
||||
aria-labelledby={"file-preview-name"}
|
||||
onLoad={onLoad}
|
||||
onError={onError}
|
||||
decoding={"async"}
|
||||
/>
|
||||
</Wrapper>
|
||||
<AnimatePresence initial={true} mode={"popLayout"}>
|
||||
{!loaded && <FilePreviewLoader />}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Copyright (c) Mudita sp. z o.o. All rights reserved.
|
||||
* For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import styled from "styled-components"
|
||||
import { motion } from "motion/react"
|
||||
import React, { FunctionComponent } from "react"
|
||||
import { SpinnerLoader } from "../../shared/spinner-loader"
|
||||
|
||||
const FilePreviewLoaderWrapper = styled(motion.div)`
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 1;
|
||||
`
|
||||
|
||||
export const FilePreviewLoader: FunctionComponent = () => {
|
||||
return (
|
||||
<FilePreviewLoaderWrapper
|
||||
key="loader"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<SpinnerLoader />
|
||||
</FilePreviewLoaderWrapper>
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { useDispatch, useSelector, useStore } from "react-redux"
|
||||
import {
|
||||
FileWithPath,
|
||||
selectEntityData,
|
||||
selectFilesSendingFailed,
|
||||
sendFiles,
|
||||
SendFilesAction,
|
||||
} from "generic-view/store"
|
||||
@@ -23,6 +24,7 @@ import path from "node:path"
|
||||
export interface FilePreviewResponse {
|
||||
id: string
|
||||
path: string
|
||||
nativePath: string
|
||||
name: string
|
||||
type: string
|
||||
}
|
||||
@@ -62,7 +64,7 @@ export const useFilePreviewDownload = ({
|
||||
const nativePath = path.join(nativeDir, fileName)
|
||||
return {
|
||||
nativeDir,
|
||||
nativePath: nativePath,
|
||||
nativePath,
|
||||
safePath:
|
||||
process.platform === "win32"
|
||||
? nativePath
|
||||
@@ -72,30 +74,50 @@ export const useFilePreviewDownload = ({
|
||||
[tempDirectoryPath]
|
||||
)
|
||||
|
||||
const getEntityData = useCallback(
|
||||
(id?: string) => {
|
||||
const entity =
|
||||
deviceId && id
|
||||
? selectEntityData(store.getState(), {
|
||||
deviceId,
|
||||
entitiesType,
|
||||
entityId: id,
|
||||
})
|
||||
: undefined
|
||||
|
||||
return {
|
||||
fileName: (entity?.[fields.titleField] as string) || "",
|
||||
fileType: (entity?.[fields.mimeTypeField] as string) || "",
|
||||
filePath: (entity?.[fields.pathField] as string) || "",
|
||||
fileSize: entity?.[fields.sizeField] as number | undefined,
|
||||
isInternal: entity?.["isInternal"] as boolean | undefined,
|
||||
}
|
||||
},
|
||||
[
|
||||
deviceId,
|
||||
store,
|
||||
entitiesType,
|
||||
fields.titleField,
|
||||
fields.mimeTypeField,
|
||||
fields.pathField,
|
||||
fields.sizeField,
|
||||
]
|
||||
)
|
||||
|
||||
const downloadFile = useCallback(
|
||||
async (
|
||||
entityId: string,
|
||||
onFileNameFound?: (name: string) => void
|
||||
): Promise<FilePreviewResponse | undefined> => {
|
||||
async (entityId: string): Promise<FilePreviewResponse | undefined> => {
|
||||
if (!deviceId || !tempDirectoryPath) {
|
||||
return
|
||||
}
|
||||
const entity = selectEntityData(store.getState(), {
|
||||
deviceId,
|
||||
entitiesType,
|
||||
entityId,
|
||||
})
|
||||
const entity = getEntityData(entityId)
|
||||
|
||||
if (!entity || !("filePath" in entity) || !("fileName" in entity)) {
|
||||
if (!entity.fileName || !entity.filePath || !entity.fileType) {
|
||||
return
|
||||
}
|
||||
|
||||
const fileName = entity[fields.titleField] as string
|
||||
onFileNameFound?.(fileName)
|
||||
const fileType = (entity[fields.mimeTypeField] as string)
|
||||
.split("/")[0]
|
||||
.toLowerCase()
|
||||
const filePath = getFilePath(entity[fields.pathField] as string)
|
||||
const fileName = entity.fileName
|
||||
const fileType = entity.fileType.split("/")[0].toLowerCase()
|
||||
const filePath = getFilePath(entity.filePath)
|
||||
if (!filePath) {
|
||||
return
|
||||
}
|
||||
@@ -104,6 +126,7 @@ export const useFilePreviewDownload = ({
|
||||
return {
|
||||
id: entityId,
|
||||
path: filePath.safePath,
|
||||
nativePath: filePath.nativePath,
|
||||
name: fileName,
|
||||
type: fileType,
|
||||
}
|
||||
@@ -112,12 +135,9 @@ export const useFilePreviewDownload = ({
|
||||
|
||||
const fileData: FileWithPath = {
|
||||
id: entityId,
|
||||
path: sliceMtpPaths(
|
||||
entity[fields.pathField] as string,
|
||||
entity.isInternal as boolean
|
||||
),
|
||||
name: String(entity[fields.titleField]),
|
||||
size: Number(entity[fields.sizeField]),
|
||||
path: sliceMtpPaths(entity.filePath, entity.isInternal as boolean),
|
||||
name: String(entity.fileName),
|
||||
size: Number(entity.fileSize),
|
||||
groupId: actionId,
|
||||
}
|
||||
|
||||
@@ -127,9 +147,7 @@ export const useFilePreviewDownload = ({
|
||||
destinationPath: filePath.nativeDir,
|
||||
actionId,
|
||||
entitiesType,
|
||||
isMtpPathInternal: isMtpPathInternal(
|
||||
entity[fields.pathField] as string
|
||||
),
|
||||
isMtpPathInternal: isMtpPathInternal(entity.filePath as string),
|
||||
actionType: SendFilesAction.ActionExport,
|
||||
})
|
||||
) as unknown as ReturnType<ReturnType<typeof sendFiles>>
|
||||
@@ -142,13 +160,20 @@ export const useFilePreviewDownload = ({
|
||||
|
||||
const response = await promise
|
||||
|
||||
if (response.meta.requestStatus === "rejected") {
|
||||
return
|
||||
const requestFailed =
|
||||
selectFilesSendingFailed(store.getState(), {
|
||||
filesIds: [entityId],
|
||||
}).length > 0
|
||||
|
||||
if (response.meta.requestStatus === "rejected" || requestFailed) {
|
||||
throw new Error("Preview download failed")
|
||||
}
|
||||
|
||||
delete abortReferences.current[entityId]
|
||||
return {
|
||||
id: entityId,
|
||||
path: filePath.safePath,
|
||||
nativePath: filePath.nativePath,
|
||||
name: fileName,
|
||||
type: fileType,
|
||||
}
|
||||
@@ -158,42 +183,34 @@ export const useFilePreviewDownload = ({
|
||||
deviceId,
|
||||
dispatch,
|
||||
entitiesType,
|
||||
fields.mimeTypeField,
|
||||
fields.pathField,
|
||||
fields.sizeField,
|
||||
fields.titleField,
|
||||
getEntityData,
|
||||
getFilePath,
|
||||
store,
|
||||
tempDirectoryPath,
|
||||
]
|
||||
)
|
||||
|
||||
const removePreviewFile = useCallback(
|
||||
async (entityId: string) => {
|
||||
if (!deviceId || !tempDirectoryPath) {
|
||||
return
|
||||
const removeFile = useCallback(
|
||||
async (params: { entityId: string } | { path: string }) => {
|
||||
let filePath: string | undefined
|
||||
|
||||
if ("path" in params) {
|
||||
filePath = params.path
|
||||
} else if ("entityId" in params) {
|
||||
if (!deviceId || !tempDirectoryPath) {
|
||||
return
|
||||
}
|
||||
const entity = getEntityData(params.entityId)
|
||||
if (!entity.filePath) {
|
||||
return
|
||||
}
|
||||
filePath = getFilePath(entity.filePath)?.nativePath
|
||||
}
|
||||
const entity = selectEntityData(store.getState(), {
|
||||
deviceId,
|
||||
entitiesType,
|
||||
entityId,
|
||||
})
|
||||
if (!entity) {
|
||||
return
|
||||
}
|
||||
const path = getFilePath(entity[fields.pathField] as string)?.nativePath
|
||||
if (path) {
|
||||
await removeDirectory(path)
|
||||
if (filePath) {
|
||||
await removeDirectory(filePath)
|
||||
}
|
||||
},
|
||||
[
|
||||
deviceId,
|
||||
entitiesType,
|
||||
fields.pathField,
|
||||
getFilePath,
|
||||
store,
|
||||
tempDirectoryPath,
|
||||
]
|
||||
[deviceId, getEntityData, getFilePath, tempDirectoryPath]
|
||||
)
|
||||
|
||||
const cancelDownload = useCallback(
|
||||
@@ -202,13 +219,14 @@ export const useFilePreviewDownload = ({
|
||||
abortReferences.current[entityId]()
|
||||
delete abortReferences.current[entityId]
|
||||
}
|
||||
await removePreviewFile(entityId)
|
||||
await removeFile({ entityId })
|
||||
},
|
||||
[removePreviewFile]
|
||||
[removeFile]
|
||||
)
|
||||
|
||||
return {
|
||||
downloadFile,
|
||||
cancelDownload,
|
||||
removeFile,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* Copyright (c) Mudita sp. z o.o. All rights reserved.
|
||||
* For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import { Query, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { useDispatch, useSelector, useStore } from "react-redux"
|
||||
import { activeDeviceIdSelector } from "active-device-registry/feature"
|
||||
import { AppDispatch, ReduxRootState } from "Core/__deprecated__/renderer/store"
|
||||
import {
|
||||
FilesTransferMode,
|
||||
selectEntityData,
|
||||
selectFilesTransferMode,
|
||||
sendFilesClear,
|
||||
setFilesTransferMode,
|
||||
} from "generic-view/store"
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { EventEmitter } from "events"
|
||||
import {
|
||||
useFilePreviewDownload,
|
||||
UseFilePreviewDownloadParams,
|
||||
} from "./use-file-preview-download"
|
||||
import { checkPath, getAppPath, removeDirectory } from "system-utils/feature"
|
||||
import {
|
||||
FilePreviewError,
|
||||
FilePreviewErrorType,
|
||||
isFilePreviewError,
|
||||
} from "./file-preview-error-types"
|
||||
|
||||
export type FilePreviewEntitiesConfig =
|
||||
UseFilePreviewDownloadParams["fields"] & {
|
||||
type: string
|
||||
}
|
||||
|
||||
const STALE_TIME = Infinity
|
||||
const GC_TIME = 60 * 60 * 1000 // 1 hour
|
||||
|
||||
enum QueueEvent {
|
||||
Busy = "queueBusy",
|
||||
Free = "queueFree",
|
||||
}
|
||||
|
||||
type QueryData = {
|
||||
fileName: string
|
||||
fileType?: string
|
||||
filePath?: string
|
||||
nativeFilePath?: string
|
||||
} | null
|
||||
|
||||
interface Params {
|
||||
entitiesIds: string[]
|
||||
entitiesType: string
|
||||
entityId?: string
|
||||
entitiesConfig: FilePreviewEntitiesConfig
|
||||
}
|
||||
|
||||
export const useFilePreview = ({
|
||||
entitiesIds,
|
||||
entitiesType,
|
||||
entityId,
|
||||
entitiesConfig,
|
||||
}: Params) => {
|
||||
const store = useStore<ReduxRootState>()
|
||||
const dispatch = useDispatch<AppDispatch>()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const eventEmitterRef = useRef(new EventEmitter())
|
||||
const queueBusyRef = useRef(false)
|
||||
|
||||
const [tempDirectoryPath, setTempDirectoryPath] = useState<string>()
|
||||
const deviceId = useSelector(activeDeviceIdSelector)
|
||||
const fileTransferMode = useSelector(selectFilesTransferMode)
|
||||
|
||||
const actionId = entitiesConfig.type + "Preview"
|
||||
const queryEnabled = entityId !== undefined
|
||||
|
||||
const { downloadFile, cancelDownload, removeFile } = useFilePreviewDownload({
|
||||
actionId,
|
||||
tempDirectoryPath,
|
||||
entitiesType: entitiesConfig.type,
|
||||
fields: entitiesConfig,
|
||||
})
|
||||
|
||||
const ensureTempDirectory = useCallback(async () => {
|
||||
const destinationPath = await getAppPath("filePreview")
|
||||
if (destinationPath.ok) {
|
||||
await checkPath(destinationPath.data, true)
|
||||
setTempDirectoryPath(destinationPath.data)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const clearTempDirectory = useCallback(async () => {
|
||||
dispatch(sendFilesClear({ groupId: actionId }))
|
||||
if (tempDirectoryPath) {
|
||||
await removeDirectory(tempDirectoryPath)
|
||||
}
|
||||
}, [actionId, dispatch, tempDirectoryPath])
|
||||
|
||||
const getBaseFileData = useCallback(
|
||||
(id?: string) => {
|
||||
const entity =
|
||||
deviceId && id
|
||||
? selectEntityData(store.getState(), {
|
||||
deviceId,
|
||||
entitiesType,
|
||||
entityId: id,
|
||||
})
|
||||
: undefined
|
||||
|
||||
return {
|
||||
fileName: (entity?.[entitiesConfig.titleField] as string) || "",
|
||||
fileType: (entity?.[entitiesConfig.mimeTypeField] as string) || "",
|
||||
}
|
||||
},
|
||||
[
|
||||
deviceId,
|
||||
entitiesConfig.mimeTypeField,
|
||||
entitiesConfig.titleField,
|
||||
entitiesType,
|
||||
store,
|
||||
]
|
||||
)
|
||||
|
||||
const getNextFileId = useCallback(() => {
|
||||
const currentIndex = entitiesIds.findIndex((id) => id === entityId)
|
||||
const nextIndex = currentIndex + 1
|
||||
if (nextIndex < entitiesIds.length) {
|
||||
return entitiesIds[nextIndex]
|
||||
}
|
||||
return entitiesIds[0]
|
||||
}, [entitiesIds, entityId])
|
||||
|
||||
const getPrevFileId = useCallback(() => {
|
||||
const currentIndex = entitiesIds.findIndex((id) => id === entityId)
|
||||
const prevIndex = currentIndex - 1
|
||||
if (prevIndex >= 0) {
|
||||
return entitiesIds[prevIndex]
|
||||
}
|
||||
return entitiesIds[entitiesIds.length - 1]
|
||||
}, [entitiesIds, entityId])
|
||||
|
||||
const fileBaseData = useMemo(() => {
|
||||
return getBaseFileData(entityId)
|
||||
}, [entityId, getBaseFileData])
|
||||
|
||||
const queryKey = useMemo(() => {
|
||||
return ["filePreview", entityId]
|
||||
}, [entityId])
|
||||
|
||||
const queryFn = useCallback(
|
||||
async (id?: string, signal?: AbortSignal) => {
|
||||
if (!id) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (fileTransferMode !== FilesTransferMode.Mtp) {
|
||||
await cancelDownload(id)
|
||||
throw {
|
||||
type: FilePreviewErrorType.UnsupportedTransferMode,
|
||||
}
|
||||
}
|
||||
|
||||
// Delay request before blocking the queue, to properly handle quick switching between files
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
if (signal?.aborted) {
|
||||
await cancelDownload(id)
|
||||
return null
|
||||
}
|
||||
|
||||
eventEmitterRef.current.emit(QueueEvent.Busy)
|
||||
|
||||
if (queueBusyRef.current) {
|
||||
await new Promise<void>((resolve) => {
|
||||
eventEmitterRef.current.once(QueueEvent.Free, () => {
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
if (signal?.aborted) {
|
||||
await cancelDownload(id)
|
||||
return null
|
||||
}
|
||||
} else {
|
||||
queueBusyRef.current = true
|
||||
}
|
||||
|
||||
try {
|
||||
const entityData = getBaseFileData(id)
|
||||
|
||||
// Check for unsupported file types
|
||||
if (entityData.fileType.toLowerCase().startsWith("image")) {
|
||||
if (entityData.fileName.toLowerCase().endsWith(".heic")) {
|
||||
throw {
|
||||
type: FilePreviewErrorType.UnsupportedFileType,
|
||||
details: "HEIC",
|
||||
} satisfies FilePreviewError
|
||||
}
|
||||
if (entityData.fileName.toLowerCase().endsWith(".heif")) {
|
||||
throw {
|
||||
type: FilePreviewErrorType.UnsupportedFileType,
|
||||
details: "HEIF",
|
||||
} satisfies FilePreviewError
|
||||
}
|
||||
}
|
||||
|
||||
const file = await downloadFile(id)
|
||||
|
||||
if (!file) {
|
||||
throw {
|
||||
type: FilePreviewErrorType.FileNotFound,
|
||||
} satisfies FilePreviewError
|
||||
}
|
||||
|
||||
return {
|
||||
...entityData,
|
||||
filePath: file.path,
|
||||
nativeFilePath: file.nativePath,
|
||||
}
|
||||
} catch (error) {
|
||||
await cancelDownload(id)
|
||||
if (isFilePreviewError(error)) {
|
||||
throw error
|
||||
}
|
||||
throw {
|
||||
type: FilePreviewErrorType.Unknown,
|
||||
} satisfies FilePreviewError
|
||||
} finally {
|
||||
eventEmitterRef.current.emit(QueueEvent.Free)
|
||||
queueBusyRef.current = false
|
||||
}
|
||||
},
|
||||
[cancelDownload, downloadFile, fileTransferMode, getBaseFileData]
|
||||
)
|
||||
|
||||
const clearOutdatedFiles = useCallback(
|
||||
async (ignoredFiles: string[] = []) => {
|
||||
const queries = queryClient.getQueryCache().findAll({
|
||||
predicate: (query) => {
|
||||
return (
|
||||
query.queryKey[0] === "filePreview" &&
|
||||
![entityId, ...ignoredFiles].includes(query.queryKey[1] as string)
|
||||
)
|
||||
},
|
||||
}) as Query<QueryData>[]
|
||||
|
||||
await Promise.all(
|
||||
queries.map(async (query) => {
|
||||
const id = query.queryKey[1] as string
|
||||
const path = query.state.data?.nativeFilePath
|
||||
if (path) {
|
||||
await removeFile({ path })
|
||||
}
|
||||
queryClient.removeQueries({
|
||||
queryKey: ["filePreview", id],
|
||||
})
|
||||
})
|
||||
)
|
||||
},
|
||||
[entityId, queryClient, removeFile]
|
||||
)
|
||||
|
||||
const preloadSurroundingEntities = useCallback(async () => {
|
||||
const nextId = getNextFileId()
|
||||
const prevId = getPrevFileId()
|
||||
|
||||
await clearOutdatedFiles([nextId, prevId])
|
||||
|
||||
await queryClient.prefetchQuery({
|
||||
queryKey: ["filePreview", nextId],
|
||||
queryFn: ({ signal }) => queryFn(nextId, signal),
|
||||
networkMode: "offlineFirst",
|
||||
retry: false,
|
||||
staleTime: STALE_TIME,
|
||||
gcTime: GC_TIME,
|
||||
})
|
||||
await queryClient.prefetchQuery({
|
||||
queryKey: ["filePreview", prevId],
|
||||
queryFn: ({ signal }) => queryFn(prevId, signal),
|
||||
networkMode: "offlineFirst",
|
||||
retry: false,
|
||||
staleTime: STALE_TIME,
|
||||
gcTime: GC_TIME,
|
||||
})
|
||||
}, [clearOutdatedFiles, getNextFileId, getPrevFileId, queryClient, queryFn])
|
||||
|
||||
const cancel = useCallback(async () => {
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: ["filePreview", entityId],
|
||||
})
|
||||
}, [entityId, queryClient])
|
||||
|
||||
const query = useQuery<QueryData, FilePreviewError>({
|
||||
queryKey,
|
||||
queryFn: ({ signal }) => queryFn(entityId, signal),
|
||||
enabled: queryEnabled,
|
||||
retry: (failureCount, error) => {
|
||||
// Retry only for errors other than unsupported file type
|
||||
return error.type === FilePreviewErrorType.UnsupportedFileType
|
||||
? false
|
||||
: failureCount < 1
|
||||
},
|
||||
staleTime: STALE_TIME,
|
||||
gcTime: GC_TIME,
|
||||
retryOnMount: false,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
networkMode: "offlineFirst",
|
||||
})
|
||||
|
||||
const refetch = useCallback(async () => {
|
||||
dispatch(setFilesTransferMode(FilesTransferMode.Mtp))
|
||||
await query.refetch()
|
||||
}, [dispatch, query])
|
||||
|
||||
useEffect(() => {
|
||||
if (fileTransferMode !== FilesTransferMode.Mtp && entityId !== undefined) {
|
||||
void cancelDownload(entityId)
|
||||
}
|
||||
}, [fileTransferMode, cancelDownload, entityId])
|
||||
|
||||
useEffect(() => {
|
||||
if (query.isSuccess) {
|
||||
void preloadSurroundingEntities()
|
||||
}
|
||||
}, [preloadSurroundingEntities, query.isSuccess])
|
||||
|
||||
useEffect(() => {
|
||||
if (queryEnabled) {
|
||||
void ensureTempDirectory()
|
||||
} else {
|
||||
queryClient.removeQueries({
|
||||
queryKey: ["filePreview"],
|
||||
})
|
||||
void clearTempDirectory()
|
||||
|
||||
eventEmitterRef.current.emit(QueueEvent.Free)
|
||||
queueBusyRef.current = false
|
||||
}
|
||||
}, [clearTempDirectory, ensureTempDirectory, queryClient, queryEnabled])
|
||||
|
||||
return {
|
||||
...query,
|
||||
...fileBaseData,
|
||||
refetch,
|
||||
getNextFileId,
|
||||
getPrevFileId,
|
||||
cancel,
|
||||
}
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) Mudita sp. z o.o. All rights reserved.
|
||||
* For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import { useDispatch } from "react-redux"
|
||||
import { sendFilesClear } from "generic-view/store"
|
||||
import { AppDispatch } from "Core/__deprecated__/renderer/store"
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { checkPath, getAppPath, removeDirectory } from "system-utils/feature"
|
||||
import { uniqBy } from "lodash"
|
||||
import {
|
||||
FilePreviewResponse,
|
||||
useFilePreviewDownload,
|
||||
UseFilePreviewDownloadParams,
|
||||
} from "./use-file-preview-download"
|
||||
import { Queue } from "shared/utils"
|
||||
import EventEmitter from "events"
|
||||
|
||||
export enum QueuePriority {
|
||||
Current = 2,
|
||||
Next = 1,
|
||||
Prev = 0,
|
||||
}
|
||||
|
||||
export interface UseFilesPreviewParams {
|
||||
items: string[]
|
||||
activeItem?: string
|
||||
entitiesConfig: UseFilePreviewDownloadParams["fields"] & {
|
||||
type: string
|
||||
}
|
||||
}
|
||||
|
||||
export const useFilesPreview = ({
|
||||
items,
|
||||
activeItem,
|
||||
entitiesConfig,
|
||||
}: UseFilesPreviewParams) => {
|
||||
const actionId = entitiesConfig.type + "Preview"
|
||||
const dispatch = useDispatch<AppDispatch>()
|
||||
const eventEmitterRef = useRef(new EventEmitter())
|
||||
|
||||
const [refetchTrigger, setRefetchTrigger] = useState(0)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [tempDirectoryPath, setTempDirectoryPath] = useState<string>()
|
||||
const [downloadedItems, setDownloadedItems] = useState<FilePreviewResponse[]>(
|
||||
[]
|
||||
)
|
||||
const downloadedItemsDependency = JSON.stringify(
|
||||
downloadedItems.map((item) => item.id)
|
||||
)
|
||||
|
||||
const { downloadFile, cancelDownload } = useFilePreviewDownload({
|
||||
actionId,
|
||||
tempDirectoryPath,
|
||||
entitiesType: entitiesConfig.type,
|
||||
fields: entitiesConfig,
|
||||
})
|
||||
|
||||
const queue = useMemo(() => {
|
||||
return new Queue({ interval: 100, capacity: 3 })
|
||||
}, [])
|
||||
|
||||
const getNearestFiles = useCallback(
|
||||
(item?: string) => {
|
||||
if (!item || !items.length) {
|
||||
return { previous: undefined, next: undefined }
|
||||
}
|
||||
const currentIndex = items.indexOf(item)
|
||||
return {
|
||||
previous: items[(currentIndex - 1 + items.length) % items.length],
|
||||
next: items[(currentIndex + 1) % items.length],
|
||||
}
|
||||
},
|
||||
[items]
|
||||
)
|
||||
|
||||
const nearestFiles = useMemo(() => {
|
||||
return getNearestFiles(activeItem)
|
||||
}, [activeItem, getNearestFiles])
|
||||
|
||||
const currentFile = useMemo(() => {
|
||||
if (!activeItem) return undefined
|
||||
return downloadedItems.find((item) => item.id === activeItem)
|
||||
// eslint-disable-next-line
|
||||
}, [activeItem, downloadedItemsDependency, refetchTrigger])
|
||||
|
||||
const getFilePriority = useCallback(
|
||||
(fileId: string) => {
|
||||
if (fileId === activeItem) {
|
||||
return QueuePriority.Current
|
||||
}
|
||||
const nearest = getNearestFiles(activeItem)
|
||||
if (fileId === nearest.next) {
|
||||
return QueuePriority.Next
|
||||
}
|
||||
return QueuePriority.Prev
|
||||
},
|
||||
[activeItem, getNearestFiles]
|
||||
)
|
||||
|
||||
const downloadFiles = useCallback(
|
||||
(filesIds: string[]) => {
|
||||
for (const fileId of filesIds) {
|
||||
void queue.add({
|
||||
id: fileId,
|
||||
task: async () => {
|
||||
const fileInfo = await downloadFile(fileId)
|
||||
if (fileInfo) {
|
||||
setDownloadedItems((prev) => uniqBy([...prev, fileInfo], "id"))
|
||||
if (fileInfo.id === activeItem) {
|
||||
setIsLoading(false)
|
||||
eventEmitterRef.current.emit("currentFileDownloaded", fileId)
|
||||
}
|
||||
}
|
||||
},
|
||||
priority: getFilePriority(fileId),
|
||||
})
|
||||
}
|
||||
},
|
||||
[activeItem, downloadFile, getFilePriority, queue]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentFile?.id) return
|
||||
|
||||
const filesToRemove = downloadedItems.filter((item) => {
|
||||
return (
|
||||
item.id !== currentFile.id &&
|
||||
item.id !== nearestFiles.next &&
|
||||
item.id !== nearestFiles.previous
|
||||
)
|
||||
})
|
||||
|
||||
if (filesToRemove.length > 0) {
|
||||
for (const file of filesToRemove) {
|
||||
void cancelDownload(file.id)
|
||||
setDownloadedItems((prev) => prev.filter((item) => item.id !== file.id))
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line
|
||||
}, [
|
||||
currentFile?.id,
|
||||
cancelDownload,
|
||||
downloadedItemsDependency,
|
||||
nearestFiles.next,
|
||||
nearestFiles.previous,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeItem) return
|
||||
|
||||
const filesToDownload = [
|
||||
activeItem,
|
||||
nearestFiles.next,
|
||||
nearestFiles.previous,
|
||||
].filter(Boolean) as string[]
|
||||
|
||||
downloadFiles(filesToDownload)
|
||||
}, [
|
||||
activeItem,
|
||||
nearestFiles.next,
|
||||
nearestFiles.previous,
|
||||
downloadFiles,
|
||||
refetchTrigger,
|
||||
])
|
||||
|
||||
const ensureTempDirectory = useCallback(async () => {
|
||||
const destinationPath = await getAppPath("filePreview")
|
||||
if (destinationPath.ok) {
|
||||
await checkPath(destinationPath.data, true)
|
||||
setTempDirectoryPath(destinationPath.data)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const clearTempDirectory = useCallback(async () => {
|
||||
dispatch(sendFilesClear({ groupId: actionId }))
|
||||
if (tempDirectoryPath) {
|
||||
await removeDirectory(tempDirectoryPath)
|
||||
}
|
||||
}, [actionId, dispatch, tempDirectoryPath])
|
||||
|
||||
const refetch = useCallback(() => {
|
||||
return new Promise<void>((resolve) => {
|
||||
setIsLoading(true)
|
||||
setRefetchTrigger((prev) => prev + 1)
|
||||
eventEmitterRef.current.on("currentFileDownloaded", (fileId: string) => {
|
||||
if (fileId === activeItem) {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}, [activeItem])
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeItem) {
|
||||
void clearTempDirectory()
|
||||
setDownloadedItems([])
|
||||
}
|
||||
}, [activeItem, clearTempDirectory])
|
||||
|
||||
useEffect(() => {
|
||||
const emitter = eventEmitterRef.current
|
||||
void ensureTempDirectory()
|
||||
return () => {
|
||||
queue.clear()
|
||||
emitter.removeAllListeners()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
return {
|
||||
data: currentFile,
|
||||
nextId: nearestFiles.next,
|
||||
previousId: nearestFiles.previous,
|
||||
refetch,
|
||||
isLoading,
|
||||
}
|
||||
}
|
||||
|
||||
// Comment for rebuild trigger
|
||||
@@ -48,6 +48,8 @@ export const Table: APIFC<TableData, TableConfig> & {
|
||||
-1, -1,
|
||||
])
|
||||
|
||||
const [previewOpened, setPreviewOpened] = useState(false)
|
||||
const [initialPreviewId, setInitialPreviewId] = useState<string>()
|
||||
const [activePreviewId, setActivePreviewId] = useState<string>()
|
||||
const nextActivePreviewId = useRef<string | undefined>(undefined)
|
||||
const { activeIdFieldName } = formOptions || {}
|
||||
@@ -63,7 +65,9 @@ export const Table: APIFC<TableData, TableConfig> & {
|
||||
const onRowClick = useCallback(
|
||||
(id: string) => {
|
||||
if (previewOptions?.enabled) {
|
||||
handleActivePreviewIdChange(id)
|
||||
// handleActivePreviewIdChange(id)
|
||||
setInitialPreviewId(id)
|
||||
setPreviewOpened(true)
|
||||
return
|
||||
}
|
||||
if (activeIdFieldName) {
|
||||
@@ -108,15 +112,14 @@ export const Table: APIFC<TableData, TableConfig> & {
|
||||
}
|
||||
}, [activePreviewId])
|
||||
|
||||
const handleActivePreviewIdChange = useCallback(
|
||||
(id?: string) => {
|
||||
setActivePreviewId(id)
|
||||
if (id) {
|
||||
nextActivePreviewId.current = data[(data.indexOf(id) + 1) % data.length]
|
||||
}
|
||||
},
|
||||
[data]
|
||||
)
|
||||
const handleActivePreviewIdChange = useCallback((id?: string) => {
|
||||
setActivePreviewId(id)
|
||||
}, [])
|
||||
|
||||
const handlePreviewClose = useCallback(() => {
|
||||
setPreviewOpened(false)
|
||||
setInitialPreviewId(undefined)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (activePreviewId !== undefined) {
|
||||
@@ -137,12 +140,6 @@ export const Table: APIFC<TableData, TableConfig> & {
|
||||
scrollToPreviewActiveItem,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (activePreviewId && !data.includes(activePreviewId)) {
|
||||
handleActivePreviewIdChange(nextActivePreviewId.current)
|
||||
}
|
||||
}, [activePreviewId, data, handleActivePreviewIdChange])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeRowId) {
|
||||
scrollToActiveItem()
|
||||
@@ -286,8 +283,10 @@ export const Table: APIFC<TableData, TableConfig> & {
|
||||
return (
|
||||
<FilePreview
|
||||
items={data}
|
||||
activeItem={activePreviewId}
|
||||
initialItem={initialPreviewId}
|
||||
opened={previewOpened}
|
||||
onActiveItemChange={handleActivePreviewIdChange}
|
||||
onClose={handlePreviewClose}
|
||||
componentKey={previewOptions.componentKey}
|
||||
entitiesConfig={{
|
||||
type: previewOptions.entitiesType,
|
||||
@@ -299,7 +298,14 @@ export const Table: APIFC<TableData, TableConfig> & {
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}, [activePreviewId, data, handleActivePreviewIdChange, previewOptions])
|
||||
}, [
|
||||
data,
|
||||
handleActivePreviewIdChange,
|
||||
handlePreviewClose,
|
||||
initialPreviewId,
|
||||
previewOpened,
|
||||
previewOptions,
|
||||
])
|
||||
|
||||
return useMemo(
|
||||
() => (
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from "react"
|
||||
|
||||
type DefaultProps = Partial<
|
||||
Pick<ReactHTMLElement<HTMLElement>["props"], "className" | "style">
|
||||
Pick<ReactHTMLElement<HTMLElement>["props"], "className" | "style" | "id">
|
||||
> & {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
componentRef?: Ref<any>
|
||||
|
||||
51
package-lock.json
generated
51
package-lock.json
generated
@@ -9,6 +9,8 @@
|
||||
"dependencies": {
|
||||
"@contentful/rich-text-plain-text-renderer": "^16.2.6",
|
||||
"@orama/orama": "^2.0.22",
|
||||
"@tanstack/react-query": "^5.85.5",
|
||||
"@tanstack/react-query-devtools": "^5.85.5",
|
||||
"@types/w3c-web-usb": "^1.0.10",
|
||||
"@vscode/sudo-prompt": "^9.3.1",
|
||||
"@wdio/json-reporter": "^8.40.3",
|
||||
@@ -9422,6 +9424,55 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.85.5",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.85.5.tgz",
|
||||
"integrity": "sha512-KO0WTob4JEApv69iYp1eGvfMSUkgw//IpMnq+//cORBzXf0smyRwPLrUvEe5qtAEGjwZTXrjxg+oJNP/C00t6w==",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-devtools": {
|
||||
"version": "5.84.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.84.0.tgz",
|
||||
"integrity": "sha512-fbF3n+z1rqhvd9EoGp5knHkv3p5B2Zml1yNRjh7sNXklngYI5RVIWUrUjZ1RIcEoscarUb0+bOvIs5x9dwzOXQ==",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-query": {
|
||||
"version": "5.85.5",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.85.5.tgz",
|
||||
"integrity": "sha512-/X4EFNcnPiSs8wM2v+b6DqS5mmGeuJQvxBglmDxl6ZQb5V26ouD2SJYAcC3VjbNwqhY2zjxVD15rDA5nGbMn3A==",
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.85.5"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-query-devtools": {
|
||||
"version": "5.85.5",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.85.5.tgz",
|
||||
"integrity": "sha512-6Ol6Q+LxrCZlQR4NoI5181r+ptTwnlPG2t7H9Sp3klxTBhYGunONqcgBn2YKRPsaKiYM8pItpKMdMXMEINntMQ==",
|
||||
"dependencies": {
|
||||
"@tanstack/query-devtools": "5.84.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tanstack/react-query": "^5.85.5",
|
||||
"react": "^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/@testim/chrome-version": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@testim/chrome-version/-/chrome-version-1.1.4.tgz",
|
||||
|
||||
@@ -283,6 +283,8 @@
|
||||
"dependencies": {
|
||||
"@contentful/rich-text-plain-text-renderer": "^16.2.6",
|
||||
"@orama/orama": "^2.0.22",
|
||||
"@tanstack/react-query": "^5.85.5",
|
||||
"@tanstack/react-query-devtools": "^5.85.5",
|
||||
"@types/w3c-web-usb": "^1.0.10",
|
||||
"@vscode/sudo-prompt": "^9.3.1",
|
||||
"@wdio/json-reporter": "^8.40.3",
|
||||
|
||||
Reference in New Issue
Block a user