上传处理
Upload handling
有些类型的编辑会涉及异步操作,但你希望把它们作为一个单一动作呈现给用户。例如,当从用户的本地文件系统插入一张图片时,在你完成上传并为它创建好 URL 之前,你无法拿到真正的图片。然而,你也不想让用户经历「先上传图片,再等待完成,然后才能把图片插入文档」这一整套流程。
Some types of editing involve asynchronous operations, but you want to present them to your users as a single action. For example, when inserting an image from the user's local filesystem, you won't have access to the actual image until you've uploaded it and created a URL for it. Yet, you don't want to make the user go through the motion of first uploading the image, then waiting for that to complete, and only then inserting the image into the document.
理想情况下,当图片被选中时,你立即开始上传,同时也立即在文档中插入一个占位符。然后,当上传完成时,这个占位符会被最终的图片替换掉。
Ideally, when the image is selected, you start the upload but also immediately insert a placeholder into the document. Then, when the upload finishes, that placeholder is replaced with the final image.
由于上传可能需要一点时间,而用户在等待期间可能还会做更多修改,所以占位符应该随着文档的编辑而跟随其上下文一起移动;当最终图片插入时,它应该被放到当时占位符所在的位置。
Since the upload might take a moment, and the user might make more changes while waiting for it, the placeholder should move along with its context as the document is edited, and when the final image is inserted, it should be put where the placeholder has ended up by that time.
最简单的方式是把占位符做成一个 decoration,这样它就只存在于用户的界面中。我们先来写一个管理这些装饰的插件。
The easiest way to do this is to make the placeholder a decoration, so that it only exists in the user's interface. Let's start by writing a plugin that manages such decorations.
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 装饰。
This is a thin wrapper around a decoration set—it has to be a set because multiple uploads can be in progress at the same time. The meta property for the plugin can be used to add and remove widget decorations by ID.
这个插件附带一个函数,用于返回给定 ID 的占位符的当前位置(如果它仍然存在的话)。
The plugin comes with a function that returns the current position of the placeholder with the given ID, if it still exists.
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
}
当使用编辑器下方的文件输入框时,这个事件处理器会检查一些条件,并在可行时触发上传。
When the file input below the editor is used, this event handler checks some conditions, and fires off the upload when possible.
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)。
The core functionality happens in startImageUpload. The utility
uploadFile returns a promise that resolves to the uploaded
file's URL (in the demo it actually just waits for a bit and then
returns a 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}}))
})
}