"use client"; import * as React from "react"; import type { NodeViewProps } from "@tiptap/react"; import { NodeViewWrapper } from "@tiptap/react"; import { CloseIcon } from "@/components/shared/tiptap/tiptap-icons/close-icon"; import { Button } from "@/components/shared/tiptap/tiptap-ui-primitive/button"; import "@/components/shared/tiptap/tiptap-node/image-upload-node/image-upload-node.scss"; import { focusNextNode, isValidPosition } from "@/lib/tiptap-utils"; export interface FileItem { /** * Unique identifier for the file item */ id: string; /** * The actual File object being uploaded */ file: File; /** * Current upload progress as a percentage (0-100) */ progress: number; /** * Current status of the file upload process * @default "uploading" */ status: "uploading" | "success" | "error"; /** * URL to the uploaded file, available after successful upload * @optional */ url?: string; /** * Controller that can be used to abort the upload process * @optional */ abortController?: AbortController; } export interface UploadOptions { /** * Maximum allowed file size in bytes */ maxSize: number; /** * Maximum number of files that can be uploaded */ limit: number; /** * String specifying acceptable file types (MIME types or extensions) * @example ".jpg,.png,image/jpeg" or "image/*" */ accept: string; /** * Function that handles the actual file upload process * @param {File} file - The file to be uploaded * @param {Function} onProgress - Callback function to report upload progress * @param {AbortSignal} signal - Signal that can be used to abort the upload * @returns {Promise} Promise resolving to the URL of the uploaded file */ upload: ( file: File, onProgress: (event: { progress: number }) => void, signal: AbortSignal, ) => Promise; /** * Callback triggered when a file is uploaded successfully * @param {string} url - URL of the successfully uploaded file * @optional */ onSuccess?: (url: string) => void; /** * Callback triggered when an error occurs during upload * @param {Error} error - The error that occurred * @optional */ onError?: (error: Error) => void; } /** * Custom hook for managing multiple file uploads with progress tracking and cancellation */ function useFileUpload(options: UploadOptions) { const [fileItems, setFileItems] = React.useState([]); const uploadFile = async (file: File): Promise => { if (file.size > options.maxSize) { const error = new Error( `File size exceeds maximum allowed (${options.maxSize / 1024 / 1024}MB)`, ); options.onError?.(error); return null; } const abortController = new AbortController(); const fileId = crypto.randomUUID(); const newFileItem: FileItem = { id: fileId, file, progress: 0, status: "uploading", abortController, }; setFileItems((prev) => [...prev, newFileItem]); try { if (!options.upload) { throw new Error("Upload function is not defined"); } const url = await options.upload( file, (event: { progress: number }) => { setFileItems((prev) => prev.map((item) => item.id === fileId ? { ...item, progress: event.progress } : item, ), ); }, abortController.signal, ); if (!url) throw new Error("Upload failed: No URL returned"); if (!abortController.signal.aborted) { setFileItems((prev) => prev.map((item) => item.id === fileId ? { ...item, status: "success", url, progress: 100 } : item, ), ); options.onSuccess?.(url); return url; } return null; } catch (error) { if (!abortController.signal.aborted) { setFileItems((prev) => prev.map((item) => item.id === fileId ? { ...item, status: "error", progress: 0 } : item, ), ); options.onError?.( error instanceof Error ? error : new Error("Upload failed"), ); } return null; } }; const uploadFiles = async (files: File[]): Promise => { if (!files || files.length === 0) { options.onError?.(new Error("No files to upload")); return []; } if (options.limit && files.length > options.limit) { options.onError?.( new Error( `Maximum ${options.limit} file${options.limit === 1 ? "" : "s"} allowed`, ), ); return []; } // Upload all files concurrently const uploadPromises = files.map((file) => uploadFile(file)); const results = await Promise.all(uploadPromises); // Filter out null results (failed uploads) return results.filter((url): url is string => url !== null); }; const removeFileItem = (fileId: string) => { setFileItems((prev) => { const fileToRemove = prev.find((item) => item.id === fileId); if (fileToRemove?.abortController) { fileToRemove.abortController.abort(); } if (fileToRemove?.url) { URL.revokeObjectURL(fileToRemove.url); } return prev.filter((item) => item.id !== fileId); }); }; const clearAllFiles = () => { fileItems.forEach((item) => { if (item.abortController) { item.abortController.abort(); } if (item.url) { URL.revokeObjectURL(item.url); } }); setFileItems([]); }; return { fileItems, uploadFiles, removeFileItem, clearAllFiles, }; } const CloudUploadIcon: React.FC = () => ( ); const FileIcon: React.FC = () => ( ); const FileCornerIcon: React.FC = () => ( ); interface ImageUploadDragAreaProps { /** * Callback function triggered when files are dropped or selected * @param {File[]} files - Array of File objects that were dropped or selected */ onFile: (files: File[]) => void; /** * Optional child elements to render inside the drag area * @optional * @default undefined */ children?: React.ReactNode; } /** * A component that creates a drag-and-drop area for image uploads */ const ImageUploadDragArea: React.FC = ({ onFile, children, }) => { const [isDragOver, setIsDragOver] = React.useState(false); const [isDragActive, setIsDragActive] = React.useState(false); const handleDragEnter = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragActive(true); }; const handleDragLeave = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); if (!e.currentTarget.contains(e.relatedTarget as Node)) { setIsDragActive(false); setIsDragOver(false); } }; const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragOver(true); }; const handleDrop = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragActive(false); setIsDragOver(false); const files = Array.from(e.dataTransfer.files); if (files.length > 0) { onFile(files); } }; return (
{children}
); }; interface ImageUploadPreviewProps { /** * The file item to preview */ fileItem: FileItem; /** * Callback to remove this file from upload queue */ onRemove: () => void; } /** * Component that displays a preview of an uploading file with progress */ const ImageUploadPreview: React.FC = ({ fileItem, onRemove, }) => { const formatFileSize = (bytes: number) => { if (bytes === 0) return "0 Bytes"; const k = 1024; const sizes = ["Bytes", "KB", "MB", "GB"]; const i = Math.floor(Math.log(bytes) / Math.log(k)); return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`; }; return (
{fileItem.status === "uploading" && (
)}
{fileItem.file.name} {formatFileSize(fileItem.file.size)}
{fileItem.status === "uploading" && ( {fileItem.progress}% )}
); }; const DropZoneContent: React.FC<{ maxSize: number; limit: number }> = ({ maxSize, limit, }) => ( <>
Click to upload or drag and drop Maximum {limit} file{limit === 1 ? "" : "s"}, {maxSize / 1024 / 1024}MB each.
); export const ImageUploadNode: React.FC = (props) => { const { accept, limit, maxSize } = props.node.attrs; const inputRef = React.useRef(null); const extension = props.extension; const uploadOptions: UploadOptions = { maxSize, limit, accept, upload: extension.options.upload, onSuccess: extension.options.onSuccess, onError: extension.options.onError, }; const { fileItems, uploadFiles, removeFileItem, clearAllFiles } = useFileUpload(uploadOptions); const handleUpload = async (files: File[]) => { const urls = await uploadFiles(files); if (urls.length > 0) { const pos = props.getPos(); if (isValidPosition(pos)) { const imageNodes = urls.map((url, index) => { const filename = files[index]?.name.replace(/\.[^/.]+$/, "") || "unknown"; return { type: extension.options.type, attrs: { ...extension.options, src: url, alt: filename, title: filename, }, }; }); props.editor .chain() .focus() .deleteRange({ from: pos, to: pos + props.node.nodeSize }) .insertContentAt(pos, imageNodes) .run(); focusNextNode(props.editor); } } }; const handleChange = (e: React.ChangeEvent) => { const files = e.target.files; if (!files || files.length === 0) { extension.options.onError?.(new Error("No file selected")); return; } handleUpload(Array.from(files)); }; const handleClick = () => { if (inputRef.current && fileItems.length === 0) { inputRef.current.value = ""; inputRef.current.click(); } }; const hasFiles = fileItems.length > 0; return ( {!hasFiles && ( )} {hasFiles && (
{fileItems.length > 1 && (
Uploading {fileItems.length} files
)} {fileItems.map((fileItem) => ( removeFileItem(fileItem.id)} /> ))}
)} 1} onChange={handleChange} onClick={(e: React.MouseEvent) => e.stopPropagation()} />
); };