mirror of
https://github.com/colanode/colanode.git
synced 2025-12-29 00:25:03 +01:00
Implement file paste handler for editor
This commit is contained in:
26
apps/desktop/src/main/jobs/clean-temp-files.ts
Normal file
26
apps/desktop/src/main/jobs/clean-temp-files.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
46
apps/desktop/src/main/mutations/files/file-save-temp.ts
Normal file
46
apps/desktop/src/main/mutations/files/file-save-temp.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -108,7 +108,13 @@ export const DocumentEditor = ({
|
||||
DocumentNode,
|
||||
PageNode,
|
||||
FolderNode,
|
||||
FileNode,
|
||||
FileNode.configure({
|
||||
context: {
|
||||
userId: workspace.userId,
|
||||
documentId,
|
||||
rootId,
|
||||
},
|
||||
}),
|
||||
TextNode,
|
||||
ParagraphNode,
|
||||
Heading1Node,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
19
apps/desktop/src/shared/mutations/files/file-save-temp.ts
Normal file
19
apps/desktop/src/shared/mutations/files/file-save-temp.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user