"use client" import * as React from "react" import type { NodeViewProps } from "@tiptap/react" import { NodeViewWrapper } from "@tiptap/react" import { CloseIcon } from "@/components/tiptap-icons/close-icon" import "@/components/tiptap-node/image-upload-node/image-upload-node.scss" export interface FileItem { id: string file: File progress: number status: "uploading" | "success" | "error" url?: string abortController?: AbortController } 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 } function useFileUpload(options: UploadOptions) { const [fileItem, setFileItem] = React.useState(null) 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 newFileItem: FileItem = { id: crypto.randomUUID(), file, progress: 0, status: "uploading", abortController, } setFileItem(newFileItem) try { if (!options.upload) { throw new Error("Upload function is not defined") } const url = await options.upload( file, (event: { progress: number }) => { setFileItem((prev) => { if (!prev) return null return { ...prev, progress: event.progress, } }) }, abortController.signal ) if (!url) throw new Error("Upload failed: No URL returned") if (!abortController.signal.aborted) { setFileItem((prev) => { if (!prev) return null return { ...prev, status: "success", url, progress: 100, } }) options.onSuccess?.(url) return url } return null } catch (error) { if (!abortController.signal.aborted) { setFileItem((prev) => { if (!prev) return null return { ...prev, status: "error", progress: 0, } }) 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 null } if (options.limit && files.length > options.limit) { options.onError?.( new Error( `Maximum ${options.limit} file${options.limit === 1 ? "" : "s"} allowed` ) ) return null } const file = files[0] if (!file) { options.onError?.(new Error("File is undefined")) return null } return uploadFile(file) } const clearFileItem = () => { if (!fileItem) return if (fileItem.abortController) { fileItem.abortController.abort() } if (fileItem.url) { URL.revokeObjectURL(fileItem.url) } setFileItem(null) } return { fileItem, uploadFiles, clearFileItem, } } const CloudUploadIcon: React.FC = () => ( ) const FileIcon: React.FC = () => ( ) const FileCornerIcon: React.FC = () => ( ) interface ImageUploadDragAreaProps { onFile: (files: File[]) => void children?: React.ReactNode } const ImageUploadDragArea: React.FC = ({ onFile, children, }) => { const [dragover, setDragover] = React.useState(false) const onDrop = (e: React.DragEvent) => { setDragover(false) e.preventDefault() e.stopPropagation() const files = Array.from(e.dataTransfer.files) onFile(files) } const onDragover = (e: React.DragEvent) => { e.preventDefault() setDragover(true) } const onDragleave = (e: React.DragEvent) => { e.preventDefault() setDragover(false) } return (
{children}
) } interface ImageUploadPreviewProps { file: File progress: number status: "uploading" | "success" | "error" onRemove: () => void } const ImageUploadPreview: React.FC = ({ file, progress, status, 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 (
{status === "uploading" && (
)}
{file.name} {formatFileSize(file.size)}
{status === "uploading" && ( {progress}% )}
) } const DropZoneContent: React.FC<{ maxSize: number }> = ({ maxSize }) => ( <>
Click to upload or drag and drop Maximum file size {maxSize / 1024 / 1024}MB.
) 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 { fileItem, uploadFiles, clearFileItem } = useFileUpload(uploadOptions) 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 handleUpload = async (files: File[]) => { const url = await uploadFiles(files) if (url) { const pos = props.getPos() const filename = files[0]?.name.replace(/\.[^/.]+$/, "") || "unknown" props.editor .chain() .focus() .deleteRange({ from: pos, to: pos + 1 }) .insertContentAt(pos, [ { type: "image", attrs: { src: url, alt: filename, title: filename }, }, ]) .run() } } const handleClick = () => { if (inputRef.current && !fileItem) { inputRef.current.value = "" inputRef.current.click() } } return ( {!fileItem && ( )} {fileItem && ( )} ) => e.stopPropagation()} /> ) }