feat: add file view (#1064)

This commit is contained in:
Aman Harwara
2022-06-06 21:30:51 +05:30
committed by GitHub
parent c20f0ad78b
commit 92024ec7ca
33 changed files with 661 additions and 382 deletions

View File

@@ -19,7 +19,7 @@ import PremiumModalProvider from '@/Hooks/usePremiumModal'
import ConfirmSignoutContainer from '@/Components/ConfirmSignoutModal/ConfirmSignoutModal'
import TagsContextMenuWrapper from '@/Components/Tags/TagContextMenu'
import { ToastContainer } from '@standardnotes/stylekit'
import FilePreviewModalWrapper from '@/Components/Files/FilePreviewModal'
import FilePreviewModalWrapper from '@/Components/FilePreview/FilePreviewModal'
import ContentListView from '@/Components/ContentListView/ContentListView'
import FileContextMenuWrapper from '@/Components/FileContextMenu/FileContextMenu'
import PermissionsModalWrapper from '@/Components/PermissionsModal/PermissionsModalWrapper'

View File

@@ -1,5 +1,4 @@
import { WebApplication } from '@/Application/Application'
import { ViewControllerManager } from '@/Services/ViewControllerManager'
import { MENU_MARGIN_FROM_APP_BORDER } from '@/Constants/Constants'
import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure'
import VisuallyHidden from '@reach/visually-hidden'
@@ -14,20 +13,33 @@ import AttachedFilesPopover from './AttachedFilesPopover'
import { usePremiumModal } from '@/Hooks/usePremiumModal'
import { PopoverTabs } from './PopoverTabs'
import { isHandlingFileDrag } from '@/Utils/DragTypeCheck'
import { NotesController } from '@/Controllers/NotesController'
import { FilePreviewModalController } from '@/Controllers/FilePreviewModalController'
import { NavigationController } from '@/Controllers/Navigation/NavigationController'
import { FeaturesController } from '@/Controllers/FeaturesController'
import { FilesController } from '@/Controllers/FilesController'
type Props = {
application: WebApplication
viewControllerManager: ViewControllerManager
featuresController: FeaturesController
filePreviewModalController: FilePreviewModalController
filesController: FilesController
navigationController: NavigationController
notesController: NotesController
onClickPreprocessing?: () => Promise<void>
}
const AttachedFilesButton: FunctionComponent<Props> = ({
application,
viewControllerManager,
featuresController,
filesController,
filePreviewModalController,
navigationController,
notesController,
onClickPreprocessing,
}: Props) => {
const premiumModal = usePremiumModal()
const note: SNNote | undefined = viewControllerManager.notesController.firstSelectedNote
const note: SNNote | undefined = notesController.firstSelectedNote
const [open, setOpen] = useState(false)
const [position, setPosition] = useState({
@@ -41,14 +53,16 @@ const AttachedFilesButton: FunctionComponent<Props> = ({
const [closeOnBlur, keepMenuOpen] = useCloseOnBlur(containerRef, setOpen)
useEffect(() => {
if (viewControllerManager.filePreviewModalController.isOpen) {
if (filePreviewModalController.isOpen) {
keepMenuOpen(true)
} else {
keepMenuOpen(false)
}
}, [viewControllerManager.filePreviewModalController.isOpen, keepMenuOpen])
}, [filePreviewModalController.isOpen, keepMenuOpen])
const [currentTab, setCurrentTab] = useState(PopoverTabs.AttachedFiles)
const [currentTab, setCurrentTab] = useState(
navigationController.isInFilesView ? PopoverTabs.AllFiles : PopoverTabs.AttachedFiles,
)
const [allFiles, setAllFiles] = useState<FileItem[]>([])
const [attachedFiles, setAttachedFiles] = useState<FileItem[]>([])
const attachedFilesCount = attachedFiles.length
@@ -92,10 +106,10 @@ const AttachedFilesButton: FunctionComponent<Props> = ({
}, [onClickPreprocessing, open])
const prospectivelyShowFilesPremiumModal = useCallback(() => {
if (!viewControllerManager.featuresController.hasFiles) {
if (!featuresController.hasFiles) {
premiumModal.activate('Files')
}
}, [viewControllerManager.featuresController.hasFiles, premiumModal])
}, [featuresController.hasFiles, premiumModal])
const toggleAttachedFilesMenuWithEntitlementCheck = useCallback(async () => {
prospectivelyShowFilesPremiumModal()
@@ -192,7 +206,7 @@ const AttachedFilesButton: FunctionComponent<Props> = ({
setIsDraggingFiles(false)
if (!viewControllerManager.featuresController.hasFiles) {
if (!featuresController.hasFiles) {
prospectivelyShowFilesPremiumModal()
return
}
@@ -207,7 +221,7 @@ const AttachedFilesButton: FunctionComponent<Props> = ({
return
}
const uploadedFiles = await viewControllerManager.filesController.uploadNewFile(fileOrHandle)
const uploadedFiles = await filesController.uploadNewFile(fileOrHandle)
if (!uploadedFiles) {
return
@@ -225,8 +239,8 @@ const AttachedFilesButton: FunctionComponent<Props> = ({
}
},
[
viewControllerManager.filesController,
viewControllerManager.featuresController.hasFiles,
filesController,
featuresController.hasFiles,
attachFileToNote,
currentTab,
application,
@@ -283,13 +297,14 @@ const AttachedFilesButton: FunctionComponent<Props> = ({
{open && (
<AttachedFilesPopover
application={application}
filesController={viewControllerManager.filesController}
filesController={filesController}
attachedFiles={attachedFiles}
allFiles={allFiles}
closeOnBlur={closeOnBlur}
currentTab={currentTab}
isDraggingFiles={isDraggingFiles}
setCurrentTab={setCurrentTab}
attachedTabDisabled={navigationController.isInFilesView}
/>
)}
</DisclosurePanel>

View File

@@ -20,6 +20,7 @@ type Props = {
currentTab: PopoverTabs
isDraggingFiles: boolean
setCurrentTab: Dispatch<SetStateAction<PopoverTabs>>
attachedTabDisabled: boolean
}
const AttachedFilesPopover: FunctionComponent<Props> = ({
@@ -31,6 +32,7 @@ const AttachedFilesPopover: FunctionComponent<Props> = ({
currentTab,
isDraggingFiles,
setCurrentTab,
attachedTabDisabled,
}) => {
const [searchQuery, setSearchQuery] = useState('')
const searchInputRef = useRef<HTMLInputElement>(null)
@@ -81,11 +83,12 @@ const AttachedFilesPopover: FunctionComponent<Props> = ({
id={PopoverTabs.AttachedFiles}
className={`bg-default border-0 cursor-pointer px-3 py-2.5 relative focus:bg-info-backdrop focus:shadow-bottom ${
currentTab === PopoverTabs.AttachedFiles ? 'color-info font-medium shadow-bottom' : 'color-text'
}`}
} ${attachedTabDisabled ? 'color-neutral cursor-not-allowed' : ''}`}
onClick={() => {
setCurrentTab(PopoverTabs.AttachedFiles)
}}
onBlur={closeOnBlur}
disabled={attachedTabDisabled}
>
Attached
</button>

View File

@@ -64,7 +64,6 @@ const ContentList: FunctionComponent<Props> = ({
<ContentListItem
key={item.uuid}
application={application}
viewControllerManager={viewControllerManager}
item={item}
selected={!!selectedItems[item.uuid]}
hideDate={hideDate}
@@ -72,6 +71,10 @@ const ContentList: FunctionComponent<Props> = ({
hideTags={hideTags}
hideIcon={hideEditorIcon}
sortBy={sortBy}
filesController={viewControllerManager.filesController}
selectionController={viewControllerManager.selectionController}
navigationController={viewControllerManager.navigationController}
notesController={viewControllerManager.notesController}
/>
))}
</div>

View File

@@ -10,7 +10,7 @@ const ContentListItem: FunctionComponent<AbstractListItemProps> = (props) => {
return []
}
const selectedTag = props.viewControllerManager.navigationController.selected
const selectedTag = props.navigationController.selected
if (!selectedTag) {
return []
}

View File

@@ -10,7 +10,8 @@ import { DisplayableListItemProps } from './Types/DisplayableListItemProps'
const FileListItem: FunctionComponent<DisplayableListItemProps> = ({
application,
viewControllerManager,
filesController,
selectionController,
hideDate,
hideIcon,
hideTags,
@@ -21,40 +22,28 @@ const FileListItem: FunctionComponent<DisplayableListItemProps> = ({
}) => {
const openFileContextMenu = useCallback(
(posX: number, posY: number) => {
viewControllerManager.filesController.setFileContextMenuLocation({
filesController.setFileContextMenuLocation({
x: posX,
y: posY,
})
viewControllerManager.filesController.setShowFileContextMenu(true)
filesController.setShowFileContextMenu(true)
},
[viewControllerManager.filesController],
[filesController],
)
const openContextMenu = useCallback(
async (posX: number, posY: number) => {
const { didSelect } = await viewControllerManager.selectionController.selectItem(item.uuid)
const { didSelect } = await selectionController.selectItem(item.uuid)
if (didSelect) {
openFileContextMenu(posX, posY)
}
},
[viewControllerManager.selectionController, item.uuid, openFileContextMenu],
[selectionController, item.uuid, openFileContextMenu],
)
const onClick = useCallback(() => {
void viewControllerManager.selectionController.selectItem(item.uuid, true).then(({ didSelect }) => {
if (didSelect && viewControllerManager.selectionController.selectedItemsCount < 2) {
viewControllerManager.filePreviewModalController.activate(
item as FileItem,
viewControllerManager.filesController.allFiles,
)
}
})
}, [
viewControllerManager.filePreviewModalController,
viewControllerManager.filesController.allFiles,
viewControllerManager.selectionController,
item,
])
void selectionController.selectItem(item.uuid, true)
}, [item.uuid, selectionController])
const IconComponent = () =>
getFileIconComponent(

View File

@@ -11,7 +11,8 @@ import { DisplayableListItemProps } from './Types/DisplayableListItemProps'
const NoteListItem: FunctionComponent<DisplayableListItemProps> = ({
application,
viewControllerManager,
notesController,
selectionController,
hideDate,
hideIcon,
hideTags,
@@ -27,16 +28,16 @@ const NoteListItem: FunctionComponent<DisplayableListItemProps> = ({
const hasFiles = application.items.getFilesForNote(item as SNNote).length > 0
const openNoteContextMenu = (posX: number, posY: number) => {
viewControllerManager.notesController.setContextMenuClickLocation({
notesController.setContextMenuClickLocation({
x: posX,
y: posY,
})
viewControllerManager.notesController.reloadContextMenuLayout()
viewControllerManager.notesController.setContextMenuOpen(true)
notesController.reloadContextMenuLayout()
notesController.setContextMenuOpen(true)
}
const openContextMenu = async (posX: number, posY: number) => {
const { didSelect } = await viewControllerManager.selectionController.selectItem(item.uuid, true)
const { didSelect } = await selectionController.selectItem(item.uuid, true)
if (didSelect) {
openNoteContextMenu(posX, posY)
}
@@ -49,7 +50,7 @@ const NoteListItem: FunctionComponent<DisplayableListItemProps> = ({
}`}
id={item.uuid}
onClick={() => {
void viewControllerManager.selectionController.selectItem(item.uuid, true)
void selectionController.selectItem(item.uuid, true)
}}
onContextMenu={(event) => {
event.preventDefault()

View File

@@ -1,11 +1,17 @@
import { WebApplication } from '@/Application/Application'
import { ViewControllerManager } from '@/Services/ViewControllerManager'
import { FilesController } from '@/Controllers/FilesController'
import { NavigationController } from '@/Controllers/Navigation/NavigationController'
import { NotesController } from '@/Controllers/NotesController'
import { SelectedItemsController } from '@/Controllers/SelectedItemsController'
import { SortableItem } from '@standardnotes/snjs'
import { ListableContentItem } from './ListableContentItem'
export type AbstractListItemProps = {
application: WebApplication
viewControllerManager: ViewControllerManager
filesController: FilesController
selectionController: SelectedItemsController
navigationController: NavigationController
notesController: NotesController
hideDate: boolean
hideIcon: boolean
hideTags: boolean

View File

@@ -134,6 +134,13 @@ const FileMenuOptions: FunctionComponent<Props> = ({
<Icon type="trash" className="mr-2 color-danger" />
<span className="color-danger">Delete permanently</span>
</button>
{selectedFiles.length === 1 && (
<div className="px-3 pt-1.5 pb-0.5 text-xs color-neutral font-medium">
<div>
<span className="font-semibold">File ID:</span> {selectedFiles[0].uuid}
</div>
</div>
)}
</>
)
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -12,7 +12,7 @@ const ImagePreview: FunctionComponent<Props> = ({ objectUrl }) => {
const [imageZoomPercent, setImageZoomPercent] = useState(100)
return (
<div className="flex items-center justify-center w-full h-full">
<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}
@@ -26,6 +26,7 @@ const ImagePreview: FunctionComponent<Props> = ({ objectUrl }) => {
: {
position: 'absolute',
top: 0,
left: 0,
margin: 'auto',
}),
}}

View File

@@ -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

View File

@@ -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

View File

@@ -1,10 +1,13 @@
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 || isPdf) {
if (isImage || isVideo || isAudio || isText || isPdf) {
return true
}

View File

@@ -0,0 +1,36 @@
import { observer } from 'mobx-react-lite'
import { useCallback, useEffect, useState } from 'react'
import ProtectedItemOverlay from '@/Components/ProtectedItemOverlay/ProtectedItemOverlay'
import FileViewWithoutProtection from './FileViewWithoutProtection'
import { FileViewProps } from './FileViewProps'
const FileView = ({ application, viewControllerManager, file }: FileViewProps) => {
const [shouldShowProtectedOverlay, setShouldShowProtectedOverlay] = useState(
file.protected && !application.hasProtectionSources(),
)
useEffect(() => {
setShouldShowProtectedOverlay(viewControllerManager.filesController.showProtectedOverlay)
}, [viewControllerManager.filesController.showProtectedOverlay])
const dismissProtectedWarning = useCallback(() => {
void viewControllerManager.filesController.toggleFileProtection(file)
}, [file, viewControllerManager.filesController])
return shouldShowProtectedOverlay ? (
<div aria-label="Note" className="section editor sn-component">
<div className="h-full flex justify-center items-center">
<ProtectedItemOverlay
viewControllerManager={viewControllerManager}
hasProtectionSources={application.hasProtectionSources()}
onViewItem={dismissProtectedWarning}
itemType={'note'}
/>
</div>
</div>
) : (
<FileViewWithoutProtection application={application} viewControllerManager={viewControllerManager} file={file} />
)
}
export default observer(FileView)

View File

@@ -0,0 +1,9 @@
import { WebApplication } from '@/Application/Application'
import { ViewControllerManager } from '@/Services/ViewControllerManager'
import { FileItem } from '@standardnotes/snjs/dist/@types'
export type FileViewProps = {
application: WebApplication
viewControllerManager: ViewControllerManager
file: FileItem
}

View File

@@ -0,0 +1,75 @@
import { ElementIds } from '@/Constants/ElementIDs'
import { observer } from 'mobx-react-lite'
import { ChangeEventHandler, FormEventHandler, useCallback, useEffect, useState } from 'react'
import AttachedFilesButton from '@/Components/AttachedFilesPopover/AttachedFilesButton'
import FileOptionsPanel from '@/Components/FileContextMenu/FileOptionsPanel'
import FilePreview from '@/Components/FilePreview/FilePreview'
import { FileViewProps } from './FileViewProps'
const FileViewWithoutProtection = ({ application, viewControllerManager, file }: FileViewProps) => {
const [name, setName] = useState(file.name)
useEffect(() => {
setName(file.name)
}, [file.name])
const onTitleChange: ChangeEventHandler<HTMLInputElement> = useCallback(async (event) => {
setName(event.target.value)
}, [])
const onFormSubmit: FormEventHandler = useCallback(
async (event) => {
event.preventDefault()
await application.items.renameFile(file, name)
void application.sync.sync()
},
[application.items, application.sync, file, name],
)
return (
<div className="sn-component section editor" aria-label="File">
<div className="flex flex-col">
<div className="content-title-bar section-title-bar w-full" id="file-title-bar">
<div className="flex items-center justify-between h-8">
<div className="flex-grow">
<form onSubmit={onFormSubmit} className="title overflow-auto">
<input
className="input"
id={ElementIds.FileTitleEditor}
onChange={onTitleChange}
onFocus={(event) => {
event.target.select()
}}
spellCheck={false}
value={name}
autoComplete="off"
/>
</form>
</div>
<div className="flex items-center">
<div className="mr-3">
<AttachedFilesButton
application={application}
featuresController={viewControllerManager.featuresController}
filePreviewModalController={viewControllerManager.filePreviewModalController}
filesController={viewControllerManager.filesController}
navigationController={viewControllerManager.navigationController}
notesController={viewControllerManager.notesController}
/>
</div>
<FileOptionsPanel
filesController={viewControllerManager.filesController}
selectionController={viewControllerManager.selectionController}
/>
</div>
</div>
</div>
</div>
<FilePreview file={file} application={application} key={file.uuid} />
</div>
)
}
export default observer(FileViewWithoutProtection)

View File

@@ -1,270 +0,0 @@
import { WebApplication } from '@/Application/Application'
import { concatenateUint8Arrays } from '@/Utils/ConcatenateUint8Arrays'
import { DialogContent, DialogOverlay } from '@reach/dialog'
import { addToast, ToastType } from '@standardnotes/stylekit'
import { NoPreviewIllustration } from '@standardnotes/icons'
import { FunctionComponent, KeyboardEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { getFileIconComponent } from '@/Components/AttachedFilesPopover/getFileIconComponent'
import Button from '@/Components/Button/Button'
import Icon from '@/Components/Icon/Icon'
import FilePreviewInfoPanel from './FilePreviewInfoPanel'
import { isFileTypePreviewable } from './isFilePreviewable'
import PreviewComponent from './PreviewComponent'
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants'
import { KeyboardKey } from '@/Services/IOService'
import { ViewControllerManager } from '@/Services/ViewControllerManager'
import { observer } from 'mobx-react-lite'
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 [objectUrl, setObjectUrl] = useState<string>()
const [isFilePreviewable, setIsFilePreviewable] = useState(false)
const [isLoadingFile, setIsLoadingFile] = useState(true)
const [fileDownloadProgress, setFileDownloadProgress] = useState(0)
const [showFileInfoPanel, setShowFileInfoPanel] = useState(false)
const currentFileIdRef = useRef<string>()
const closeButtonRef = useRef<HTMLButtonElement>(null)
const getObjectUrl = useCallback(async () => {
try {
const chunks: Uint8Array[] = []
setFileDownloadProgress(0)
await application.files.downloadFile(currentFile, async (decryptedChunk, progress) => {
chunks.push(decryptedChunk)
if (progress) {
setFileDownloadProgress(Math.round(progress.percentComplete))
}
})
const finalDecryptedBytes = concatenateUint8Arrays(chunks)
setObjectUrl(
URL.createObjectURL(
new Blob([finalDecryptedBytes], {
type: currentFile.mimeType,
}),
),
)
} catch (error) {
console.error(error)
} finally {
setIsLoadingFile(false)
}
}, [application.files, currentFile])
useEffect(() => {
setIsLoadingFile(true)
}, [currentFile.uuid])
useEffect(() => {
const isPreviewable = isFileTypePreviewable(currentFile.mimeType)
setIsFilePreviewable(isPreviewable)
if (!isPreviewable) {
setObjectUrl('')
setIsLoadingFile(false)
}
if (currentFileIdRef.current !== currentFile.uuid && isPreviewable) {
getObjectUrl().catch(console.error)
}
currentFileIdRef.current = currentFile.uuid
return () => {
if (objectUrl) {
URL.revokeObjectURL(objectUrl)
}
}
}, [currentFile, getObjectUrl, objectUrl])
const keyDownHandler: KeyboardEventHandler = useCallback(
(event) => {
if (event.key !== KeyboardKey.Left && event.key !== KeyboardKey.Right) {
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>
{objectUrl && (
<Button
variant="primary"
className="mr-4"
onClick={() => {
application.getArchiveService().downloadData(objectUrl, currentFile.name)
addToast({
type: ToastType.Success,
message: 'Successfully downloaded file',
})
}}
>
Download
</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">
{isLoadingFile ? (
<div className="flex flex-col items-center">
<div className="flex items-center">
<div className="sk-spinner w-5 h-5 spinner-info mr-3"></div>
<div className="text-base font-semibold">{fileDownloadProgress}%</div>
</div>
<span className="mt-3">Loading file...</span>
</div>
) : objectUrl ? (
<PreviewComponent file={currentFile} objectUrl={objectUrl} />
) : (
<div className="flex flex-col items-center">
<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={() => {
getObjectUrl().catch(console.error)
}}
>
Try again
</Button>
<Button
variant="normal"
onClick={() => {
application
.getViewControllerManager()
.filesController.downloadFile(currentFile)
.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={() => {
application
.getViewControllerManager()
.filesController.downloadFile(currentFile)
.catch(console.error)
}}
>
Download
</Button>
</>
)}
</div>
)}
</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)

View File

@@ -1,26 +0,0 @@
import { FileItem } from '@standardnotes/snjs'
import { FunctionComponent } from 'react'
import ImagePreview from './ImagePreview'
type Props = {
file: FileItem
objectUrl: string
}
const PreviewComponent: FunctionComponent<Props> = ({ file, objectUrl }) => {
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 <audio src={objectUrl} controls />
}
return <object className="w-full h-full" data={objectUrl} />
}
export default PreviewComponent

View File

@@ -1,15 +1,17 @@
import { NoteViewController } from '@standardnotes/snjs'
import { FileItem, NoteViewController } from '@standardnotes/snjs'
import { PureComponent } from '@/Components/Abstract/PureComponent'
import { WebApplication } from '@/Application/Application'
import MultipleSelectedNotes from '@/Components/MultipleSelectedNotes/MultipleSelectedNotes'
import NoteView from '@/Components/NoteView/NoteView'
import MultipleSelectedFiles from '../MultipleSelectedFiles/MultipleSelectedFiles'
import { ElementIds } from '@/Constants/ElementIDs'
import FileView from '@/Components/FileView/FileView'
type State = {
showMultipleSelectedNotes: boolean
showMultipleSelectedFiles: boolean
controllers: NoteViewController[]
selectedFile: FileItem | undefined
}
type Props = {
@@ -25,6 +27,7 @@ class NoteGroupView extends PureComponent<Props, State> {
showMultipleSelectedNotes: false,
showMultipleSelectedFiles: false,
controllers: [],
selectedFile: undefined,
}
}
@@ -56,6 +59,14 @@ class NoteGroupView extends PureComponent<Props, State> {
})
}
})
this.autorun(() => {
if (this.viewControllerManager && this.viewControllerManager.selectionController) {
this.setState({
selectedFile: this.viewControllerManager.selectionController.selectedFiles[0],
})
}
})
}
override deinit() {
@@ -66,6 +77,9 @@ class NoteGroupView extends PureComponent<Props, State> {
}
override render() {
const shouldNotShowMultipleSelectedItems =
!this.state.showMultipleSelectedNotes && !this.state.showMultipleSelectedFiles
return (
<div id={ElementIds.EditorColumn} className="h-full app-column app-column-third">
{this.state.showMultipleSelectedNotes && (
@@ -79,13 +93,21 @@ class NoteGroupView extends PureComponent<Props, State> {
/>
)}
{!this.state.showMultipleSelectedNotes && (
{shouldNotShowMultipleSelectedItems && this.state.controllers.length > 0 && (
<>
{this.state.controllers.map((controller) => {
return <NoteView key={controller.note.uuid} application={this.application} controller={controller} />
})}
</>
)}
{shouldNotShowMultipleSelectedItems && this.state.controllers.length < 1 && this.state.selectedFile && (
<FileView
application={this.application}
viewControllerManager={this.viewControllerManager}
file={this.state.selectedFile}
/>
)}
</div>
)
}

View File

@@ -19,7 +19,7 @@ import { KeyboardModifier, KeyboardKey } from '@/Services/IOService'
import { STRING_DELETE_PLACEHOLDER_ATTEMPT, STRING_DELETE_LOCKED_ATTEMPT, StringDeleteNote } from '@/Constants/Strings'
import { confirmDialog } from '@/Services/AlertService'
import { PureComponent } from '@/Components/Abstract/PureComponent'
import ProtectedNoteOverlay from '@/Components/ProtectedNoteOverlay/ProtectedNoteOverlay'
import ProtectedItemOverlay from '@/Components/ProtectedItemOverlay/ProtectedItemOverlay'
import PinNoteButton from '@/Components/PinNoteButton/PinNoteButton'
import NotesOptionsPanel from '@/Components/NotesOptions/NotesOptionsPanel'
import NoteTagsContainer from '@/Components/NoteTags/NoteTagsContainer'
@@ -871,10 +871,11 @@ class NoteView extends PureComponent<NoteViewProps, State> {
<div aria-label="Note" className="section editor sn-component">
{this.state.showProtectedWarning && (
<div className="h-full flex justify-center items-center">
<ProtectedNoteOverlay
<ProtectedItemOverlay
viewControllerManager={this.viewControllerManager}
hasProtectionSources={this.application.hasProtectionSources()}
onViewNote={this.dismissProtectedWarning}
onViewItem={this.dismissProtectedWarning}
itemType={'note'}
/>
</div>
)}
@@ -906,7 +907,7 @@ class NoteView extends PureComponent<NoteViewProps, State> {
)}
{this.note && (
<div id="editor-title-bar" className="section-title-bar w-full">
<div id="editor-title-bar" className="content-title-bar section-title-bar w-full">
<div className="flex items-center justify-between h-8">
<div className={(this.state.noteLocked ? 'locked' : '') + ' flex-grow'}>
<div className="title overflow-auto">
@@ -943,8 +944,12 @@ class NoteView extends PureComponent<NoteViewProps, State> {
<div className="mr-3">
<AttachedFilesButton
application={this.application}
viewControllerManager={this.viewControllerManager}
onClickPreprocessing={this.ensureNoteIsInsertedBeforeUIAction}
featuresController={this.viewControllerManager.featuresController}
filePreviewModalController={this.viewControllerManager.filePreviewModalController}
filesController={this.viewControllerManager.filesController}
navigationController={this.viewControllerManager.navigationController}
notesController={this.viewControllerManager.notesController}
/>
</div>
<div className="mr-3">

View File

@@ -2,18 +2,19 @@ import { ViewControllerManager } from '@/Services/ViewControllerManager'
type Props = {
viewControllerManager: ViewControllerManager
onViewNote: () => void
onViewItem: () => void
hasProtectionSources: boolean
itemType: 'note' | 'file'
}
const ProtectedNoteOverlay = ({ viewControllerManager, onViewNote, hasProtectionSources }: Props) => {
const ProtectedItemOverlay = ({ viewControllerManager, onViewItem, hasProtectionSources, itemType }: Props) => {
const instructionText = hasProtectionSources
? 'Authenticate to view this note.'
: 'Add a passcode or create an account to require authentication to view this note.'
? `Authenticate to view this ${itemType}.`
: `Add a passcode or create an account to require authentication to view this ${itemType}.`
return (
<div className="flex flex-col items-center justify-center text-center max-w-md">
<h1 className="text-2xl m-0 w-full">This note is protected</h1>
<h1 className="text-2xl m-0 w-full">This {itemType} is protected</h1>
<p className="text-lg mt-2 w-full">{instructionText}</p>
<div className="mt-4 flex gap-3">
{!hasProtectionSources && (
@@ -26,12 +27,12 @@ const ProtectedNoteOverlay = ({ viewControllerManager, onViewNote, hasProtection
Open account menu
</button>
)}
<button className="sn-button small outlined normal-focus-brightness" onClick={onViewNote}>
{hasProtectionSources ? 'Authenticate' : 'View Note'}
<button className="sn-button small outlined normal-focus-brightness" onClick={onViewItem}>
{hasProtectionSources ? 'Authenticate' : `View ${itemType}`}
</button>
</div>
</div>
)
}
export default ProtectedNoteOverlay
export default ProtectedItemOverlay

View File

@@ -22,3 +22,6 @@ export const TAG_FOLDERS_FEATURE_TOOLTIP = 'A Plus or Pro plan is required to en
export const SMART_TAGS_FEATURE_NAME = 'Smart Tags'
export const PLAIN_EDITOR_NAME = 'Plain Text'
export const SYNC_TIMEOUT_DEBOUNCE = 350
export const SYNC_TIMEOUT_NO_DEBOUNCE = 100

View File

@@ -1,6 +1,8 @@
export const ElementIds = {
NoteTextEditor: 'note-text-editor',
NoteTitleEditor: 'note-title-editor',
FileTitleEditor: 'file-title-editor',
FileTextPreview: 'file-text-preview',
EditorContent: 'editor-content',
EditorColumn: 'editor-column',
}

View File

@@ -30,6 +30,7 @@ export class FilesController extends AbstractViewController {
allFiles: FileItem[] = []
attachedFiles: FileItem[] = []
showFileContextMenu = false
showProtectedOverlay = false
fileContextMenuLocation: FileContextMenuLocation = { x: 0, y: 0 }
override deinit(): void {
@@ -52,9 +53,12 @@ export class FilesController extends AbstractViewController {
showFileContextMenu: observable,
fileContextMenuLocation: observable,
showProtectedOverlay: observable,
reloadAllFiles: action,
reloadAttachedFiles: action,
setShowFileContextMenu: action,
setShowProtectedOverlay: action,
setFileContextMenuLocation: action,
})
@@ -79,6 +83,10 @@ export class FilesController extends AbstractViewController {
this.showFileContextMenu = enabled
}
setShowProtectedOverlay = (enabled: boolean) => {
this.showProtectedOverlay = enabled
}
setFileContextMenuLocation = (location: FileContextMenuLocation) => {
this.fileContextMenuLocation = location
}
@@ -404,9 +412,15 @@ export class FilesController extends AbstractViewController {
setProtectionForFiles = async (protect: boolean, files: FileItem[]) => {
if (protect) {
await this.application.mutator.protectItems(files)
const protectedItems = await this.application.mutator.protectItems(files)
if (protectedItems) {
this.setShowProtectedOverlay(true)
}
} else {
await this.application.mutator.unprotectItems(files, ChallengeReason.UnprotectFile)
const unprotectedItems = await this.application.mutator.unprotectItems(files, ChallengeReason.UnprotectFile)
if (unprotectedItems) {
this.setShowProtectedOverlay(false)
}
}
}

View File

@@ -92,6 +92,8 @@ export class NavigationController extends AbstractViewController {
setContextMenuClickLocation: action,
setContextMenuPosition: action,
setContextMenuMaxHeight: action,
isInFilesView: computed,
})
this.disposers.push(
@@ -257,6 +259,10 @@ export class NavigationController extends AbstractViewController {
}
}
public get isInFilesView(): boolean {
return this.selectedUuid === SystemViewId.Files
}
public get allLocalRootTags(): SNTag[] {
if (this.editing_ instanceof SNTag && this.application.items.isTemplateItem(this.editing_)) {
return [this.editing_, ...this.rootTags]

View File

@@ -1,21 +1,14 @@
.app-column-container {
display: flex;
flex-direction: row;
display: grid;
grid-template-columns: auto auto 2fr;
}
.app-column-first {
width: 220px;
flex-shrink: 0.2;
}
.app-column-second {
width: 350px;
flex-shrink: 0.5;
}
.app-column-third {
flex-grow: 1;
flex-shrink: 0.3;
}
.app-column {

View File

@@ -26,7 +26,7 @@ $heading-height: 75px;
max-width: 500px;
}
#editor-title-bar {
.content-title-bar {
width: 100%;
padding-top: 14px;
@@ -108,19 +108,21 @@ $heading-height: 75px;
flex: 1;
width: 100%;
}
}
.editable {
overflow-y: scroll;
width: 100%;
background-color: var(--editor-pane-editor-background-color);
color: var(--editor-pane-editor-foreground-color);
.editor-content .editable,
#editor-content .editable,
#file-text-preview {
overflow-y: scroll;
width: 100%;
background-color: var(--editor-pane-editor-background-color);
color: var(--editor-pane-editor-foreground-color);
border: none;
outline: none;
padding: 15px;
font-size: var(--sn-stylekit-font-size-editor);
resize: none;
}
border: none;
outline: none;
padding: 15px;
font-size: var(--sn-stylekit-font-size-editor);
resize: none;
}
#editor-pane-component-stack {

View File

@@ -62,6 +62,10 @@
padding-bottom: 0;
}
.pb-0\.5 {
padding-bottom: 0.125rem;
}
.pb-1 {
padding-bottom: 0.25rem;
}