上传处理

Upload handling

有些类型的编辑会涉及异步操作,但你希望把它们作为一个单一动作呈现给用户。例如,当从用户的本地文件系统插入一张图片时,在你完成上传并为它创建好 URL 之前,你无法拿到真正的图片。然而,你也不想让用户经历「先上传图片,再等待完成,然后才能把图片插入文档」这一整套流程。

理想情况下,当图片被选中时,你立即开始上传,同时也立即在文档中插入一个占位符。然后,当上传完成时,这个占位符会被最终的图片替换掉。

Insert image:

由于上传可能需要一点时间,而用户在等待期间可能还会做更多修改,所以占位符应该随着文档的编辑而跟随其上下文一起移动;当最终图片插入时,它应该被放到当时占位符所在的位置。

最简单的方式是把占位符做成一个 decoration,这样它就只存在于用户的界面中。我们先来写一个管理这些装饰的插件。

import {Plugin} from "prosemirror-state"
import {Decoration, DecorationSet} from "prosemirror-view"

let placeholderPlugin = new Plugin({
  state: {
    init() { return DecorationSet.empty },
    apply(tr, set) {
      // Adjust decoration positions to changes made by the transaction
      set = set.map(tr.mapping, tr.doc)
      // See if the transaction adds or removes any placeholders
      let action = tr.getMeta(this)
      if (action && action.add) {
        let widget = document.createElement("placeholder")
        let deco = Decoration.widget(action.add.pos, widget, {id: action.add.id})
        set = set.add(tr.doc, [deco])
      } else if (action && action.remove) {
        set = set.remove(set.find(null, null,
                                  spec => spec.id == action.remove.id))
      }
      return set
    }
  },
  props: {
    decorations(state) { return this.getState(state) }
  }
})

这是一个围绕 decoration set 的薄封装——它必须是一个 set,因为可能有多个上传同时在进行。插件的 meta 属性可以用来按 ID 添加和移除 widget 装饰。

这个插件附带一个函数,用于返回给定 ID 的占位符的当前位置(如果它仍然存在的话)。

function findPlaceholder(state, id) {
  let decos = placeholderPlugin.getState(state)
  let found = decos.find(null, null, spec => spec.id == id)
  return found.length ? found[0].from : null
}

当使用编辑器下方的文件输入框时,这个事件处理器会检查一些条件,并在可行时触发上传。

document.querySelector("#image-upload").addEventListener("change", e => {
  if (view.state.selection.$from.parent.inlineContent && e.target.files.length)
    startImageUpload(view, e.target.files[0])
  view.focus()
})

核心逻辑发生在 startImageUpload 中。工具函数 uploadFile 返回一个 promise,它会解析为已上传文件的 URL(在这个演示中,它实际上只是等一会儿,然后返回一个 data: URL)。

function startImageUpload(view, file) {
  // A fresh object to act as the ID for this upload
  let id = {}

  // Replace the selection with a placeholder
  let tr = view.state.tr
  if (!tr.selection.empty) tr.deleteSelection()
  tr.setMeta(placeholderPlugin, {add: {id, pos: tr.selection.from}})
  view.dispatch(tr)

  uploadFile(file).then(url => {
    let pos = findPlaceholder(view.state, id)
    // If the content around the placeholder has been deleted, drop
    // the image
    if (pos == null) return
    // Otherwise, insert it at the placeholder's position, and remove
    // the placeholder
    view.dispatch(view.state.tr
                  .replaceWith(pos, pos, schema.nodes.image.create({src: url}))
                  .setMeta(placeholderPlugin, {remove: {id}}))
  }, () => {
    // On failure, just clean up the placeholder
    view.dispatch(tr.setMeta(placeholderPlugin, {remove: {id}}))
  })
}

因为占位符插件会通过事务来 映射它的装饰,所以即使文档在上传期间被修改过,findPlaceholder 也能得到图片的准确位置。