Implement file paste handler for editor

This commit is contained in:
Hakan Shehu
2025-01-06 14:46:56 +01:00
parent 661720bb4e
commit c384d7a4f4
16 changed files with 378 additions and 14 deletions

View File

@@ -0,0 +1,26 @@
import { fileService } from '@/main/services/file-service';
import { JobHandler } from '@/main/jobs';
export type CleanTempFilesInput = {
type: 'clean_temp_files';
userId: string;
};
declare module '@/main/jobs' {
interface JobMap {
clean_temp_files: {
input: CleanTempFilesInput;
};
}
}
export class CleanTempFilesJobHandler
implements JobHandler<CleanTempFilesInput>
{
public triggerDebounce = 1000;
public interval = 1000 * 60 * 30;
public async handleJob(input: CleanTempFilesInput) {
await fileService.cleanTempFiles(input.userId);
}
}

View File

@@ -0,0 +1,46 @@
import path from 'path';
import fs from 'fs';
import { MutationHandler } from '@/main/types';
import {
FileSaveTempMutationInput,
FileSaveTempMutationOutput,
} from '@/shared/mutations/files/file-save-temp';
import { getWorkspaceTempFilesDirectoryPath } from '@/main/utils';
export class FileSaveTempMutationHandler
implements MutationHandler<FileSaveTempMutationInput>
{
async handleMutation(
input: FileSaveTempMutationInput
): Promise<FileSaveTempMutationOutput> {
const directoryPath = getWorkspaceTempFilesDirectoryPath(input.userId);
const fileName = this.generateUniqueName(directoryPath, input.name);
const filePath = path.join(directoryPath, fileName);
if (!fs.existsSync(directoryPath)) {
fs.mkdirSync(directoryPath, { recursive: true });
}
const buffer = Buffer.from(input.buffer);
fs.writeFileSync(filePath, buffer);
return {
path: filePath,
};
}
private generateUniqueName(directoryPath: string, name: string): string {
let result = name;
let counter = 1;
while (fs.existsSync(path.join(directoryPath, result))) {
const nameWithoutExtension = path.basename(name, path.extname(name));
const extension = path.extname(name);
result = `${nameWithoutExtension}_${counter}${extension}`;
counter++;
}
return result;
}
}

View File

@@ -21,6 +21,7 @@ import { FileDeleteMutationHandler } from '@/main/mutations/files/file-delete';
import { FileDownloadMutationHandler } from '@/main/mutations/files/file-download';
import { FileMarkOpenedMutationHandler } from '@/main/mutations/files/file-mark-opened';
import { FileMarkSeenMutationHandler } from '@/main/mutations/files/file-mark-seen';
import { FileSaveTempMutationHandler } from '@/main/mutations/files/file-save-temp';
import { FolderCreateMutationHandler } from '@/main/mutations/folders/folder-create';
import { FolderUpdateMutationHandler } from '@/main/mutations/folders/folder-update';
import { FolderDeleteMutationHandler } from '@/main/mutations/folders/folder-delete';
@@ -117,6 +118,7 @@ export const mutationHandlerMap: MutationHandlerMap = {
file_download: new FileDownloadMutationHandler(),
file_mark_opened: new FileMarkOpenedMutationHandler(),
file_mark_seen: new FileMarkSeenMutationHandler(),
file_save_temp: new FileSaveTempMutationHandler(),
space_update: new SpaceUpdateMutationHandler(),
account_update: new AccountUpdateMutationHandler(),
view_update: new ViewUpdateMutationHandler(),

View File

@@ -67,7 +67,11 @@ export class FileGetQueryHandler implements QueryHandler<FileGetQueryInput> {
};
}
if (event.type === 'file_state_created') {
if (
event.type === 'file_state_created' &&
event.userId === input.userId &&
event.fileState.fileId === input.id
) {
if (output === null) {
const newResult = await this.fetchFile(input);
return {
@@ -85,7 +89,11 @@ export class FileGetQueryHandler implements QueryHandler<FileGetQueryInput> {
};
}
if (event.type === 'file_state_updated') {
if (
event.type === 'file_state_updated' &&
event.userId === input.userId &&
event.fileState.fileId === input.id
) {
if (output === null) {
const newResult = await this.fetchFile(input);
return {

View File

@@ -82,7 +82,7 @@ export class FileListQueryHandler implements QueryHandler<FileListQueryInput> {
}
}
if (event.type === 'file_state_created') {
if (event.type === 'file_state_created' && event.userId === input.userId) {
const file = output.find((file) => file.id === event.fileState.fileId);
if (file) {
const newResult = output.map((file) => {
@@ -102,7 +102,7 @@ export class FileListQueryHandler implements QueryHandler<FileListQueryInput> {
}
}
if (event.type === 'file_state_updated') {
if (event.type === 'file_state_updated' && event.userId === input.userId) {
const file = output.find((file) => file.id === event.fileState.fileId);
if (file) {
const newResult = output.map((file) => {

View File

@@ -15,6 +15,7 @@ import { ConnectSocketJobHandler } from '@/main/jobs/connect-socket';
import { UploadFilesJobHandler } from '@/main/jobs/upload-files';
import { DownloadFilesJobHandler } from '@/main/jobs/download-files';
import { CleanDeletedFilesJobHandler } from '@/main/jobs/clean-deleted-files';
import { CleanTempFilesJobHandler } from '@/main/jobs/clean-temp-files';
import { eventBus } from '@/shared/lib/event-bus';
import { Event } from '@/shared/types/events';
@@ -33,6 +34,7 @@ export const jobHandlerMap: JobHandlerMap = {
upload_files: new UploadFilesJobHandler(),
download_files: new DownloadFilesJobHandler(),
clean_deleted_files: new CleanDeletedFilesJobHandler(),
clean_temp_files: new CleanTempFilesJobHandler(),
};
type JobState = {
@@ -271,6 +273,16 @@ class Scheduler {
type: 'download_files',
userId,
});
this.schedule({
type: 'clean_deleted_files',
userId,
});
this.schedule({
type: 'clean_temp_files',
userId,
});
}
private deleteWorkspaceJobs(userId: string) {
@@ -332,6 +344,13 @@ class Scheduler {
return true;
}
if (
state.input.type === 'clean_temp_files' &&
state.input.userId === userId
) {
return true;
}
return false;
}

View File

@@ -20,6 +20,7 @@ import { serverService } from '@/main/services/server-service';
import {
fetchWorkspaceCredentials,
getWorkspaceFilesDirectoryPath,
getWorkspaceTempFilesDirectoryPath,
mapFile,
mapFileInteraction,
mapFileState,
@@ -77,6 +78,15 @@ class FileService {
`Copying file ${filePath} to ${destinationFilePath} for user ${userId}`
);
fs.copyFileSync(filePath, destinationFilePath);
// check if the file is in the temp files directory. If it is in
// temp files directory it means it has been pasted or dragged
// therefore we need to delete it
const fileDirectory = path.dirname(filePath);
const tempFilesDir = getWorkspaceTempFilesDirectoryPath(userId);
if (fileDirectory === tempFilesDir) {
fs.rmSync(filePath);
}
}
public openFile(userId: string, id: string, extension: string): void {
@@ -436,6 +446,32 @@ class FileService {
}
}
public async cleanTempFiles(userId: string): Promise<void> {
this.debug(`Checking temp files for user ${userId}`);
const tempFilesDir = getWorkspaceTempFilesDirectoryPath(userId);
if (!fs.existsSync(tempFilesDir)) {
return;
}
const files = fs.readdirSync(tempFilesDir);
const oneDayAgo = Date.now() - 24 * 60 * 60 * 1000; // 24 hours in milliseconds
for (const file of files) {
const filePath = path.join(tempFilesDir, file);
const stats = fs.statSync(filePath);
if (stats.mtimeMs < oneDayAgo) {
try {
fs.unlinkSync(filePath);
this.debug(`Deleted old temp file: ${filePath}`);
} catch (error) {
this.debug(`Failed to delete temp file: ${filePath}`, error);
}
}
}
}
public async syncServerFile(
userId: string,
file: SyncFileData

View File

@@ -56,6 +56,10 @@ export const getWorkspaceFilesDirectoryPath = (userId: string): string => {
return path.join(getWorkspaceDirectoryPath(userId), 'files');
};
export const getWorkspaceTempFilesDirectoryPath = (userId: string): string => {
return path.join(getWorkspaceDirectoryPath(userId), 'temp');
};
export const getAccountAvatarsDirectoryPath = (accountId: string): string => {
return path.join(appPath, 'avatars', accountId);
};

View File

@@ -108,7 +108,13 @@ export const DocumentEditor = ({
DocumentNode,
PageNode,
FolderNode,
FileNode,
FileNode.configure({
context: {
userId: workspace.userId,
documentId,
rootId,
},
}),
TextNode,
ParagraphNode,
Heading1Node,

View File

@@ -175,6 +175,7 @@ export const MessageCreate = React.forwardRef<MessageCreateRefProps>(
<div className="max-h-72 flex-grow overflow-y-auto">
{conversation.canCreateMessage ? (
<MessageEditor
userId={workspace.userId}
ref={messageEditorRef}
conversationId={conversation.id}
onChange={setContent}

View File

@@ -29,6 +29,7 @@ import { EditorBubbleMenu } from '@/renderer/editor/menu/bubble-menu';
import { FileMetadata } from '@/shared/types/files';
interface MessageEditorProps {
userId: string;
conversationId: string;
onChange?: (content: JSONContent) => void;
onSubmit: () => void;
@@ -67,7 +68,9 @@ export const MessageEditor = React.forwardRef<
HighlightMark,
LinkMark,
DropcursorExtension,
FilePlaceholderNode,
FilePlaceholderNode.configure({
userId: props.userId,
}),
FileNode,
],
editorProps: {

View File

@@ -10,11 +10,11 @@ import React from 'react';
import tippy from 'tippy.js';
import { updateScrollView } from '@/shared/lib/utils';
import { EditorCommand,EditorCommandContext } from '@/shared/types/editor';
import { EditorCommand, EditorContext } from '@/shared/types/editor';
interface CommanderOptions {
commands: EditorCommand[];
context: EditorCommandContext | null;
context: EditorContext | null;
}
const navigationKeys = ['ArrowUp', 'ArrowDown', 'Enter'];
@@ -188,7 +188,7 @@ export const CommanderExtension = Extension.create<CommanderOptions>({
addOptions() {
return {
commands: [],
context: {} as EditorCommandContext,
context: {} as EditorContext,
};
},
addProseMirrorPlugins() {

View File

@@ -1,9 +1,11 @@
import { generateId, IdType } from '@colanode/core';
import { CommandProps,mergeAttributes, Node } from '@tiptap/core';
import { CommandProps, mergeAttributes, Node } from '@tiptap/core';
import { ReactNodeViewRenderer } from '@tiptap/react';
import { Plugin, PluginKey } from '@tiptap/pm/state';
import { FilePlaceholderNodeView } from '@/renderer/editor/views';
import { FileMetadata } from '@/shared/types/files';
import { toast } from '@/renderer/hooks/use-toast';
declare module '@tiptap/core' {
interface Commands<ReturnType> {
@@ -13,7 +15,11 @@ declare module '@tiptap/core' {
}
}
export const FilePlaceholderNode = Node.create({
interface FilePlaceholderOptions {
userId: string;
}
export const FilePlaceholderNode = Node.create<FilePlaceholderOptions>({
name: 'filePlaceholder',
group: 'block',
atom: true,
@@ -68,4 +74,64 @@ export const FilePlaceholderNode = Node.create({
},
};
},
addProseMirrorPlugins() {
const editor = this.editor;
const options = this.options;
return [
new Plugin({
key: new PluginKey('file-placeholder-paste'),
props: {
handlePaste(_, event) {
const files = Array.from(event.clipboardData?.files || []);
if (files.length == 0) {
return false;
}
(async () => {
for (const file of files) {
const buffer = await file.arrayBuffer();
const fileSaveResult = await window.colanode.executeMutation({
type: 'file_save_temp',
name: file.name,
buffer,
userId: options.userId,
});
if (!fileSaveResult.success) {
toast({
variant: 'destructive',
title: 'Failed to add file',
description: fileSaveResult.error.message,
});
return;
}
const path = fileSaveResult.output.path;
const fileMetadata = await window.colanode.executeQuery({
type: 'file_metadata_get',
path: path,
});
if (fileMetadata === null) {
toast({
title: 'Failed to add file',
description:
'Something went wrong adding file. Please try again!',
variant: 'destructive',
});
return;
}
editor.chain().focus().addFilePlaceholder(fileMetadata).run();
}
})();
return true;
},
},
}),
];
},
});

View File

@@ -1,9 +1,27 @@
import { mergeAttributes, Node } from '@tiptap/core';
import { ReactNodeViewRenderer } from '@tiptap/react';
import { Plugin, PluginKey } from '@tiptap/pm/state';
import { FileNodeView } from '@/renderer/editor/views';
import { EditorContext } from '@/shared/types/editor';
import { toast } from '@/renderer/hooks/use-toast';
export const FileNode = Node.create({
interface FileNodeOptions {
context: EditorContext;
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
file: {
/**
* Insert a file
*/
addFile: (path: string) => ReturnType;
};
}
}
export const FileNode = Node.create<FileNodeOptions>({
name: 'file',
group: 'block',
atom: true,
@@ -24,4 +42,114 @@ export const FileNode = Node.create({
as: 'file',
});
},
addCommands() {
const options = this.options;
return {
addFile: (path: string) => {
return ({ editor, tr }) => {
(async () => {
const fileCreateResult = await window.colanode.executeMutation({
type: 'file_create',
entryId: options.context.documentId,
rootId: options.context.rootId,
filePath: path,
userId: options.context.userId,
parentId: options.context.documentId,
});
if (!fileCreateResult.success) {
toast({
variant: 'destructive',
title: 'Failed to add file',
description: fileCreateResult.error.message,
});
return;
}
const fileId = fileCreateResult.output.id;
const range = tr.selection.ranges[0];
if (range) {
editor
.chain()
.focus()
.deleteRange({
from: range.$from.pos,
to: range.$to.pos,
})
.insertContent({
type: 'file',
attrs: {
id: fileId,
},
})
.run();
} else {
editor
.chain()
.focus()
.insertContent({
type: 'file',
attrs: {
id: fileId,
},
})
.run();
}
})();
return true;
};
},
};
},
addProseMirrorPlugins() {
const editor = this.editor;
const options = this.options;
if (!options.context) {
return [];
}
return [
new Plugin({
key: new PluginKey('file-paste'),
props: {
handlePaste(_, event) {
const files = Array.from(event.clipboardData?.files || []);
if (files.length == 0) {
return false;
}
(async () => {
for (const file of files) {
const buffer = await file.arrayBuffer();
const fileSaveResult = await window.colanode.executeMutation({
type: 'file_save_temp',
name: file.name,
buffer,
userId: options.context.userId,
});
if (!fileSaveResult.success) {
toast({
variant: 'destructive',
title: 'Failed to add file',
description: fileSaveResult.error.message,
});
return;
}
const path = fileSaveResult.output.path;
editor.chain().focus().addFile(path).run();
}
})();
return true;
},
},
}),
];
},
});

View File

@@ -0,0 +1,19 @@
export type FileSaveTempMutationInput = {
type: 'file_save_temp';
userId: string;
name: string;
buffer: ArrayBuffer;
};
export type FileSaveTempMutationOutput = {
path: string;
};
declare module '@/shared/mutations' {
interface MutationMap {
file_save_temp: {
input: FileSaveTempMutationInput;
output: FileSaveTempMutationOutput;
};
}
}

View File

@@ -4,10 +4,10 @@ import { FC } from 'react';
export type EditorCommandProps = {
editor: Editor;
range: Range;
context: EditorCommandContext | null;
context: EditorContext | null;
};
export type EditorCommandContext = {
export type EditorContext = {
documentId: string;
userId: string;
rootId: string;