处理上传

一些编辑涉及到异步的操作,但是你想要将它们作为单个动作呈现为给用户,例如,当用户从本地插入图片,你只能在 用户上传到服务端完成并拿到了 URL 后才能访问实际的图片。但是你不想让用户经历先上传图片,然后等待上传图片后再将图片插入的漫长过程。

理想情况下,选择图片后,你应该立即在文档中插入一个占位符以开始上传。然后,当上传完成后将占位符替换为最终图片。

插入图片:

Remix on Glitch

由于上传可能需要一点时间,因此用户可能在等待的时候对文档做出其他更改,所以这个占位符应该随着上下文的更改进行移动,当最终的图片插入成功后,它应该替换掉此时占位符的位置。

实现这个方案最简单的方式是将占位符作为一个 decoration ,这样的话它就仅存在于用户的 UI 界面。让我们从写一个管理这个 decoration 的 plugin 开始:

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

let placeholderPlugin = new Plugin({
  state: {
    init() { return DecorationSet.empty },
    apply(tr, set) {
      // 调整因为 decoration 的位置,以适应 transaction 引起的文档的改变
      set = set.map(tr.mapping, tr.doc)
      // 查看 transaction 是否增加或者删除任何占位符了
      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 的简单包裹--它必须是一个 集合 ,因为多个上传可能同时发生。 plugin 的 meta 信息可以被用来通过 ID 增加或者删除 widget decoration。

该 plugin 有个通过给定 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,它最终会 resolve 文件的 URL (在这个 Demo 中,它实际上只是等待了一会儿然后返回了一个 data: URL):

function startImageUpload(view, file) {
  // 为 upload 构建一个空的对象来存放占位符们的 ID
  let id = {}

  // 用占位符替换选区
  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 (pos == null) return
    // 否则的话,将图片插入占位符所在的位置,然后移除占位符
    view.dispatch(view.state.tr
                  .replaceWith(pos, pos, schema.nodes.image.create({src: url}))
                  .setMeta(placeholderPlugin, {remove: {id}}))
  }, () => {
    // 如果上传失败,简单移除占位符就好
    view.dispatch(tr.setMeta(placeholderPlugin, {remove: {id}}))
  })
}

因为 placeholder plugin 通过一个 transaction maps(映射) 它的 decorations,因此, 即使文档在文件上传期间被修改过,findPlaceholder 也会得到图片的正确位置。