"use client" import * as React from "react" import type { NodeViewProps } from "@tiptap/react" import { NodeViewWrapper } from "@tiptap/react" import { Button } from "@/components/tiptap-ui-primitive/button" import { CloseIcon } from "@/components/tiptap-icons/close-icon" import "@/components/tiptap-node/image-upload-node/image-upload-node.scss" import { 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: "image", attrs: { src: url, alt: filename, title: filename }, } }) props.editor .chain() .focus() .deleteRange({ from: pos, to: pos + 1 }) .insertContentAt(pos, imageNodes) .run() } } } 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()} />
) }