refactor: repo (#1070)
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
import { FileItem } from '@standardnotes/snjs'
|
||||
import { MutableRefObject } from 'react'
|
||||
|
||||
export const createObjectURLWithRef = (
|
||||
type: FileItem['mimeType'],
|
||||
bytes: Uint8Array,
|
||||
ref: MutableRefObject<string | undefined>,
|
||||
) => {
|
||||
const objectURL = URL.createObjectURL(
|
||||
new Blob([bytes], {
|
||||
type,
|
||||
}),
|
||||
)
|
||||
|
||||
ref.current = objectURL
|
||||
|
||||
return objectURL
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import { concatenateUint8Arrays } from '@/Utils'
|
||||
import { FileItem } from '@standardnotes/snjs'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import FilePreviewError from './FilePreviewError'
|
||||
import { isFileTypePreviewable } from './isFilePreviewable'
|
||||
import PreviewComponent from './PreviewComponent'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
file: FileItem
|
||||
}
|
||||
|
||||
const FilePreview = ({ file, application }: Props) => {
|
||||
const isFilePreviewable = useMemo(() => {
|
||||
return isFileTypePreviewable(file.mimeType)
|
||||
}, [file.mimeType])
|
||||
|
||||
const [isDownloading, setIsDownloading] = useState(true)
|
||||
const [downloadProgress, setDownloadProgress] = useState(0)
|
||||
const [downloadedBytes, setDownloadedBytes] = useState<Uint8Array>()
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFilePreviewable) {
|
||||
setIsDownloading(false)
|
||||
setDownloadProgress(0)
|
||||
setDownloadedBytes(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
const downloadFileForPreview = async () => {
|
||||
if (downloadedBytes) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsDownloading(true)
|
||||
|
||||
try {
|
||||
const chunks: Uint8Array[] = []
|
||||
setDownloadProgress(0)
|
||||
await application.files.downloadFile(file, async (decryptedChunk, progress) => {
|
||||
chunks.push(decryptedChunk)
|
||||
if (progress) {
|
||||
setDownloadProgress(Math.round(progress.percentComplete))
|
||||
}
|
||||
})
|
||||
const finalDecryptedBytes = concatenateUint8Arrays(chunks)
|
||||
setDownloadedBytes(finalDecryptedBytes)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
setIsDownloading(false)
|
||||
}
|
||||
}
|
||||
|
||||
void downloadFileForPreview()
|
||||
}, [application.files, downloadedBytes, file, isFilePreviewable])
|
||||
|
||||
return isDownloading ? (
|
||||
<div className="flex flex-col justify-center items-center flex-grow">
|
||||
<div className="flex items-center">
|
||||
<div className="sk-spinner w-5 h-5 spinner-info mr-3"></div>
|
||||
<div className="text-base font-semibold">{downloadProgress}%</div>
|
||||
</div>
|
||||
<span className="mt-3">Loading file...</span>
|
||||
</div>
|
||||
) : downloadedBytes ? (
|
||||
<PreviewComponent file={file} bytes={downloadedBytes} />
|
||||
) : (
|
||||
<FilePreviewError
|
||||
file={file}
|
||||
filesController={application.getViewControllerManager().filesController}
|
||||
tryAgainCallback={() => {
|
||||
setDownloadedBytes(undefined)
|
||||
}}
|
||||
isFilePreviewable={isFilePreviewable}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default FilePreview
|
||||
@@ -0,0 +1,62 @@
|
||||
import { FilesController } from '@/Controllers/FilesController'
|
||||
import { NoPreviewIllustration } from '@standardnotes/icons'
|
||||
import { FileItem } from '@standardnotes/snjs'
|
||||
import Button from '../Button/Button'
|
||||
|
||||
type Props = {
|
||||
file: FileItem
|
||||
filesController: FilesController
|
||||
isFilePreviewable: boolean
|
||||
tryAgainCallback: () => void
|
||||
}
|
||||
|
||||
const FilePreviewError = ({ file, filesController, isFilePreviewable, tryAgainCallback }: Props) => {
|
||||
return (
|
||||
<div className="flex flex-col justify-center items-center flex-grow">
|
||||
<NoPreviewIllustration className="w-30 h-30 mb-4" />
|
||||
<div className="font-bold text-base mb-2">This file can't be previewed.</div>
|
||||
{isFilePreviewable ? (
|
||||
<>
|
||||
<div className="text-sm text-center color-passive-0 mb-4 max-w-35ch">
|
||||
There was an error loading the file. Try again, or download the file and open it using another application.
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
variant="primary"
|
||||
className="mr-3"
|
||||
onClick={() => {
|
||||
tryAgainCallback()
|
||||
}}
|
||||
>
|
||||
Try again
|
||||
</Button>
|
||||
<Button
|
||||
variant="normal"
|
||||
onClick={() => {
|
||||
filesController.downloadFile(file).catch(console.error)
|
||||
}}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-sm text-center color-passive-0 mb-4 max-w-35ch">
|
||||
To view this file, download it and open it using another application.
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
filesController.downloadFile(file).catch(console.error)
|
||||
}}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FilePreviewError
|
||||
@@ -0,0 +1,39 @@
|
||||
import { formatSizeToReadableString } from '@standardnotes/filepicker'
|
||||
import { FileItem } from '@standardnotes/snjs'
|
||||
import { FunctionComponent } from 'react'
|
||||
import Icon from '@/Components/Icon/Icon'
|
||||
|
||||
type Props = {
|
||||
file: FileItem
|
||||
}
|
||||
|
||||
const FilePreviewInfoPanel: FunctionComponent<Props> = ({ file }) => {
|
||||
return (
|
||||
<div className="flex flex-col min-w-70 p-4 border-0 border-l-1px border-solid border-main">
|
||||
<div className="flex items-center mb-4">
|
||||
<Icon type="info" className="mr-2" />
|
||||
<div className="font-semibold">File information</div>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<span className="font-semibold">Type:</span> {file.mimeType}
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<span className="font-semibold">Decrypted Size:</span> {formatSizeToReadableString(file.decryptedSize)}
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<span className="font-semibold">Encrypted Size:</span> {formatSizeToReadableString(file.encryptedSize)}
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<span className="font-semibold">Created:</span> {file.created_at.toLocaleString()}
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<span className="font-semibold">Last Modified:</span> {file.userModifiedDate.toLocaleString()}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">File ID:</span> {file.uuid}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FilePreviewInfoPanel
|
||||
@@ -0,0 +1,134 @@
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import { DialogContent, DialogOverlay } from '@reach/dialog'
|
||||
import { FunctionComponent, KeyboardEventHandler, useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { getFileIconComponent } from '@/Components/AttachedFilesPopover/getFileIconComponent'
|
||||
import Icon from '@/Components/Icon/Icon'
|
||||
import FilePreviewInfoPanel from './FilePreviewInfoPanel'
|
||||
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants'
|
||||
import { KeyboardKey } from '@/Services/IOService'
|
||||
import { ViewControllerManager } from '@/Services/ViewControllerManager'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import FilePreview from './FilePreview'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
viewControllerManager: ViewControllerManager
|
||||
}
|
||||
|
||||
const FilePreviewModal: FunctionComponent<Props> = observer(({ application, viewControllerManager }) => {
|
||||
const { currentFile, setCurrentFile, otherFiles, dismiss } = viewControllerManager.filePreviewModalController
|
||||
|
||||
if (!currentFile) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [showFileInfoPanel, setShowFileInfoPanel] = useState(false)
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const keyDownHandler: KeyboardEventHandler = useCallback(
|
||||
(event) => {
|
||||
const hasNotPressedLeftOrRightKeys = event.key !== KeyboardKey.Left && event.key !== KeyboardKey.Right
|
||||
|
||||
if (hasNotPressedLeftOrRightKeys) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
const currentFileIndex = otherFiles.findIndex((fileFromArray) => fileFromArray.uuid === currentFile.uuid)
|
||||
|
||||
switch (event.key) {
|
||||
case KeyboardKey.Left: {
|
||||
const previousFileIndex = currentFileIndex - 1 >= 0 ? currentFileIndex - 1 : otherFiles.length - 1
|
||||
const previousFile = otherFiles[previousFileIndex]
|
||||
if (previousFile) {
|
||||
setCurrentFile(previousFile)
|
||||
}
|
||||
break
|
||||
}
|
||||
case KeyboardKey.Right: {
|
||||
const nextFileIndex = currentFileIndex + 1 < otherFiles.length ? currentFileIndex + 1 : 0
|
||||
const nextFile = otherFiles[nextFileIndex]
|
||||
if (nextFile) {
|
||||
setCurrentFile(nextFile)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
},
|
||||
[currentFile.uuid, otherFiles, setCurrentFile],
|
||||
)
|
||||
|
||||
const IconComponent = useMemo(
|
||||
() =>
|
||||
getFileIconComponent(
|
||||
application.iconsController.getIconForFileType(currentFile.mimeType),
|
||||
'w-6 h-6 flex-shrink-0',
|
||||
),
|
||||
[application.iconsController, currentFile.mimeType],
|
||||
)
|
||||
|
||||
return (
|
||||
<DialogOverlay
|
||||
className="sn-component"
|
||||
aria-label="File preview modal"
|
||||
onDismiss={dismiss}
|
||||
initialFocusRef={closeButtonRef}
|
||||
dangerouslyBypassScrollLock
|
||||
>
|
||||
<DialogContent
|
||||
aria-label="File preview modal"
|
||||
className="flex flex-col rounded shadow-overlay"
|
||||
style={{
|
||||
width: '90%',
|
||||
maxWidth: '90%',
|
||||
minHeight: '90%',
|
||||
background: 'var(--modal-background-color)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex flex-shrink-0 justify-between items-center min-h-6 px-4 py-3 border-0 border-b-1 border-solid border-main focus:shadow-none"
|
||||
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
|
||||
onKeyDown={keyDownHandler}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className="w-6 h-6">{IconComponent}</div>
|
||||
<span className="ml-3 font-medium">{currentFile.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
className="flex p-1.5 mr-4 bg-transparent hover:bg-contrast border-solid border-main border-1 cursor-pointer rounded"
|
||||
onClick={() => setShowFileInfoPanel((show) => !show)}
|
||||
>
|
||||
<Icon type="info" className="color-neutral" />
|
||||
</button>
|
||||
<button
|
||||
ref={closeButtonRef}
|
||||
onClick={dismiss}
|
||||
aria-label="Close modal"
|
||||
className="flex p-1 bg-transparent hover:bg-contrast border-0 cursor-pointer rounded"
|
||||
>
|
||||
<Icon type="close" className="color-neutral" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-grow min-h-0">
|
||||
<div className="flex flex-grow items-center justify-center relative max-w-full">
|
||||
<FilePreview file={currentFile} application={application} key={currentFile.uuid} />
|
||||
</div>
|
||||
{showFileInfoPanel && <FilePreviewInfoPanel file={currentFile} />}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogOverlay>
|
||||
)
|
||||
})
|
||||
|
||||
FilePreviewModal.displayName = 'FilePreviewModal'
|
||||
|
||||
const FilePreviewModalWrapper: FunctionComponent<Props> = ({ application, viewControllerManager }) => {
|
||||
return viewControllerManager.filePreviewModalController.isOpen ? (
|
||||
<FilePreviewModal application={application} viewControllerManager={viewControllerManager} />
|
||||
) : null
|
||||
}
|
||||
|
||||
export default observer(FilePreviewModalWrapper)
|
||||
@@ -0,0 +1,73 @@
|
||||
import { IconType } from '@standardnotes/snjs'
|
||||
import { FunctionComponent, useRef, useState } from 'react'
|
||||
import IconButton from '../Button/IconButton'
|
||||
|
||||
type Props = {
|
||||
objectUrl: string
|
||||
}
|
||||
|
||||
const ImagePreview: FunctionComponent<Props> = ({ objectUrl }) => {
|
||||
const initialImgHeightRef = useRef<number>()
|
||||
|
||||
const [imageZoomPercent, setImageZoomPercent] = useState(100)
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full min-h-0">
|
||||
<div className="flex items-center justify-center w-full h-full relative overflow-auto">
|
||||
<img
|
||||
src={objectUrl}
|
||||
style={{
|
||||
height: `${imageZoomPercent}%`,
|
||||
...(imageZoomPercent <= 100
|
||||
? {
|
||||
minWidth: '100%',
|
||||
objectFit: 'contain',
|
||||
}
|
||||
: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
margin: 'auto',
|
||||
}),
|
||||
}}
|
||||
ref={(imgElement) => {
|
||||
if (!initialImgHeightRef.current) {
|
||||
initialImgHeightRef.current = imgElement?.height
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center absolute left-1/2 -translate-x-1/2 bottom-6 py-1 px-3 bg-default border-1 border-solid border-main rounded">
|
||||
<span className="mr-1.5">Zoom:</span>
|
||||
<IconButton
|
||||
className="hover:bg-contrast p-1 rounded"
|
||||
icon={'subtract' as IconType}
|
||||
title="Zoom Out"
|
||||
focusable={true}
|
||||
onClick={() => {
|
||||
setImageZoomPercent((percent) => {
|
||||
const newPercent = percent - 10
|
||||
if (newPercent >= 10) {
|
||||
return newPercent
|
||||
} else {
|
||||
return percent
|
||||
}
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<span className="mx-2">{imageZoomPercent}%</span>
|
||||
<IconButton
|
||||
className="hover:bg-contrast p-1 rounded"
|
||||
icon="add"
|
||||
title="Zoom In"
|
||||
focusable={true}
|
||||
onClick={() => {
|
||||
setImageZoomPercent((percent) => percent + 10)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ImagePreview
|
||||
@@ -0,0 +1,54 @@
|
||||
import { FileItem } from '@standardnotes/snjs'
|
||||
import { FunctionComponent, useEffect, useMemo, useRef } from 'react'
|
||||
import { createObjectURLWithRef } from './CreateObjectURLWithRef'
|
||||
import ImagePreview from './ImagePreview'
|
||||
import { PreviewableTextFileTypes } from './isFilePreviewable'
|
||||
import TextPreview from './TextPreview'
|
||||
|
||||
type Props = {
|
||||
file: FileItem
|
||||
bytes: Uint8Array
|
||||
}
|
||||
|
||||
const PreviewComponent: FunctionComponent<Props> = ({ file, bytes }) => {
|
||||
const objectUrlRef = useRef<string>()
|
||||
|
||||
const objectUrl = useMemo(() => {
|
||||
return createObjectURLWithRef(file.mimeType, bytes, objectUrlRef)
|
||||
}, [bytes, file.mimeType])
|
||||
|
||||
useEffect(() => {
|
||||
const objectUrl = objectUrlRef.current
|
||||
|
||||
return () => {
|
||||
if (objectUrl) {
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
objectUrlRef.current = ''
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (file.mimeType.startsWith('image/')) {
|
||||
return <ImagePreview objectUrl={objectUrl} />
|
||||
}
|
||||
|
||||
if (file.mimeType.startsWith('video/')) {
|
||||
return <video className="w-full h-full" src={objectUrl} controls autoPlay />
|
||||
}
|
||||
|
||||
if (file.mimeType.startsWith('audio/')) {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<audio src={objectUrl} controls />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (PreviewableTextFileTypes.includes(file.mimeType)) {
|
||||
return <TextPreview bytes={bytes} />
|
||||
}
|
||||
|
||||
return <object className="w-full h-full" data={objectUrl} />
|
||||
}
|
||||
|
||||
export default PreviewComponent
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ElementIds } from '@/Constants/ElementIDs'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
type Props = {
|
||||
bytes: Uint8Array
|
||||
}
|
||||
|
||||
const TextPreview = ({ bytes }: Props) => {
|
||||
const text = useMemo(() => {
|
||||
const textDecoder = new TextDecoder()
|
||||
return textDecoder.decode(bytes)
|
||||
}, [bytes])
|
||||
|
||||
return (
|
||||
<textarea
|
||||
autoComplete="off"
|
||||
className="w-full h-full flex-grow font-editor focus:shadow-none focus:outline-none"
|
||||
dir="auto"
|
||||
id={ElementIds.FileTextPreview}
|
||||
defaultValue={text}
|
||||
readOnly={true}
|
||||
></textarea>
|
||||
)
|
||||
}
|
||||
|
||||
export default TextPreview
|
||||
@@ -0,0 +1,15 @@
|
||||
export const PreviewableTextFileTypes = ['text/plain', 'application/json']
|
||||
|
||||
export const isFileTypePreviewable = (fileType: string) => {
|
||||
const isImage = fileType.startsWith('image/')
|
||||
const isVideo = fileType.startsWith('video/')
|
||||
const isAudio = fileType.startsWith('audio/')
|
||||
const isPdf = fileType === 'application/pdf'
|
||||
const isText = PreviewableTextFileTypes.includes(fileType)
|
||||
|
||||
if (isImage || isVideo || isAudio || isText || isPdf) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user