OverviewFilesCommitsIssuesDocsReleasesPeers

holepunchto-deps / @holepunchto / keet-editor / src / extensions / file-uploader.js

139 lines · javascript ref:
import { Node } from '@tiptap/core'
import { Plugin } from 'prosemirror-state'

import { NODE_TYPE, NODE_ATTR } from '../constants.js'

import { asyncCommand } from './helpers/async-command.js'

const getNodeType = (fileModule, mimetype, preview) => {
  if (preview) {
    if (fileModule.isVideoFile(mimetype)) {
      return NODE_TYPE.FILE_VIDEO
    }

    if (fileModule.isImageFile(mimetype)) {
      return NODE_TYPE.FILE_IMAGE
    }
  }

  return NODE_TYPE.FILE_ANY
}

const handleFilesUpload = async (view, files, options) => {
  try {
    const { state, dispatch } = view
    const { schema, doc } = state
    const { fileModule, onUpload, onUploadError } = options

    const loaderMap = new Map()

    // Insert Loaders
    for (const sourceFile of files) {
      const loaderId = window.crypto.randomUUID()
      const loaderNode = schema.nodes[NODE_TYPE.FILE_LOADER].create({ [NODE_ATTR.ID]: loaderId })
      dispatch(view.state.tr.insert(doc.content.size, loaderNode))
      loaderMap.set(sourceFile, loaderId)
    }

    for (let i = files.length - 1; i >= 0; i--) {
      const sourceFile = files[i]
      const result = await onUpload(sourceFile, onUploadError)
      if (!result) continue
      const { id, file } = result

      // Replace Loader for a File Node
      view.state.doc.descendants((node, pos) => {
        if (node.type.name === NODE_TYPE.FILE_LOADER && node.attrs[NODE_ATTR.ID] === loaderMap.get(sourceFile)) {
          const { url, type, name, dimensions, previews: preview } = file
          const nodeType = getNodeType(fileModule, file.type, preview)
          const newNode = schema.nodes[nodeType].create({
            [NODE_ATTR.ID]: id,
            src: url,
            type,
            name,
            alt: name,
            title: name,
            dimensions,
            preview
          })
          dispatch(view.state.tr.replaceWith(pos, pos + node.nodeSize, newNode))
        }
      })
      view.focus()
    }
  } catch (error) {
    options.onUploadError?.(error)
  }
}

export const FileUploader = Node.create({
  name: NODE_TYPE.FILE_UPLOADER,

  addCommands () {
    const { options } = this
    return {
      upload:
        (files) =>
          ({ can, chain, commands, dispatch, editor, state, tr, view }) => {
            asyncCommand(() => handleFilesUpload(view, files, options))
          }
    }
  },

  addProseMirrorPlugins () {
    const { options } = this
    return [
      new Plugin({
        view (view) {
          const onDrop = (event) => {
            if (options.dropPreventClassNames?.some(item => (
              event.target.matches(item) ||
              event.target.closest(item)
            ))) {
              return
            }

            const files = []
            const { items } = event.dataTransfer || { items: [] }

            for (let i = 0; i < items.length; i++) {
              const item = items[i]
              if (item.webkitGetAsEntry()?.isFile) {
                files.push(item.getAsFile())
              }
            }

            if (files.length === 0) {
              return false
            }

            event.preventDefault()

            handleFilesUpload(view, files, options)
          }
          document.addEventListener('drop', onDrop)
          return {
            destroy () {
              document.removeEventListener('drop', onDrop)
            }
          }
        },

        props: {
          handlePaste (view, event) {
            const files = [...(event.clipboardData?.files || [])]

            if (!files || files.length === 0) {
              return false
            }

            event.preventDefault()

            handleFilesUpload(view, files, options)
          }
        }
      })
    ]
  }
})