chore: immediately insert file node and show progress inside super note when uploading a new file (#2876) [skip e2e]

This commit is contained in:
Aman Harwara
2024-05-06 20:32:04 +05:30
committed by GitHub
parent 5cb781e336
commit 561e451fa0
9 changed files with 239 additions and 101 deletions

View File

@@ -23,6 +23,7 @@ export interface FilesClientInterface {
finishUpload(
operation: EncryptAndUploadFileOperation,
fileMetadata: FileMetadata,
uuid: string,
): Promise<FileItem | ClientDisplayableError>
downloadFile(

View File

@@ -17,6 +17,12 @@ import {
isEncryptedPayload,
VaultListingInterface,
SharedVaultListingInterface,
DecryptedPayload,
FillItemContent,
PayloadVaultOverrides,
PayloadTimestampDefaults,
CreateItemFromPayload,
DecryptedItemInterface,
} from '@standardnotes/models'
import { PureCryptoInterface } from '@standardnotes/sncrypto-common'
import { LoggerInterface, spaceSeparatedStrings, UuidGenerator } from '@standardnotes/utils'
@@ -246,6 +252,7 @@ export class FileService extends AbstractService implements FilesClientInterface
public async finishUpload(
operation: EncryptAndUploadFileOperation,
fileMetadata: FileMetadata,
uuid: string,
): Promise<FileItem | ClientDisplayableError> {
const uploadSessionClosed = await this.api.closeUploadSession(
operation.getValetToken(),
@@ -268,16 +275,22 @@ export class FileService extends AbstractService implements FilesClientInterface
remoteIdentifier: result.remoteIdentifier,
}
const file = await this.mutator.createItem<FileItem>(
ContentType.TYPES.File,
FillItemContentSpecialized(fileContent),
true,
operation.vault,
)
const filePayload = new DecryptedPayload<FileContent>({
uuid,
content_type: ContentType.TYPES.File,
content: FillItemContent<FileContent>(FillItemContentSpecialized(fileContent)),
dirty: true,
...PayloadVaultOverrides(operation.vault),
...PayloadTimestampDefaults(),
})
const fileItem = CreateItemFromPayload(filePayload) as DecryptedItemInterface<FileContent>
const insertedItem = await this.mutator.insertItem<FileItem>(fileItem)
await this.sync.sync()
return file
return insertedItem
}
private async decryptCachedEntry(file: FileItem, entry: EncryptedBytes): Promise<DecryptedBytes> {

View File

@@ -8,12 +8,22 @@ import { useMemo, useState, createContext, ReactNode, useRef, useCallback, useEf
import Portal from './Portal/Portal'
import { ElementIds } from '@/Constants/ElementIDs'
type FileDragTargetData = {
type FileDragTargetCommonData = {
tooltipText: string
callback: (files: FileItem) => void
note?: SNNote
}
type FileDragTargetCallbacks =
| {
callback: (files: FileItem) => void
handleFileUpload?: never
}
| {
handleFileUpload: (fileOrHandle: File | FileSystemFileHandle) => void
callback?: never
}
type FileDragTargetData = FileDragTargetCommonData & FileDragTargetCallbacks
type FileDnDContextData = {
isDraggingFiles: boolean
addDragTarget: (target: HTMLElement, data: FileDragTargetData) => void
@@ -203,6 +213,11 @@ const FileDragNDropProvider = ({ application, children }: Props) => {
const dragTarget = closestDragTarget ? dragTargets.current.get(closestDragTarget) : undefined
if (dragTarget?.handleFileUpload) {
dragTarget.handleFileUpload(fileOrHandle)
return
}
const uploadedFile = await application.filesController.uploadNewFile(fileOrHandle, {
note: dragTarget?.note,
})
@@ -211,7 +226,9 @@ const FileDragNDropProvider = ({ application, children }: Props) => {
return
}
dragTarget?.callback(uploadedFile)
if (dragTarget?.callback) {
dragTarget.callback(uploadedFile)
}
})
dragCounter.current = 0

View File

@@ -1,6 +1,6 @@
import { FilesController } from '@/Controllers/FilesController'
import { LinkingController } from '@/Controllers/LinkingController'
import { SNNote } from '@standardnotes/snjs'
import { NoteType, SNNote } from '@standardnotes/snjs'
import { useEffect } from 'react'
import { useApplication } from '../ApplicationProvider'
import { useFileDragNDrop } from '../FileDragNDropProvider'
@@ -20,17 +20,28 @@ const NoteViewFileDropTarget = ({ note, linkingController, noteViewElement, file
const target = noteViewElement
if (target) {
addDragTarget(target, {
tooltipText: 'Drop your files to upload and link them to the current note',
callback: async (uploadedFile) => {
await linkingController.linkItems(note, uploadedFile)
void application.changeAndSaveItem.execute(uploadedFile, (mutator) => {
mutator.protected = note.protected
})
filesController.notifyObserversOfUploadedFileLinkingToCurrentNote(uploadedFile.uuid)
},
note,
})
const tooltipText = 'Drop your files to upload and link them to the current note'
if (note.noteType === NoteType.Super) {
addDragTarget(target, {
tooltipText,
handleFileUpload: (fileOrHandle) => {
filesController.uploadAndInsertFileToCurrentNote(fileOrHandle)
},
note,
})
} else {
addDragTarget(target, {
tooltipText,
callback: async (uploadedFile) => {
await linkingController.linkItems(note, uploadedFile)
void application.changeAndSaveItem.execute(uploadedFile, (mutator) => {
mutator.protected = note.protected
})
filesController.notifyObserversOfUploadedFileLinkingToCurrentNote(uploadedFile.uuid)
},
note,
})
}
}
return () => {

View File

@@ -1,6 +1,7 @@
import { createCommand, LexicalCommand } from 'lexical'
export const INSERT_FILE_COMMAND: LexicalCommand<string> = createCommand('INSERT_FILE_COMMAND')
export const UPLOAD_AND_INSERT_FILE_COMMAND: LexicalCommand<File> = createCommand('UPLOAD_AND_INSERT_FILE_COMMAND')
export const INSERT_BUBBLE_COMMAND: LexicalCommand<string> = createCommand('INSERT_BUBBLE_COMMAND')
export const INSERT_DATETIME_COMMAND: LexicalCommand<'date' | 'time' | 'datetime'> =
createCommand('INSERT_DATETIME_COMMAND')

View File

@@ -1,4 +1,4 @@
import { INSERT_FILE_COMMAND } from '../Commands'
import { INSERT_FILE_COMMAND, UPLOAD_AND_INSERT_FILE_COMMAND } from '../Commands'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { useEffect, useState } from 'react'
@@ -19,45 +19,24 @@ import { FilesControllerEvent } from '@/Controllers/FilesController'
import { useLinkingController } from '@/Controllers/LinkingControllerProvider'
import { useApplication } from '@/Components/ApplicationProvider'
import { SNNote } from '@standardnotes/snjs'
import Spinner from '../../../Spinner/Spinner'
import Modal from '../../Lexical/UI/Modal'
import Button from '@/Components/Button/Button'
import { isMobileScreen } from '../../../../Utils'
export const OPEN_FILE_UPLOAD_MODAL_COMMAND = createCommand('OPEN_FILE_UPLOAD_MODAL_COMMAND')
function UploadFileDialog({ currentNote, onClose }: { currentNote: SNNote; onClose: () => void }) {
const application = useApplication()
function UploadFileDialog({ onClose }: { onClose: () => void }) {
const [editor] = useLexicalComposerContext()
const filesController = useFilesController()
const linkingController = useLinkingController()
const [file, setFile] = useState<File>()
const [isUploadingFile, setIsUploadingFile] = useState(false)
const onClick = () => {
if (!file) {
return
}
setIsUploadingFile(true)
filesController
.uploadNewFile(file)
.then((uploadedFile) => {
if (!uploadedFile) {
return
}
editor.dispatchCommand(INSERT_FILE_COMMAND, uploadedFile.uuid)
void linkingController.linkItemToSelectedItem(uploadedFile)
void application.changeAndSaveItem.execute(uploadedFile, (mutator) => {
mutator.protected = currentNote.protected
})
})
.catch(console.error)
.finally(() => {
setIsUploadingFile(false)
onClose()
})
editor.dispatchCommand(UPLOAD_AND_INSERT_FILE_COMMAND, file)
onClose()
}
return (
@@ -72,13 +51,9 @@ function UploadFileDialog({ currentNote, onClose }: { currentNote: SNNote; onClo
}}
/>
<div className="mt-1.5 flex justify-end">
{isUploadingFile ? (
<Spinner className="h-4 w-4" />
) : (
<Button onClick={onClick} disabled={!file} small={isMobileScreen()}>
Upload
</Button>
)}
<Button onClick={onClick} disabled={!file} small={isMobileScreen()}>
Upload
</Button>
</div>
</>
)
@@ -99,17 +74,23 @@ export default function FilePlugin({ currentNote }: { currentNote: SNNote }): JS
const uploadFilesList = (files: FileList) => {
Array.from(files).forEach(async (file) => {
try {
const uploadedFile = await filesController.uploadNewFile(file)
if (uploadedFile) {
editor.dispatchCommand(INSERT_FILE_COMMAND, uploadedFile.uuid)
void linkingController.linkItemToSelectedItem(uploadedFile)
void application.changeAndSaveItem.execute(uploadedFile, (mutator) => {
mutator.protected = currentNote.protected
})
}
} catch (error) {
console.error(error)
editor.dispatchCommand(UPLOAD_AND_INSERT_FILE_COMMAND, file)
})
}
const insertFileNode = (uuid: string, onInsert?: (node: FileNode) => void) => {
editor.update(() => {
const fileNode = $createFileNode(uuid)
$insertNodes([fileNode])
if ($isRootOrShadowRoot(fileNode.getParentOrThrow())) {
$wrapNodeInElement(fileNode, $createParagraphNode).selectEnd()
}
const newLineNode = $createParagraphNode()
fileNode.getParentOrThrow().insertAfter(newLineNode)
newLineNode.selectEnd()
editor.focus()
if (onInsert) {
onInsert(fileNode)
}
})
}
@@ -118,14 +99,34 @@ export default function FilePlugin({ currentNote }: { currentNote: SNNote }): JS
editor.registerCommand<string>(
INSERT_FILE_COMMAND,
(payload) => {
const fileNode = $createFileNode(payload)
$insertNodes([fileNode])
if ($isRootOrShadowRoot(fileNode.getParentOrThrow())) {
$wrapNodeInElement(fileNode, $createParagraphNode).selectEnd()
}
const newLineNode = $createParagraphNode()
fileNode.getParentOrThrow().insertAfter(newLineNode)
insertFileNode(payload)
return true
},
COMMAND_PRIORITY_EDITOR,
),
editor.registerCommand(
UPLOAD_AND_INSERT_FILE_COMMAND,
(file) => {
const note = currentNote
let fileNode: FileNode | undefined
filesController
.uploadNewFile(file, {
showToast: false,
onUploadStart(fileUuid) {
insertFileNode(fileUuid, (node) => (fileNode = node))
},
})
.then((uploadedFile) => {
if (uploadedFile) {
void linkingController.linkItems(note, uploadedFile)
void application.changeAndSaveItem.execute(uploadedFile, (mutator) => {
mutator.protected = note.protected
})
} else {
editor.update(() => fileNode?.remove())
}
})
.catch(console.error)
return true
},
COMMAND_PRIORITY_EDITOR,
@@ -150,28 +151,26 @@ export default function FilePlugin({ currentNote }: { currentNote: SNNote }): JS
},
COMMAND_PRIORITY_NORMAL,
),
editor.registerNodeTransform(FileNode, (node) => {
/**
* When adding the node we wrap it with a paragraph to avoid insertion errors,
* however that causes issues with selection. We unwrap the node to fix that.
*/
const parent = node.getParent()
if (!parent) {
return
}
if (parent.getChildrenSize() === 1) {
parent.insertBefore(node)
parent.remove()
}
}),
)
}, [application, currentNote.protected, editor, filesController, linkingController])
}, [application, currentNote, editor, filesController, linkingController])
useEffect(() => {
const disposer = filesController.addEventObserver((event, data) => {
if (event === FilesControllerEvent.FileUploadedToNote) {
if (event === FilesControllerEvent.FileUploadedToNote && data[FilesControllerEvent.FileUploadedToNote]) {
const fileUuid = data[FilesControllerEvent.FileUploadedToNote].uuid
editor.dispatchCommand(INSERT_FILE_COMMAND, fileUuid)
} else if (event === FilesControllerEvent.UploadAndInsertFile && data[FilesControllerEvent.UploadAndInsertFile]) {
const { fileOrHandle } = data[FilesControllerEvent.UploadAndInsertFile]
if (fileOrHandle instanceof FileSystemFileHandle) {
fileOrHandle
.getFile()
.then((file) => {
editor.dispatchCommand(UPLOAD_AND_INSERT_FILE_COMMAND, file)
})
.catch(console.error)
} else {
editor.dispatchCommand(UPLOAD_AND_INSERT_FILE_COMMAND, fileOrHandle)
}
}
})
@@ -181,7 +180,7 @@ export default function FilePlugin({ currentNote }: { currentNote: SNNote }): JS
if (showFileUploadModal) {
return (
<Modal onClose={() => setShowFileUploadModal(false)} title="Upload File">
<UploadFileDialog currentNote={currentNote} onClose={() => setShowFileUploadModal(false)} />
<UploadFileDialog onClose={() => setShowFileUploadModal(false)} />
</Modal>
)
}

View File

@@ -6,6 +6,9 @@ import FilePreview from '@/Components/FilePreview/FilePreview'
import { FileItem } from '@standardnotes/snjs'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { useLexicalNodeSelection } from '@lexical/react/useLexicalNodeSelection'
import { observer } from 'mobx-react-lite'
import Spinner from '@/Components/Spinner/Spinner'
import { FilesControllerEvent } from '@/Controllers/FilesController'
export type FileComponentProps = Readonly<{
className: Readonly<{
@@ -19,10 +22,11 @@ export type FileComponentProps = Readonly<{
setZoomLevel: (zoomLevel: number) => void
}>
export function FileComponent({ className, format, nodeKey, fileUuid, zoomLevel, setZoomLevel }: FileComponentProps) {
function FileComponent({ className, format, nodeKey, fileUuid, zoomLevel, setZoomLevel }: FileComponentProps) {
const application = useApplication()
const [editor] = useLexicalComposerContext()
const file = useMemo(() => application.items.findItem<FileItem>(fileUuid), [application, fileUuid])
const [file, setFile] = useState(() => application.items.findItem<FileItem>(fileUuid))
const uploadProgress = application.filesController.uploadProgressMap.get(fileUuid)
const [canLoad, setCanLoad] = useState(false)
@@ -90,6 +94,41 @@ export function FileComponent({ className, format, nodeKey, fileUuid, zoomLevel,
)
}, [editor, isSelected, nodeKey, setSelected])
useEffect(() => {
return application.filesController.addEventObserver((event, data) => {
if (event === FilesControllerEvent.FileUploadFinished && data[FilesControllerEvent.FileUploadFinished]) {
const { uploadedFile } = data[FilesControllerEvent.FileUploadFinished]
if (uploadedFile.uuid === fileUuid) {
setFile(uploadedFile)
}
}
})
}, [application.filesController, fileUuid])
if (uploadProgress && (uploadProgress.progress < 100 || !file)) {
const progress = uploadProgress.progress
return (
<BlockWithAlignableContents className={className} format={format} nodeKey={nodeKey}>
<div className="flex flex-col items-center justify-center gap-2 p-4 text-center" ref={blockWrapperRef}>
<div className="flex items-center gap-2">
<Spinner className="h-4 w-4" />
Uploading file "{uploadProgress.file.name}"... ({progress}%)
</div>
<div className="w-full max-w-[50%] overflow-hidden rounded bg-contrast">
<div
className="h-2 rounded rounded-tl-none bg-info transition-[width] duration-100"
role="progressbar"
style={{
width: `${progress}%`,
}}
aria-valuenow={progress}
/>
</div>
</div>
</BlockWithAlignableContents>
)
}
if (!file) {
return (
<BlockWithAlignableContents className={className} format={format} nodeKey={nodeKey}>
@@ -114,3 +153,5 @@ export function FileComponent({ className, format, nodeKey, fileUuid, zoomLevel,
</BlockWithAlignableContents>
)
}
export default observer(FileComponent)

View File

@@ -1,7 +1,7 @@
import { DOMConversionMap, DOMExportOutput, EditorConfig, ElementFormatType, LexicalEditor, NodeKey } from 'lexical'
import { DecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode'
import { $createFileNode, convertToFileElement } from './FileUtils'
import { FileComponent } from './FileComponent'
import FileComponent from './FileComponent'
import { SerializedFileNode } from './SerializedFileNode'
import { ItemNodeInterface } from '../../ItemNodeInterface'

View File

@@ -38,6 +38,7 @@ import {
ProtectionsClientInterface,
SNNote,
SyncServiceInterface,
UuidGenerator,
VaultServiceInterface,
} from '@standardnotes/snjs'
import { addToast, dismissToast, ToastType, updateToast } from '@standardnotes/toast'
@@ -52,14 +53,22 @@ const NonMutatingFileActions = [FileItemActionType.DownloadFile, FileItemActionT
type FileContextMenuLocation = { x: number; y: number }
export type FilesControllerEventData = {
[FilesControllerEvent.FileUploadedToNote]: {
uuid: string
}
export enum FilesControllerEvent {
FileUploadedToNote = 'FileUploadedToNote',
FileUploadFinished = 'FileUploadFinished',
UploadAndInsertFile = 'UploadAndInsertFile',
}
export enum FilesControllerEvent {
FileUploadedToNote,
export type FilesControllerEventData = {
[FilesControllerEvent.FileUploadedToNote]?: {
uuid: string
}
[FilesControllerEvent.FileUploadFinished]?: {
uploadedFile: FileItem
}
[FilesControllerEvent.UploadAndInsertFile]?: {
fileOrHandle: File | FileSystemFileHandle
}
}
export class FilesController extends AbstractViewController<FilesControllerEvent, FilesControllerEventData> {
@@ -73,6 +82,14 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
reader = this.shouldUseStreamingAPI ? StreamingFileReader : ClassicFileReader
maxFileSize = this.reader.maximumFileSize()
uploadProgressMap: Map<
string,
{
file: File
progress: number
}
> = new Map()
override deinit(): void {
super.deinit()
;(this.notesController as unknown) = undefined
@@ -111,6 +128,8 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
setShowFileContextMenu: action,
setShowProtectedOverlay: action,
setFileContextMenuLocation: action,
uploadProgressMap: observable,
})
this.disposers.push(
@@ -453,9 +472,11 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
options: {
showToast?: boolean
note?: SNNote
onUploadStart?: (fileUuid: string) => void
onUploadFinish?: () => void
} = {},
): Promise<FileItem | undefined> {
const { showToast = true, note } = options
const { showToast = true, note, onUploadStart, onUploadFinish } = options
let toastId: string | undefined
let canShowProgressNotification = false
@@ -482,6 +503,17 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
return
}
const uuid = UuidGenerator.GenerateUuid()
this.uploadProgressMap.set(uuid, {
file: fileToUpload,
progress: 0,
})
if (onUploadStart) {
onUploadStart(uuid)
}
const vaultForNote = note ? this.vaults.getItemVault(note) : undefined
const operation = await this.files.beginNewFileUpload(
@@ -499,6 +531,11 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
const initialProgress = operation.getProgress().percentComplete
this.uploadProgressMap.set(uuid, {
file: fileToUpload,
progress: initialProgress,
})
if (showToast) {
if (this.mobileDevice && canShowProgressNotification) {
toastId = await this.mobileDevice.displayNotification({
@@ -521,6 +558,10 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
await this.files.pushBytesForUpload(operation, data, index, isLast)
const percentComplete = Math.round(operation.getProgress().percentComplete)
this.uploadProgressMap.set(uuid, {
file: fileToUpload,
progress: percentComplete,
})
if (toastId) {
if (this.mobileDevice && canShowProgressNotification) {
await this.mobileDevice.displayNotification({
@@ -547,7 +588,7 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
fileResult.mimeType = await this.archiveService.getMimeType(ext)
}
const uploadedFile = await this.files.finishUpload(operation, fileResult)
const uploadedFile = await this.files.finishUpload(operation, fileResult, uuid)
if (uploadedFile instanceof ClientDisplayableError) {
addToast({
@@ -557,6 +598,14 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
throw new Error(uploadedFile.text)
}
if (onUploadFinish) {
onUploadFinish()
}
this.notifyEvent(FilesControllerEvent.FileUploadFinished, {
[FilesControllerEvent.FileUploadFinished]: { uploadedFile },
})
if (toastId) {
if (this.mobileDevice && canShowProgressNotification) {
this.mobileDevice.cancelNotification(toastId).catch(console.error)
@@ -635,6 +684,12 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
})
}
uploadAndInsertFileToCurrentNote(fileOrHandle: File | FileSystemFileHandle) {
this.notifyEvent(FilesControllerEvent.UploadAndInsertFile, {
[FilesControllerEvent.UploadAndInsertFile]: { fileOrHandle },
})
}
deleteFilesPermanently = async (files: FileItem[]) => {
const title = Strings.trashItemsTitle
const text = files.length === 1 ? StringUtils.deleteFile(files[0].name) : Strings.deleteMultipleFiles