import { Icon } from "@iconify/react" import type { NodeViewProps } from "@tiptap/react" import { NodeViewWrapper } from "@tiptap/react" import { useCallback, useEffect, useRef, useState } from "react" import { isValidPosition } from "../../utils/tiptap-utils" export interface FileItem { id: string file: File progress: number status: "uploading" | "success" | "error" url?: string abortController?: AbortController } export interface UploadOptions { maxSize: number limit: number accept: string upload: ( file: File, onProgress: (event: { progress: number }) => void, signal: AbortSignal, ) => Promise onSuccess?: (url: string) => void onError?: (error: Error) => void } /** * Custom hook for managing multiple file uploads with progress tracking and cancellation */ function useFileUpload(options: UploadOptions) { const [fileItems, setFileItems] = 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, } } interface ImageUploadPreviewProps { fileItem: FileItem 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 / k ** i).toFixed(2))} ${sizes[i]}` } return (
{fileItem.status === "uploading" && (
)}
{fileItem.file.name} {formatFileSize(fileItem.file.size)}
{fileItem.status === "uploading" && ( {fileItem.progress}% )}
) } export const ImageUploadNode: React.FC = props => { const { accept, limit, maxSize, pastedFile } = props.node.attrs const inputRef = 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) // Auto-trigger file input when component mounts (when node is inserted) useEffect(() => { if (inputRef.current && fileItems.length === 0 && !inputRef.current.files?.length) { // Small delay to ensure the component is fully rendered const timer = setTimeout(() => { inputRef.current?.click() }, 100) return () => clearTimeout(timer) } }, [fileItems.length]) const handleUpload = useCallback( 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() } } }, [uploadFiles, props.getPos, props.editor], ) const handleChange = useCallback( (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)) }, [extension.options, handleUpload], ) // Handle pasted file from attributes useEffect(() => { if (pastedFile && fileItems.length === 0) { handleUpload([pastedFile]) } }, [pastedFile, fileItems.length, handleUpload]) const hasFiles = fileItems.length > 0 return ( {hasFiles && (
{fileItems.length > 1 && (
Uploading {fileItems.length} files
)} {fileItems.map(fileItem => ( removeFileItem(fileItem.id)} /> ))}
)} 1} onChange={handleChange} onClick={(e: React.MouseEvent) => e.stopPropagation()} />
) }