ClipboardAndDropHandler.ts raw

   1  import { isSupportedImage } from '@/lib/image-scaler'
   2  import mediaUpload from '@/services/media-upload.service'
   3  import { Extension } from '@tiptap/core'
   4  import { EditorView } from '@tiptap/pm/view'
   5  import { Plugin, TextSelection } from 'prosemirror-state'
   6  
   7  const DRAGOVER_CLASS_LIST = [
   8    'outline-2',
   9    'outline-offset-4',
  10    'outline-dashed',
  11    'outline-border',
  12    'rounded-md'
  13  ]
  14  
  15  export interface ClipboardAndDropHandlerOptions {
  16    onUploadStart?: (file: File, cancel: () => void) => void
  17    onUploadEnd?: (file: File) => void
  18    onUploadProgress?: (file: File, progress: number) => void
  19  }
  20  
  21  export const ClipboardAndDropHandler = Extension.create<ClipboardAndDropHandlerOptions>({
  22    name: 'clipboardAndDropHandler',
  23  
  24    addOptions() {
  25      return {
  26        onUploadStart: undefined,
  27        onUploadSuccess: undefined,
  28        onUploadError: undefined,
  29        onUploadEnd: undefined,
  30        onUploadProgress: undefined,
  31        onProvideCancel: undefined
  32      }
  33    },
  34  
  35    addProseMirrorPlugins() {
  36      const options = this.options
  37  
  38      return [
  39        new Plugin({
  40          props: {
  41            handleDOMEvents: {
  42              dragenter(view, event) {
  43                event.preventDefault()
  44                view.dom.classList.add(...DRAGOVER_CLASS_LIST)
  45                return true
  46              },
  47              dragover(view, event) {
  48                event.preventDefault()
  49                view.dom.classList.add(...DRAGOVER_CLASS_LIST)
  50                return true
  51              },
  52              dragleave(view) {
  53                view.dom.classList.remove(...DRAGOVER_CLASS_LIST)
  54                return true
  55              }
  56            },
  57            handleDrop(view: EditorView, event: DragEvent) {
  58              event.preventDefault()
  59              event.stopPropagation()
  60              view.dom.classList.remove(...DRAGOVER_CLASS_LIST)
  61  
  62              const items = Array.from(event.dataTransfer?.files ?? [])
  63              const mediaFiles = items.filter(
  64                (item) => item.type.includes('image') || item.type.includes('video')
  65              )
  66              if (!mediaFiles.length) return false
  67  
  68              uploadFiles(view, mediaFiles, options)
  69              return true
  70            },
  71            handlePaste(view, event) {
  72              const items = Array.from(event.clipboardData?.items ?? [])
  73              let handled = false
  74  
  75              for (const item of items) {
  76                if (
  77                  item.kind === 'file' &&
  78                  (item.type.includes('image') || item.type.includes('video'))
  79                ) {
  80                  const file = item.getAsFile()
  81                  if (file) {
  82                    uploadFiles(view, [file], options)
  83                    handled = true
  84                  }
  85                } else if (item.kind === 'string' && item.type === 'text/plain') {
  86                  item.getAsString((text) => {
  87                    const { schema } = view.state
  88                    const parts = text.split('\n')
  89                    const nodes = []
  90                    for (let i = 0; i < parts.length; i++) {
  91                      if (i > 0) nodes.push(schema.nodes.hardBreak.create())
  92                      if (parts[i]) nodes.push(schema.text(parts[i]))
  93                    }
  94                    if (nodes.length > 0) {
  95                      const tr = view.state.tr.replaceSelectionWith(nodes[0])
  96                      for (let i = 1; i < nodes.length; i++) {
  97                        tr.insert(tr.selection.from, nodes[i])
  98                      }
  99                      view.dispatch(tr)
 100                    }
 101                  })
 102                  handled = true
 103                }
 104  
 105                // Only handle the first file/string item
 106                if (handled) break
 107              }
 108              return handled
 109            }
 110          }
 111        })
 112      ]
 113    }
 114  })
 115  
 116  async function uploadFiles(
 117    view: EditorView,
 118    files: File[],
 119    options: ClipboardAndDropHandlerOptions
 120  ) {
 121    const abortControllers = new Map<File, AbortController>()
 122    files.forEach((file) => {
 123      const abortController = new AbortController()
 124      abortControllers.set(file, abortController)
 125      options.onUploadStart?.(file, () => abortController.abort())
 126    })
 127  
 128    for (const file of files) {
 129      const name = file.name
 130  
 131      const placeholder = `[Uploading "${name}"...]`
 132      const uploadingNode = view.state.schema.text(placeholder)
 133      const hardBreakNode = view.state.schema.nodes.hardBreak.create()
 134      let tr = view.state.tr.replaceSelectionWith(uploadingNode)
 135      tr = tr.insert(tr.selection.from, hardBreakNode)
 136      view.dispatch(tr)
 137  
 138      const abortController = abortControllers.get(file)
 139  
 140      // Use responsive upload for supported images, regular upload for other media
 141      const uploadPromise = isSupportedImage(file)
 142        ? mediaUpload.uploadResponsiveImage(file, {
 143            onProgress: (p) => options.onUploadProgress?.(file, p),
 144            signal: abortController?.signal
 145          }).then((result) => {
 146            // For responsive uploads, use the desktop-sm or original variant URL
 147            const primaryVariant =
 148              result.variants.find((v) => v.variant === 'desktop-sm') ||
 149              result.variants.find((v) => v.variant === 'original') ||
 150              result.variants[result.variants.length - 1]
 151            return { url: primaryVariant?.url ?? result.variants[0]?.url }
 152          })
 153        : mediaUpload.upload(file, {
 154            onProgress: (p) => options.onUploadProgress?.(file, p),
 155            signal: abortController?.signal
 156          })
 157  
 158      uploadPromise
 159        .then((result) => {
 160          options.onUploadEnd?.(file)
 161          const urlNode = view.state.schema.text(result.url)
 162  
 163          const tr = view.state.tr
 164          let didReplace = false
 165  
 166          view.state.doc.descendants((node, pos) => {
 167            if (node.isText && node.text && node.text.includes(placeholder) && !didReplace) {
 168              const startPos = node.text.indexOf(placeholder)
 169              const from = pos + startPos
 170              const to = from + placeholder.length
 171              tr.replaceWith(from, to, urlNode)
 172              didReplace = true
 173              return false
 174            }
 175            return true
 176          })
 177  
 178          if (didReplace) {
 179            view.dispatch(tr)
 180          } else {
 181            const endPos = view.state.doc.content.size
 182  
 183            const paragraphNode = view.state.schema.nodes.paragraph.create(
 184              null,
 185              view.state.schema.text(result.url)
 186            )
 187  
 188            const insertTr = view.state.tr.insert(endPos, paragraphNode)
 189            const newPos = endPos + 1 + result.url.length
 190            insertTr.setSelection(TextSelection.near(insertTr.doc.resolve(newPos)))
 191            view.dispatch(insertTr)
 192          }
 193        })
 194        .catch((error) => {
 195          console.error('Upload failed:', error)
 196          options.onUploadEnd?.(file)
 197  
 198          const tr = view.state.tr
 199          let didReplace = false
 200  
 201          view.state.doc.descendants((node, pos) => {
 202            if (node.isText && node.text && node.text.includes(placeholder) && !didReplace) {
 203              const startPos = node.text.indexOf(placeholder)
 204              const from = pos + startPos
 205              const to = from + placeholder.length
 206              const errorNode = view.state.schema.text(`[Error uploading "${name}"]`)
 207              tr.replaceWith(from, to, errorNode)
 208              didReplace = true
 209              return false
 210            }
 211            return true
 212          })
 213  
 214          if (didReplace) {
 215            view.dispatch(tr)
 216          }
 217          throw error
 218        })
 219    }
 220  }
 221