diff --git a/packages/files/src/Domain/Service/FilesClientInterface.ts b/packages/files/src/Domain/Service/FilesClientInterface.ts index 6f7b462c48e..3031f746170 100644 --- a/packages/files/src/Domain/Service/FilesClientInterface.ts +++ b/packages/files/src/Domain/Service/FilesClientInterface.ts @@ -23,6 +23,7 @@ export interface FilesClientInterface { finishUpload( operation: EncryptAndUploadFileOperation, fileMetadata: FileMetadata, + uuid: string, ): Promise downloadFile( diff --git a/packages/services/src/Domain/Files/FileService.ts b/packages/services/src/Domain/Files/FileService.ts index 67fd91e049a..10d4d04b57b 100644 --- a/packages/services/src/Domain/Files/FileService.ts +++ b/packages/services/src/Domain/Files/FileService.ts @@ -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 { 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( - ContentType.TYPES.File, - FillItemContentSpecialized(fileContent), - true, - operation.vault, - ) + const filePayload = new DecryptedPayload({ + uuid, + content_type: ContentType.TYPES.File, + content: FillItemContent(FillItemContentSpecialized(fileContent)), + dirty: true, + ...PayloadVaultOverrides(operation.vault), + ...PayloadTimestampDefaults(), + }) + + const fileItem = CreateItemFromPayload(filePayload) as DecryptedItemInterface + + const insertedItem = await this.mutator.insertItem(fileItem) await this.sync.sync() - return file + return insertedItem } private async decryptCachedEntry(file: FileItem, entry: EncryptedBytes): Promise { diff --git a/packages/web/src/javascripts/Components/FileDragNDropProvider.tsx b/packages/web/src/javascripts/Components/FileDragNDropProvider.tsx index 1f57bc8201c..5b90c8b3404 100644 --- a/packages/web/src/javascripts/Components/FileDragNDropProvider.tsx +++ b/packages/web/src/javascripts/Components/FileDragNDropProvider.tsx @@ -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 diff --git a/packages/web/src/javascripts/Components/NoteView/NoteViewFileDropTarget.tsx b/packages/web/src/javascripts/Components/NoteView/NoteViewFileDropTarget.tsx index de418a2e3f5..4c9655ad150 100644 --- a/packages/web/src/javascripts/Components/NoteView/NoteViewFileDropTarget.tsx +++ b/packages/web/src/javascripts/Components/NoteView/NoteViewFileDropTarget.tsx @@ -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 () => { diff --git a/packages/web/src/javascripts/Components/SuperEditor/Plugins/Commands.ts b/packages/web/src/javascripts/Components/SuperEditor/Plugins/Commands.ts index 33bfb5e54dd..a6f60180b27 100644 --- a/packages/web/src/javascripts/Components/SuperEditor/Plugins/Commands.ts +++ b/packages/web/src/javascripts/Components/SuperEditor/Plugins/Commands.ts @@ -1,6 +1,7 @@ import { createCommand, LexicalCommand } from 'lexical' export const INSERT_FILE_COMMAND: LexicalCommand = createCommand('INSERT_FILE_COMMAND') +export const UPLOAD_AND_INSERT_FILE_COMMAND: LexicalCommand = createCommand('UPLOAD_AND_INSERT_FILE_COMMAND') export const INSERT_BUBBLE_COMMAND: LexicalCommand = createCommand('INSERT_BUBBLE_COMMAND') export const INSERT_DATETIME_COMMAND: LexicalCommand<'date' | 'time' | 'datetime'> = createCommand('INSERT_DATETIME_COMMAND') diff --git a/packages/web/src/javascripts/Components/SuperEditor/Plugins/EncryptedFilePlugin/FilePlugin.tsx b/packages/web/src/javascripts/Components/SuperEditor/Plugins/EncryptedFilePlugin/FilePlugin.tsx index 9648eff9a18..8c55db05676 100644 --- a/packages/web/src/javascripts/Components/SuperEditor/Plugins/EncryptedFilePlugin/FilePlugin.tsx +++ b/packages/web/src/javascripts/Components/SuperEditor/Plugins/EncryptedFilePlugin/FilePlugin.tsx @@ -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() - 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 }} />
- {isUploadingFile ? ( - - ) : ( - - )} +
) @@ -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( 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 ( setShowFileUploadModal(false)} title="Upload File"> - setShowFileUploadModal(false)} /> + setShowFileUploadModal(false)} /> ) } diff --git a/packages/web/src/javascripts/Components/SuperEditor/Plugins/EncryptedFilePlugin/Nodes/FileComponent.tsx b/packages/web/src/javascripts/Components/SuperEditor/Plugins/EncryptedFilePlugin/Nodes/FileComponent.tsx index f212724893c..98428f892bb 100644 --- a/packages/web/src/javascripts/Components/SuperEditor/Plugins/EncryptedFilePlugin/Nodes/FileComponent.tsx +++ b/packages/web/src/javascripts/Components/SuperEditor/Plugins/EncryptedFilePlugin/Nodes/FileComponent.tsx @@ -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(fileUuid), [application, fileUuid]) + const [file, setFile] = useState(() => application.items.findItem(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 ( + +
+
+ + Uploading file "{uploadProgress.file.name}"... ({progress}%) +
+
+
+
+
+ + ) + } + if (!file) { return ( @@ -114,3 +153,5 @@ export function FileComponent({ className, format, nodeKey, fileUuid, zoomLevel, ) } + +export default observer(FileComponent) diff --git a/packages/web/src/javascripts/Components/SuperEditor/Plugins/EncryptedFilePlugin/Nodes/FileNode.tsx b/packages/web/src/javascripts/Components/SuperEditor/Plugins/EncryptedFilePlugin/Nodes/FileNode.tsx index 0f86d135b42..b02b1e95e87 100644 --- a/packages/web/src/javascripts/Components/SuperEditor/Plugins/EncryptedFilePlugin/Nodes/FileNode.tsx +++ b/packages/web/src/javascripts/Components/SuperEditor/Plugins/EncryptedFilePlugin/Nodes/FileNode.tsx @@ -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' diff --git a/packages/web/src/javascripts/Controllers/FilesController.ts b/packages/web/src/javascripts/Controllers/FilesController.ts index dd2a116273c..35998571ddc 100644 --- a/packages/web/src/javascripts/Controllers/FilesController.ts +++ b/packages/web/src/javascripts/Controllers/FilesController.ts @@ -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 enum FilesControllerEvent { + FileUploadedToNote = 'FileUploadedToNote', + FileUploadFinished = 'FileUploadFinished', + UploadAndInsertFile = 'UploadAndInsertFile', +} + export type FilesControllerEventData = { - [FilesControllerEvent.FileUploadedToNote]: { + [FilesControllerEvent.FileUploadedToNote]?: { uuid: string } -} - -export enum FilesControllerEvent { - FileUploadedToNote, + [FilesControllerEvent.FileUploadFinished]?: { + uploadedFile: FileItem + } + [FilesControllerEvent.UploadAndInsertFile]?: { + fileOrHandle: File | FileSystemFileHandle + } } export class FilesController extends AbstractViewController { @@ -73,6 +82,14 @@ export class FilesController extends AbstractViewController = new Map() + override deinit(): void { super.deinit() ;(this.notesController as unknown) = undefined @@ -111,6 +128,8 @@ export class FilesController extends AbstractViewController void + onUploadFinish?: () => void } = {}, ): Promise { - 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 { const title = Strings.trashItemsTitle const text = files.length === 1 ? StringUtils.deleteFile(files[0].name) : Strings.deleteMultipleFiles