[WIKI-181] refactor: make file handling generic in editor (#7046)
* refactor: make file handling generic * fix: useeffect dependency array * chore: remove mime type to extension conversion
This commit is contained in:
parent
e68d344410
commit
dc16f2862e
17 changed files with 334 additions and 223 deletions
|
|
@ -1,9 +1,10 @@
|
||||||
import { Extensions } from "@tiptap/core";
|
import { Extensions } from "@tiptap/core";
|
||||||
// types
|
// types
|
||||||
import { TExtensions } from "@/types";
|
import { TExtensions, TFileHandler } from "@/types";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
disabledExtensions: TExtensions[];
|
disabledExtensions: TExtensions[];
|
||||||
|
fileHandler: TFileHandler;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CoreEditorAdditionalExtensions = (props: Props): Extensions => {
|
export const CoreEditorAdditionalExtensions = (props: Props): Extensions => {
|
||||||
|
|
|
||||||
|
|
@ -8,5 +8,55 @@ export const DEFAULT_DISPLAY_CONFIG: TDisplayConfig = {
|
||||||
wideLayout: false,
|
wideLayout: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ACCEPTED_FILE_MIME_TYPES = ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/gif"];
|
export const ACCEPTED_IMAGE_MIME_TYPES = ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/gif"];
|
||||||
export const ACCEPTED_FILE_EXTENSIONS = ACCEPTED_FILE_MIME_TYPES.map((type) => `.${type.split("/")[1]}`);
|
|
||||||
|
export const ACCEPTED_ATTACHMENT_MIME_TYPES = [
|
||||||
|
"image/jpeg",
|
||||||
|
"image/png",
|
||||||
|
"image/gif",
|
||||||
|
"image/svg+xml",
|
||||||
|
"image/webp",
|
||||||
|
"image/tiff",
|
||||||
|
"image/bmp",
|
||||||
|
"application/pdf",
|
||||||
|
"application/msword",
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
"application/vnd.ms-excel",
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
"application/vnd.ms-powerpoint",
|
||||||
|
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||||
|
"text/plain",
|
||||||
|
"application/rtf",
|
||||||
|
"audio/mpeg",
|
||||||
|
"audio/wav",
|
||||||
|
"audio/ogg",
|
||||||
|
"audio/midi",
|
||||||
|
"audio/x-midi",
|
||||||
|
"audio/aac",
|
||||||
|
"audio/flac",
|
||||||
|
"audio/x-m4a",
|
||||||
|
"video/mp4",
|
||||||
|
"video/mpeg",
|
||||||
|
"video/ogg",
|
||||||
|
"video/webm",
|
||||||
|
"video/quicktime",
|
||||||
|
"video/x-msvideo",
|
||||||
|
"video/x-ms-wmv",
|
||||||
|
"application/zip",
|
||||||
|
"application/x-rar-compressed",
|
||||||
|
"application/x-tar",
|
||||||
|
"application/gzip",
|
||||||
|
"model/gltf-binary",
|
||||||
|
"model/gltf+json",
|
||||||
|
"application/octet-stream",
|
||||||
|
"font/ttf",
|
||||||
|
"font/otf",
|
||||||
|
"font/woff",
|
||||||
|
"font/woff2",
|
||||||
|
"text/css",
|
||||||
|
"text/javascript",
|
||||||
|
"application/json",
|
||||||
|
"text/xml",
|
||||||
|
"text/csv",
|
||||||
|
"application/xml",
|
||||||
|
];
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,11 @@ import { ChangeEvent, useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
// plane utils
|
// plane utils
|
||||||
import { cn } from "@plane/utils";
|
import { cn } from "@plane/utils";
|
||||||
// constants
|
// constants
|
||||||
import { ACCEPTED_FILE_EXTENSIONS } from "@/constants/config";
|
import { ACCEPTED_IMAGE_MIME_TYPES } from "@/constants/config";
|
||||||
// extensions
|
// extensions
|
||||||
import { CustoBaseImageNodeViewProps, getImageComponentImageFileMap } from "@/extensions/custom-image";
|
import { CustoBaseImageNodeViewProps, getImageComponentImageFileMap } from "@/extensions/custom-image";
|
||||||
// hooks
|
// hooks
|
||||||
import { useUploader, useDropZone, uploadFirstImageAndInsertRemaining } from "@/hooks/use-file-upload";
|
import { useUploader, useDropZone, uploadFirstFileAndInsertRemaining } from "@/hooks/use-file-upload";
|
||||||
|
|
||||||
type CustomImageUploaderProps = CustoBaseImageNodeViewProps & {
|
type CustomImageUploaderProps = CustoBaseImageNodeViewProps & {
|
||||||
maxFileSize: number;
|
maxFileSize: number;
|
||||||
|
|
@ -41,7 +41,9 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
|
||||||
if (!imageEntityId) return;
|
if (!imageEntityId) return;
|
||||||
setIsUploaded(true);
|
setIsUploaded(true);
|
||||||
// Update the node view's src attribute post upload
|
// Update the node view's src attribute post upload
|
||||||
updateAttributes({ src: url });
|
updateAttributes({
|
||||||
|
src: url,
|
||||||
|
});
|
||||||
imageComponentImageFileMap?.delete(imageEntityId);
|
imageComponentImageFileMap?.delete(imageEntityId);
|
||||||
|
|
||||||
const pos = getPos();
|
const pos = getPos();
|
||||||
|
|
@ -51,7 +53,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
|
||||||
|
|
||||||
// only if the cursor is at the current image component, manipulate
|
// only if the cursor is at the current image component, manipulate
|
||||||
// the cursor position
|
// the cursor position
|
||||||
if (currentNode && currentNode.type.name === "imageComponent" && currentNode.attrs.src === url) {
|
if (currentNode && currentNode.type.name === node.type.name && currentNode.attrs.src === url) {
|
||||||
// control cursor position after upload
|
// control cursor position after upload
|
||||||
const nextNode = editor.state.doc.nodeAt(pos + 1);
|
const nextNode = editor.state.doc.nodeAt(pos + 1);
|
||||||
|
|
||||||
|
|
@ -68,17 +70,23 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
|
||||||
[imageComponentImageFileMap, imageEntityId, updateAttributes, getPos]
|
[imageComponentImageFileMap, imageEntityId, updateAttributes, getPos]
|
||||||
);
|
);
|
||||||
// hooks
|
// hooks
|
||||||
const { uploading: isImageBeingUploaded, uploadFile } = useUploader({
|
const { isUploading: isImageBeingUploaded, uploadFile } = useUploader({
|
||||||
blockId: imageEntityId ?? "",
|
acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES,
|
||||||
editor,
|
// @ts-expect-error - TODO: fix typings, and don't remove await from here for now
|
||||||
loadImageFromFileSystem,
|
editorCommand: async (file) => await editor?.commands.uploadImage(imageEntityId, file),
|
||||||
|
handleProgressStatus: (isUploading) => {
|
||||||
|
editor.storage.imageComponent.uploadInProgress = isUploading;
|
||||||
|
},
|
||||||
|
loadFileFromFileSystem: loadImageFromFileSystem,
|
||||||
maxFileSize,
|
maxFileSize,
|
||||||
onUpload,
|
onUpload,
|
||||||
});
|
});
|
||||||
const { draggedInside, onDrop, onDragEnter, onDragLeave } = useDropZone({
|
const { draggedInside, onDrop, onDragEnter, onDragLeave } = useDropZone({
|
||||||
|
acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES,
|
||||||
editor,
|
editor,
|
||||||
maxFileSize,
|
maxFileSize,
|
||||||
pos: getPos(),
|
pos: getPos(),
|
||||||
|
type: "image",
|
||||||
uploader: uploadFile,
|
uploader: uploadFile,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -110,11 +118,13 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
|
||||||
if (!filesList) {
|
if (!filesList) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await uploadFirstImageAndInsertRemaining({
|
await uploadFirstFileAndInsertRemaining({
|
||||||
|
acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES,
|
||||||
editor,
|
editor,
|
||||||
filesList,
|
filesList,
|
||||||
maxFileSize,
|
maxFileSize,
|
||||||
pos: getPos(),
|
pos: getPos(),
|
||||||
|
type: "image",
|
||||||
uploader: uploadFile,
|
uploader: uploadFile,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
@ -170,7 +180,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
hidden
|
hidden
|
||||||
type="file"
|
type="file"
|
||||||
accept={ACCEPTED_FILE_EXTENSIONS.join(",")}
|
accept={ACCEPTED_IMAGE_MIME_TYPES.join(",")}
|
||||||
onChange={onFileChange}
|
onChange={onFileChange}
|
||||||
multiple
|
multiple
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,15 @@ import { Editor, mergeAttributes } from "@tiptap/core";
|
||||||
import { Image } from "@tiptap/extension-image";
|
import { Image } from "@tiptap/extension-image";
|
||||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
// constants
|
||||||
|
import { ACCEPTED_IMAGE_MIME_TYPES } from "@/constants/config";
|
||||||
// extensions
|
// extensions
|
||||||
import { CustomImageNode } from "@/extensions/custom-image";
|
import { CustomImageNode } from "@/extensions/custom-image";
|
||||||
// helpers
|
// helpers
|
||||||
|
import { isFileValid } from "@/helpers/file";
|
||||||
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
|
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
|
||||||
// plugins
|
// plugins
|
||||||
import { TrackImageDeletionPlugin, TrackImageRestorationPlugin, isFileValid } from "@/plugins/image";
|
import { TrackImageDeletionPlugin, TrackImageRestorationPlugin } from "@/plugins/image";
|
||||||
// types
|
// types
|
||||||
import { TFileHandler } from "@/types";
|
import { TFileHandler } from "@/types";
|
||||||
|
|
||||||
|
|
@ -146,6 +149,7 @@ export const CustomImageExtension = (props: TFileHandler) => {
|
||||||
if (
|
if (
|
||||||
props?.file &&
|
props?.file &&
|
||||||
!isFileValid({
|
!isFileValid({
|
||||||
|
acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES,
|
||||||
file: props.file,
|
file: props.file,
|
||||||
maxFileSize,
|
maxFileSize,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
127
packages/editor/src/core/extensions/drop.ts
Normal file
127
packages/editor/src/core/extensions/drop.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
import { Extension, Editor } from "@tiptap/core";
|
||||||
|
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||||
|
// constants
|
||||||
|
import { ACCEPTED_ATTACHMENT_MIME_TYPES, ACCEPTED_IMAGE_MIME_TYPES } from "@/constants/config";
|
||||||
|
// types
|
||||||
|
import { TEditorCommands } from "@/types";
|
||||||
|
|
||||||
|
export const DropHandlerExtension = Extension.create({
|
||||||
|
name: "dropHandler",
|
||||||
|
priority: 1000,
|
||||||
|
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
const editor = this.editor;
|
||||||
|
return [
|
||||||
|
new Plugin({
|
||||||
|
key: new PluginKey("drop-handler-plugin"),
|
||||||
|
props: {
|
||||||
|
handlePaste: (view, event) => {
|
||||||
|
if (
|
||||||
|
editor.isEditable &&
|
||||||
|
event.clipboardData &&
|
||||||
|
event.clipboardData.files &&
|
||||||
|
event.clipboardData.files.length > 0
|
||||||
|
) {
|
||||||
|
event.preventDefault();
|
||||||
|
const files = Array.from(event.clipboardData.files);
|
||||||
|
const acceptedFiles = files.filter(
|
||||||
|
(f) => ACCEPTED_IMAGE_MIME_TYPES.includes(f.type) || ACCEPTED_ATTACHMENT_MIME_TYPES.includes(f.type)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (acceptedFiles.length) {
|
||||||
|
const pos = view.state.selection.from;
|
||||||
|
insertFilesSafely({
|
||||||
|
editor,
|
||||||
|
files: acceptedFiles,
|
||||||
|
initialPos: pos,
|
||||||
|
event: "drop",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
handleDrop: (view, event, _slice, moved) => {
|
||||||
|
if (
|
||||||
|
editor.isEditable &&
|
||||||
|
!moved &&
|
||||||
|
event.dataTransfer &&
|
||||||
|
event.dataTransfer.files &&
|
||||||
|
event.dataTransfer.files.length > 0
|
||||||
|
) {
|
||||||
|
event.preventDefault();
|
||||||
|
const files = Array.from(event.dataTransfer.files);
|
||||||
|
const acceptedFiles = files.filter(
|
||||||
|
(f) => ACCEPTED_IMAGE_MIME_TYPES.includes(f.type) || ACCEPTED_ATTACHMENT_MIME_TYPES.includes(f.type)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (acceptedFiles.length) {
|
||||||
|
const coordinates = view.posAtCoords({
|
||||||
|
left: event.clientX,
|
||||||
|
top: event.clientY,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (coordinates) {
|
||||||
|
const pos = coordinates.pos;
|
||||||
|
insertFilesSafely({
|
||||||
|
editor,
|
||||||
|
files: acceptedFiles,
|
||||||
|
initialPos: pos,
|
||||||
|
event: "drop",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
type InsertFilesSafelyArgs = {
|
||||||
|
editor: Editor;
|
||||||
|
event: "insert" | "drop";
|
||||||
|
files: File[];
|
||||||
|
initialPos: number;
|
||||||
|
type?: Extract<TEditorCommands, "attachment" | "image">;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const insertFilesSafely = async (args: InsertFilesSafelyArgs) => {
|
||||||
|
const { editor, event, files, initialPos, type } = args;
|
||||||
|
let pos = initialPos;
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
// safe insertion
|
||||||
|
const docSize = editor.state.doc.content.size;
|
||||||
|
pos = Math.min(pos, docSize);
|
||||||
|
|
||||||
|
let fileType: "image" | "attachment" | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (type) {
|
||||||
|
if (["image", "attachment"].includes(type)) fileType = type;
|
||||||
|
else throw new Error("Wrong file type passed");
|
||||||
|
} else {
|
||||||
|
if (ACCEPTED_IMAGE_MIME_TYPES.includes(file.type)) fileType = "image";
|
||||||
|
else if (ACCEPTED_ATTACHMENT_MIME_TYPES.includes(file.type)) fileType = "attachment";
|
||||||
|
}
|
||||||
|
// insert file depending on the type at the current position
|
||||||
|
if (fileType === "image") {
|
||||||
|
editor.commands.insertImageComponent({
|
||||||
|
file,
|
||||||
|
pos,
|
||||||
|
event,
|
||||||
|
});
|
||||||
|
} else if (fileType === "attachment") {
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error while ${event}ing file:`, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move to the next position
|
||||||
|
pos += 1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -1,94 +0,0 @@
|
||||||
import { Extension, Editor } from "@tiptap/core";
|
|
||||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
|
||||||
import { EditorView } from "@tiptap/pm/view";
|
|
||||||
|
|
||||||
export const DropHandlerExtension = Extension.create({
|
|
||||||
name: "dropHandler",
|
|
||||||
priority: 1000,
|
|
||||||
|
|
||||||
addProseMirrorPlugins() {
|
|
||||||
const editor = this.editor;
|
|
||||||
return [
|
|
||||||
new Plugin({
|
|
||||||
key: new PluginKey("drop-handler-plugin"),
|
|
||||||
props: {
|
|
||||||
handlePaste: (view: EditorView, event: ClipboardEvent) => {
|
|
||||||
if (
|
|
||||||
editor.isEditable &&
|
|
||||||
event.clipboardData &&
|
|
||||||
event.clipboardData.files &&
|
|
||||||
event.clipboardData.files.length > 0
|
|
||||||
) {
|
|
||||||
event.preventDefault();
|
|
||||||
const files = Array.from(event.clipboardData.files);
|
|
||||||
const imageFiles = files.filter((file) => file.type.startsWith("image"));
|
|
||||||
|
|
||||||
if (imageFiles.length > 0) {
|
|
||||||
const pos = view.state.selection.from;
|
|
||||||
insertImagesSafely({ editor, files: imageFiles, initialPos: pos, event: "drop" });
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
handleDrop: (view: EditorView, event: DragEvent, _slice: any, moved: boolean) => {
|
|
||||||
if (
|
|
||||||
editor.isEditable &&
|
|
||||||
!moved &&
|
|
||||||
event.dataTransfer &&
|
|
||||||
event.dataTransfer.files &&
|
|
||||||
event.dataTransfer.files.length > 0
|
|
||||||
) {
|
|
||||||
event.preventDefault();
|
|
||||||
const files = Array.from(event.dataTransfer.files);
|
|
||||||
const imageFiles = files.filter((file) => file.type.startsWith("image"));
|
|
||||||
|
|
||||||
if (imageFiles.length > 0) {
|
|
||||||
const coordinates = view.posAtCoords({
|
|
||||||
left: event.clientX,
|
|
||||||
top: event.clientY,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (coordinates) {
|
|
||||||
const pos = coordinates.pos;
|
|
||||||
insertImagesSafely({ editor, files: imageFiles, initialPos: pos, event: "drop" });
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
},
|
|
||||||
});
|
|
||||||
export const insertImagesSafely = async ({
|
|
||||||
editor,
|
|
||||||
files,
|
|
||||||
initialPos,
|
|
||||||
event,
|
|
||||||
}: {
|
|
||||||
editor: Editor;
|
|
||||||
files: File[];
|
|
||||||
initialPos: number;
|
|
||||||
event: "insert" | "drop";
|
|
||||||
}) => {
|
|
||||||
let pos = initialPos;
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
// safe insertion
|
|
||||||
const docSize = editor.state.doc.content.size;
|
|
||||||
pos = Math.min(pos, docSize);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Insert the image at the current position
|
|
||||||
editor.commands.insertImageComponent({ file, pos, event });
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error while ${event}ing image:`, error);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Move to the next position
|
|
||||||
pos += 1;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -172,6 +172,7 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
||||||
CustomColorExtension,
|
CustomColorExtension,
|
||||||
...CoreEditorAdditionalExtensions({
|
...CoreEditorAdditionalExtensions({
|
||||||
disabledExtensions,
|
disabledExtensions,
|
||||||
|
fileHandler,
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { Editor, Range } from "@tiptap/core";
|
import { Editor, Range } from "@tiptap/core";
|
||||||
// types
|
|
||||||
import { InsertImageComponentProps } from "@/extensions";
|
|
||||||
// extensions
|
// extensions
|
||||||
|
import { InsertImageComponentProps } from "@/extensions";
|
||||||
import { replaceCodeWithText } from "@/extensions/code/utils/replace-code-block-with-text";
|
import { replaceCodeWithText } from "@/extensions/code/utils/replace-code-block-with-text";
|
||||||
// helpers
|
// helpers
|
||||||
import { findTableAncestor } from "@/helpers/common";
|
import { findTableAncestor } from "@/helpers/common";
|
||||||
|
|
@ -206,6 +205,7 @@ export const insertHorizontalRule = (editor: Editor, range?: Range) => {
|
||||||
if (range) editor.chain().focus().deleteRange(range).setHorizontalRule().run();
|
if (range) editor.chain().focus().deleteRange(range).setHorizontalRule().run();
|
||||||
else editor.chain().focus().setHorizontalRule().run();
|
else editor.chain().focus().setHorizontalRule().run();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const insertCallout = (editor: Editor, range?: Range) => {
|
export const insertCallout = (editor: Editor, range?: Range) => {
|
||||||
if (range) editor.chain().focus().deleteRange(range).insertCallout().run();
|
if (range) editor.chain().focus().deleteRange(range).insertCallout().run();
|
||||||
else editor.chain().focus().insertCallout().run();
|
else editor.chain().focus().insertCallout().run();
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,18 @@
|
||||||
// constants
|
|
||||||
import { ACCEPTED_FILE_MIME_TYPES } from "@/constants/config";
|
|
||||||
|
|
||||||
type TArgs = {
|
type TArgs = {
|
||||||
|
acceptedMimeTypes: string[];
|
||||||
file: File;
|
file: File;
|
||||||
maxFileSize: number;
|
maxFileSize: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isFileValid = (args: TArgs): boolean => {
|
export const isFileValid = (args: TArgs): boolean => {
|
||||||
const { file, maxFileSize } = args;
|
const { acceptedMimeTypes, file, maxFileSize } = args;
|
||||||
|
|
||||||
if (!file) {
|
if (!file) {
|
||||||
alert("No file selected. Please select a file to upload.");
|
alert("No file selected. Please select a file to upload.");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ACCEPTED_FILE_MIME_TYPES.includes(file.type)) {
|
if (!acceptedMimeTypes.includes(file.type)) {
|
||||||
alert("Invalid file type. Please select a JPEG, JPG, PNG, WEBP or GIF file.");
|
alert("Invalid file type. Please select a JPEG, JPG, PNG, WEBP or GIF file.");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -1,87 +1,87 @@
|
||||||
import { DragEvent, useCallback, useEffect, useState } from "react";
|
|
||||||
import { Editor } from "@tiptap/core";
|
import { Editor } from "@tiptap/core";
|
||||||
|
import { DragEvent, useCallback, useEffect, useState } from "react";
|
||||||
// extensions
|
// extensions
|
||||||
import { insertImagesSafely } from "@/extensions/drop";
|
import { insertFilesSafely } from "@/extensions/drop";
|
||||||
// plugins
|
// plugins
|
||||||
import { isFileValid } from "@/plugins/image";
|
import { isFileValid } from "@/helpers/file";
|
||||||
|
// types
|
||||||
|
import { TEditorCommands } from "@/types";
|
||||||
|
|
||||||
type TUploaderArgs = {
|
type TUploaderArgs = {
|
||||||
blockId: string;
|
acceptedMimeTypes: string[];
|
||||||
editor: Editor;
|
editorCommand: (file: File) => Promise<string>;
|
||||||
loadImageFromFileSystem: (file: string) => void;
|
handleProgressStatus?: (isUploading: boolean) => void;
|
||||||
|
loadFileFromFileSystem?: (file: string) => void;
|
||||||
maxFileSize: number;
|
maxFileSize: number;
|
||||||
onUpload: (url: string) => void;
|
onUpload: (url: string, file: File) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useUploader = (args: TUploaderArgs) => {
|
export const useUploader = (args: TUploaderArgs) => {
|
||||||
const { blockId, editor, loadImageFromFileSystem, maxFileSize, onUpload } = args;
|
const { acceptedMimeTypes, editorCommand, handleProgressStatus, loadFileFromFileSystem, maxFileSize, onUpload } =
|
||||||
|
args;
|
||||||
// states
|
// states
|
||||||
const [uploading, setUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
|
||||||
const uploadFile = useCallback(
|
const uploadFile = useCallback(
|
||||||
async (file: File) => {
|
async (file: File) => {
|
||||||
const setImageUploadInProgress = (isUploading: boolean) => {
|
handleProgressStatus?.(true);
|
||||||
if (editor.storage.imageComponent) {
|
setIsUploading(true);
|
||||||
editor.storage.imageComponent.uploadInProgress = isUploading;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
setImageUploadInProgress(true);
|
|
||||||
setUploading(true);
|
|
||||||
const fileNameTrimmed = trimFileName(file.name);
|
|
||||||
const fileWithTrimmedName = new File([file], fileNameTrimmed, { type: file.type });
|
|
||||||
const isValid = isFileValid({
|
const isValid = isFileValid({
|
||||||
file: fileWithTrimmedName,
|
acceptedMimeTypes,
|
||||||
|
file,
|
||||||
maxFileSize,
|
maxFileSize,
|
||||||
});
|
});
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
setImageUploadInProgress(false);
|
handleProgressStatus?.(false);
|
||||||
|
setIsUploading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const reader = new FileReader();
|
if (loadFileFromFileSystem) {
|
||||||
reader.onload = () => {
|
const reader = new FileReader();
|
||||||
if (reader.result) {
|
reader.onload = () => {
|
||||||
loadImageFromFileSystem(reader.result as string);
|
if (reader.result) {
|
||||||
} else {
|
loadFileFromFileSystem(reader.result as string);
|
||||||
console.error("Failed to read the file: reader.result is null");
|
} else {
|
||||||
}
|
console.error("Failed to read the file: reader.result is null");
|
||||||
};
|
}
|
||||||
reader.onerror = () => {
|
};
|
||||||
console.error("Error reading file");
|
reader.onerror = () => {
|
||||||
};
|
console.error("Error reading file");
|
||||||
reader.readAsDataURL(fileWithTrimmedName);
|
};
|
||||||
// @ts-expect-error - TODO: fix typings, and don't remove await from
|
reader.readAsDataURL(file);
|
||||||
// here for now
|
}
|
||||||
const url: string = await editor?.commands.uploadImage(blockId, fileWithTrimmedName);
|
const url: string = await editorCommand(file);
|
||||||
|
|
||||||
if (!url) {
|
if (!url) {
|
||||||
throw new Error("Something went wrong while uploading the image");
|
throw new Error("Something went wrong while uploading the file.");
|
||||||
}
|
}
|
||||||
onUpload(url);
|
onUpload(url, file);
|
||||||
} catch (errPayload: any) {
|
} catch (errPayload) {
|
||||||
console.log(errPayload);
|
|
||||||
const error = errPayload?.response?.data?.error || "Something went wrong";
|
const error = errPayload?.response?.data?.error || "Something went wrong";
|
||||||
console.error(error);
|
console.error(error);
|
||||||
} finally {
|
} finally {
|
||||||
setImageUploadInProgress(false);
|
handleProgressStatus?.(false);
|
||||||
setUploading(false);
|
setIsUploading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onUpload]
|
[acceptedMimeTypes, editorCommand, handleProgressStatus, loadFileFromFileSystem, maxFileSize, onUpload]
|
||||||
);
|
);
|
||||||
|
|
||||||
return { uploading, uploadFile };
|
return { isUploading, uploadFile };
|
||||||
};
|
};
|
||||||
|
|
||||||
type TDropzoneArgs = {
|
type TDropzoneArgs = {
|
||||||
|
acceptedMimeTypes: string[];
|
||||||
editor: Editor;
|
editor: Editor;
|
||||||
maxFileSize: number;
|
maxFileSize: number;
|
||||||
pos: number;
|
pos: number;
|
||||||
|
type: Extract<TEditorCommands, "attachment" | "image">;
|
||||||
uploader: (file: File) => Promise<void>;
|
uploader: (file: File) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useDropZone = (args: TDropzoneArgs) => {
|
export const useDropZone = (args: TDropzoneArgs) => {
|
||||||
const { editor, maxFileSize, pos, uploader } = args;
|
const { acceptedMimeTypes, editor, maxFileSize, pos, type, uploader } = args;
|
||||||
// states
|
// states
|
||||||
const [isDragging, setIsDragging] = useState<boolean>(false);
|
const [isDragging, setIsDragging] = useState<boolean>(false);
|
||||||
const [draggedInside, setDraggedInside] = useState<boolean>(false);
|
const [draggedInside, setDraggedInside] = useState<boolean>(false);
|
||||||
|
|
@ -112,83 +112,79 @@ export const useDropZone = (args: TDropzoneArgs) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const filesList = e.dataTransfer.files;
|
const filesList = e.dataTransfer.files;
|
||||||
await uploadFirstImageAndInsertRemaining({
|
await uploadFirstFileAndInsertRemaining({
|
||||||
|
acceptedMimeTypes,
|
||||||
editor,
|
editor,
|
||||||
filesList,
|
filesList,
|
||||||
maxFileSize,
|
maxFileSize,
|
||||||
pos,
|
pos,
|
||||||
|
type,
|
||||||
uploader,
|
uploader,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[uploader, editor, pos]
|
[acceptedMimeTypes, editor, maxFileSize, pos, type, uploader]
|
||||||
);
|
);
|
||||||
|
const onDragEnter = useCallback(() => setDraggedInside(true), []);
|
||||||
|
const onDragLeave = useCallback(() => setDraggedInside(false), []);
|
||||||
|
|
||||||
const onDragEnter = () => {
|
return {
|
||||||
setDraggedInside(true);
|
isDragging,
|
||||||
|
draggedInside,
|
||||||
|
onDragEnter,
|
||||||
|
onDragLeave,
|
||||||
|
onDrop,
|
||||||
};
|
};
|
||||||
|
|
||||||
const onDragLeave = () => {
|
|
||||||
setDraggedInside(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return { isDragging, draggedInside, onDragEnter, onDragLeave, onDrop };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function trimFileName(fileName: string, maxLength = 100) {
|
type TMultipleFileArgs = {
|
||||||
if (fileName.length > maxLength) {
|
acceptedMimeTypes: string[];
|
||||||
const extension = fileName.split(".").pop();
|
|
||||||
const nameWithoutExtension = fileName.slice(0, -(extension?.length ?? 0 + 1));
|
|
||||||
const allowedNameLength = maxLength - (extension?.length ?? 0) - 1; // -1 for the dot
|
|
||||||
return `${nameWithoutExtension.slice(0, allowedNameLength)}.${extension}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return fileName;
|
|
||||||
}
|
|
||||||
|
|
||||||
type TMultipleImagesArgs = {
|
|
||||||
editor: Editor;
|
editor: Editor;
|
||||||
filesList: FileList;
|
filesList: FileList;
|
||||||
maxFileSize: number;
|
maxFileSize: number;
|
||||||
pos: number;
|
pos: number;
|
||||||
|
type: Extract<TEditorCommands, "attachment" | "image">;
|
||||||
uploader: (file: File) => Promise<void>;
|
uploader: (file: File) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Upload the first image and insert the remaining images for uploading multiple image
|
// Upload the first file and insert the remaining ones for uploading multiple files
|
||||||
// post insertion of image-component
|
export const uploadFirstFileAndInsertRemaining = async (args: TMultipleFileArgs) => {
|
||||||
export async function uploadFirstImageAndInsertRemaining(args: TMultipleImagesArgs) {
|
const { acceptedMimeTypes, editor, filesList, maxFileSize, pos, type, uploader } = args;
|
||||||
const { editor, filesList, maxFileSize, pos, uploader } = args;
|
|
||||||
const filteredFiles: File[] = [];
|
const filteredFiles: File[] = [];
|
||||||
for (let i = 0; i < filesList.length; i += 1) {
|
for (let i = 0; i < filesList.length; i += 1) {
|
||||||
const item = filesList.item(i);
|
const file = filesList.item(i);
|
||||||
if (
|
if (
|
||||||
item &&
|
file &&
|
||||||
item.type.indexOf("image") !== -1 &&
|
|
||||||
isFileValid({
|
isFileValid({
|
||||||
file: item,
|
acceptedMimeTypes,
|
||||||
|
file,
|
||||||
maxFileSize,
|
maxFileSize,
|
||||||
})
|
})
|
||||||
) {
|
) {
|
||||||
filteredFiles.push(item);
|
filteredFiles.push(file);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (filteredFiles.length !== filesList.length) {
|
if (filteredFiles.length !== filesList.length) {
|
||||||
console.warn("Some files were not images and have been ignored.");
|
console.warn("Some files were invalid and have been ignored.");
|
||||||
}
|
}
|
||||||
if (filteredFiles.length === 0) {
|
if (filteredFiles.length === 0) {
|
||||||
console.error("No image files found to upload");
|
console.error("No files found to upload.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upload the first image
|
// Upload the first file
|
||||||
const firstFile = filteredFiles[0];
|
const firstFile = filteredFiles[0];
|
||||||
uploader(firstFile);
|
uploader(firstFile);
|
||||||
|
// Insert the remaining files
|
||||||
// Insert the remaining images
|
|
||||||
const remainingFiles = filteredFiles.slice(1);
|
const remainingFiles = filteredFiles.slice(1);
|
||||||
|
|
||||||
if (remainingFiles.length > 0) {
|
if (remainingFiles.length > 0) {
|
||||||
const docSize = editor.state.doc.content.size;
|
const docSize = editor.state.doc.content.size;
|
||||||
const posOfNextImageToBeInserted = Math.min(pos + 1, docSize);
|
const posOfNextFileToBeInserted = Math.min(pos + 1, docSize);
|
||||||
insertImagesSafely({ editor, files: remainingFiles, initialPos: posOfNextImageToBeInserted, event: "drop" });
|
insertFilesSafely({
|
||||||
|
editor,
|
||||||
|
files: remainingFiles,
|
||||||
|
initialPos: posOfNextFileToBeInserted,
|
||||||
|
event: "drop",
|
||||||
|
type,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,10 @@ const verticalEllipsisIcon =
|
||||||
|
|
||||||
const generalSelectors = [
|
const generalSelectors = [
|
||||||
"li",
|
"li",
|
||||||
"p:not(:first-child)",
|
"p.editor-paragraph-block:not(:first-child)",
|
||||||
".code-block",
|
".code-block",
|
||||||
"blockquote",
|
"blockquote",
|
||||||
"h1, h2, h3, h4, h5, h6",
|
"h1.editor-heading-block, h2.editor-heading-block, h3.editor-heading-block, h4.editor-heading-block, h5.editor-heading-block, h6.editor-heading-block",
|
||||||
"[data-type=horizontalRule]",
|
"[data-type=horizontalRule]",
|
||||||
".table-wrapper",
|
".table-wrapper",
|
||||||
".issue-embed",
|
".issue-embed",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
import { PluginKey } from "@tiptap/pm/state";
|
|
||||||
|
|
||||||
export const uploadKey = new PluginKey("upload-image");
|
|
||||||
export const deleteKey = new PluginKey("delete-image");
|
|
||||||
export const restoreKey = new PluginKey("restore-image");
|
|
||||||
|
|
||||||
export const IMAGE_NODE_TYPE = "image";
|
|
||||||
|
|
@ -37,20 +37,16 @@ export const TrackImageDeletionPlugin = (editor: Editor, deleteImage: DeleteImag
|
||||||
|
|
||||||
removedImages.forEach(async (node) => {
|
removedImages.forEach(async (node) => {
|
||||||
const src = node.attrs.src;
|
const src = node.attrs.src;
|
||||||
editor.storage[nodeType].deletedImageSet.set(src, true);
|
editor.storage[nodeType].deletedImageSet?.set(src, true);
|
||||||
await onNodeDeleted(src, deleteImage);
|
if (!src) return;
|
||||||
|
try {
|
||||||
|
await deleteImage(src);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error deleting image:", error);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
async function onNodeDeleted(src: string, deleteImage: DeleteImage): Promise<void> {
|
|
||||||
if (!src) return;
|
|
||||||
try {
|
|
||||||
await deleteImage(src);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error deleting image: ", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
export * from "./types";
|
export * from "./types";
|
||||||
export * from "./utils";
|
|
||||||
export * from "./constants";
|
|
||||||
export * from "./delete-image";
|
export * from "./delete-image";
|
||||||
export * from "./restore-image";
|
export * from "./restore-image";
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export * from "./validate-file";
|
|
||||||
32
packages/utils/src/attachment.ts
Normal file
32
packages/utils/src/attachment.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
export const generateFileName = (fileName: string) => {
|
||||||
|
const date = new Date();
|
||||||
|
const timestamp = date.getTime();
|
||||||
|
|
||||||
|
const _fileName = getFileName(fileName);
|
||||||
|
const nameWithoutExtension = _fileName.length > 80 ? _fileName.substring(0, 80) : _fileName;
|
||||||
|
const extension = getFileExtension(fileName);
|
||||||
|
|
||||||
|
return `${nameWithoutExtension}-${timestamp}.${extension}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getFileExtension = (filename: string) => filename.slice(((filename.lastIndexOf(".") - 1) >>> 0) + 2);
|
||||||
|
|
||||||
|
export const getFileName = (fileName: string) => {
|
||||||
|
const dotIndex = fileName.lastIndexOf(".");
|
||||||
|
|
||||||
|
const nameWithoutExtension = fileName.substring(0, dotIndex);
|
||||||
|
|
||||||
|
return nameWithoutExtension;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const convertBytesToSize = (bytes: number) => {
|
||||||
|
let size;
|
||||||
|
|
||||||
|
if (bytes < 1024 * 1024) {
|
||||||
|
size = Math.round(bytes / 1024) + " KB";
|
||||||
|
} else {
|
||||||
|
size = Math.round(bytes / (1024 * 1024)) + " MB";
|
||||||
|
}
|
||||||
|
|
||||||
|
return size;
|
||||||
|
};
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
export * from "./array";
|
export * from "./array";
|
||||||
|
export * from "./attachment";
|
||||||
export * from "./auth";
|
export * from "./auth";
|
||||||
export * from "./datetime";
|
export * from "./datetime";
|
||||||
export * from "./color";
|
export * from "./color";
|
||||||
|
|
@ -16,4 +17,3 @@ export * from "./work-item";
|
||||||
export * from "./get-icon-for-link";
|
export * from "./get-icon-for-link";
|
||||||
|
|
||||||
export * from "./subscription";
|
export * from "./subscription";
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue