feat: add file view (#1064)
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -10,7 +10,7 @@ const ContentListItem: FunctionComponent<AbstractListItemProps> = (props) => {
|
||||
return []
|
||||
}
|
||||
|
||||
const selectedTag = props.viewControllerManager.navigationController.selected
|
||||
const selectedTag = props.navigationController.selected
|
||||
if (!selectedTag) {
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,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)
|
||||
@@ -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',
|
||||
}),
|
||||
}}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
|
||||
36
app/assets/javascripts/Components/FileView/FileView.tsx
Normal file
36
app/assets/javascripts/Components/FileView/FileView.tsx
Normal 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)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -62,6 +62,10 @@
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.pb-0\.5 {
|
||||
padding-bottom: 0.125rem;
|
||||
}
|
||||
|
||||
.pb-1 {
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user