ProseMirror Example For Decoration
ProseMirror 支持通过 Decoration 装饰器为编辑器添加自定义视图
自定义视图
在 ProseMirror 中可以通过以下几种方式添加自定义视图:
- 在实例化 schema 时,通过配置对象的字段
nodes(或marks)的属性toDOM,设置不同的节点类型(或样式标记)渲染到页面上的 HTML 结构,适用于构建一些简单的静态视图(交互功能由 ProseMirror 实现处理) - 在实例化 EditorView 时,通过配置对象的属性
nodeViews为特定的节点类型自定义视图构建函数,适用于构建复杂的视图(可以接管交互功能) - 使用 decoration 装饰器,在文档的特定部分添加自定义视图或样式,适用于进行轻量级的 UI 定制,它只是为页面添加一些「点缀物」,例如语法高亮,它只影响渲染输出效果而不会修改编辑器的文档数据/内容
- 使用 plugin 插件为编辑器添加自定义视图(在实例化 plugin 时,通过配置对象的属性
view进行设置),适用于为编辑器创建全局通用的视图(而前面的方法,则适用于针对特定节点类型,即自定义视图会根据文档具体结构而嵌入到相应位置),例如菜单栏
有三种不同的装饰器,分别可以使用 Decoration 类所提供的三种静态方法进行实例化:
Decoration.widget(pos, toDOM, spec?)静态方法:在给定位置pos创建一个挂件装饰器,它在页面渲染为一个 的 DOM 元素Decoration.inline(from, to, attrs, spec?)静态方法:在给定范围创建一个行内装饰器,其作用是为给定范围中的行内节点添加特定的属性Decoration.node(from, to, attrs, spec?)静态方法:在给定范围创建一个节点装饰器,其作用是为给定范围内的一个节点(即该范围与该目标节点在文档中的范围一致)添加特定的属性
而为了更高效地地管理装饰器(比对不同编辑器状态中装饰器的差异,在页面进行高效的重渲染),(这些装饰器实例对象并不是单独分别添加到编辑器上)它们需要封装/整合为 decorationSet 装饰器集合的形式来添加到编辑器上
可以通过 DecorationSet 类所提供的一些静态方法或属性进行实例化,得到一个 decorationSet 对象
DecorationSet.create(doc, decorations)静态方法:通过给定的doc一个 node 节点(一般是文档的根节点,表示该集合所包含的装饰器的应用范围是整个编辑器文档)和一系列的装饰器decorations(一个数组,元素是 decoration 装饰器对象)来创建一个装饰器合集DecorationSet.empty属性:一个空的装饰器合集
然后在实例化 EditorView 编辑器视图时 new EditorView(place, props),通过设置第二个参数 props 配置对象的字段 decorations,将 decorationSet 装饰器集合应用到编辑器上
let view = new EditorView(document.querySelector("#editor"), {
state: myState,
// 在实例化视图时,使用以下属性为编辑器添加装饰器
decorations(state) {
// 返回一个 decorationSet 对象
// 即使装饰器只有一个,也需要以 decorationSet 集合的形式应用到编辑器上
return DecorationSet.create(state.doc, [
// 创建一个 inline decoration 行内装饰器
// 将其添加到 decorationSet 集合中
Decoration.inline(0, state.doc.content.size, {style: "color: purple"})
])
}
})
推荐使用插件添加装饰器
还可以使用插件为编辑器添加装饰器
类似地,通过设置其配置对象的字段 props.decorations,将 decorationSet 装饰器集合通过插件应用到编辑器上
let specklePlugin: Plugin = new Plugin({
props: {
decorations(state) {
// return specklePlugin.getState(state)
let speckles = []
for (let pos = 1; pos < state.doc.content.size; pos += 4)
speckles.push(Decoration.inline(pos - 1, pos, {style: "background: purple"}))
return DecorationSet.create(state.doc, speckles)
}
}
})
推荐使用插件添加装饰器,因为可以让代码更模块化,易于管理和维护
为编辑器添加装饰器的大致流程:
- 使用
Decoration类所提供的静态方法(三种方法之一)进行实例化,得到相应的装饰器实例(如果在下一步需要一个空的装饰器集合,则这一步可以省略) - 使用
DecorationSet类所提供的静态方法或属性进行实例化,得到 decorationSet 装饰器集合 - 将 decorationSet 装饰器集合作为 EditorView 编辑器视图的配置对象的字段
props.decorations的值,将装饰器应用到编辑器上(也可以通过 plugin 插件进行添加)
可以参考的相关示例:
- 官方提供了一个示例,演示了如何高效更新装饰器,完整的源码可以查看相关的 Github 仓库
- 官方提供了一个示例,演示了如何使用 inline decoration 行内装饰器为文本内容添加高亮效果,以及如何使用 widget decoration 挂件装饰器插入 HTML 元素而且支持进行交互,完整的源码可以查看相关的 Github 仓库
按需更新
ProseMirror 在官方文档中演示了如何通过 decoration 装饰器为编辑器添加自定义视图(表示文件上传的临时占位图标),并实现按需更新,可以提供编辑器性能。完整的源码可以查看相关的 Github 仓库
一般将装饰器生成逻辑都写在 EditorView 编辑器视图的配置对象的字段 props.decorations 里(或写在 plugin 插件的配置对象的字段 props.decorations 里),则每当编辑器视图更新时(props.decorations 所设置的函数会自动执行一次,以同步更新编辑器上的装饰器),都会重新生成/渲染所有装饰器,如果装饰器较多时可能会影响性能
推荐使用另一种方式为编辑器添加装饰器
- 使用插件添加装饰器,让代码更具模块化,易于管理和维护
- 将 decorationSet 装饰器集合作为插件的自带状态 state,这样就可以保留已添加到编辑器里的装饰器,以便在下一次更新时复用
- 在插件的配置对象的字段
state.init设置插件自带状态的初始值,即 decorationSet 装饰器集合的初始值 - 在插件的配置对象的字段
state.apply设置应该如何更新插件的自带状态,即编辑器每次分发事务时,如何更新 decorationSet 装饰器集合。对于更新后依然会存在的装饰器,可以通过decorationSet.map(mapping, doc)调整位置(保持与原有节点的相对关系)以复用它们(而不需要重新生成),可以提供性能;也可以在这个时候增删装饰器
增删装饰器
在分发事务时,通过
tr.setMeta(key, value)的方式为事务添加元信息然后插件在更新其自带状态时,可以使用
tr.getMeta(key)获取当前事务的元信息,根据这些信息手动增删装饰器 - 在插件的配置对象的字段
- 则插件的配置对象的字段
props.decorations所返回的值就是this.getState(state)
相关示例代码如下
// 使用插件为编辑器添加装饰器
const placeholderPlugin = new Plugin({
// 插件自带的状态
state: {
// 设置 state 初始值
init() {
// 返回一个空的 DecorationSet 装饰器集合,作为 decorations 的初始值
// 即使用插件的自带状态来保存装饰器集合的值
return DecorationSet.empty
},
// 设置 state 的更新方式
// 在 state 更新时同步更新 decorations
apply(tr, decoSet) {
// 使用 decorationSet.map(mapping, doc) 映射调整已存在的装饰器的位置(以复用这些装饰器,而不是全部都重新创建)
// Adjust decoration positions to changes made by the transaction
let updateDecoSet = decoSet.map(tr.mapping, tr.doc)
// 读取当前 tr 事务对象的特定(关于该插件的)元信息
// See if the transaction adds or removes any placeholders
let action = tr.getMeta(placeholderPlugin)
if (action && action.add) {
// 如果元信息存在,且为 add 添加装饰器相关
// 则创建一个 widget decorator 挂件装饰器,添加到相应的位置
let widgetDOM = document.createElement("placeholder")
let widgetDeco = Decoration.widget(action.add.pos, widgetDOM, {id: action.add.id})
// 并添加到 decorationSet 装饰器集合里
updateDecoSet = updateDecoSet.add(tr.doc, [widgetDeco])
} else if (action && action.remove) {
// 如果元信息存在,且为 remove 删除装饰器相关
// 则根据 id 从装饰器集合里移除相应的装饰器
updateDecoSet = updateDecoSet.remove(updateDecoSet.find(undefined, undefined, (spec) => {
return spec.id == action.remove.id
}))
}
// 返回更新后的装饰器集合,作为 state 的更新值
// 即使用插件的自带状态来保存装饰器集合的值
return updateDecoSet
}
},
// 插件的其他配置
props: {
// 设置装饰器集合
decorations(state) {
// 从插件的自带状态里读取
return this.getState(state)
}
}
});
// 上传图片的核心代码
// 会先在编辑器相应位置添加一个挂件装饰器作为「占位」图标
// 待图片上传成功后,再将相应的挂件装饰器移除,将图片节点添加到该位置
function startImageUpload(view: EditorView, file: File) {
// A fresh object as the ID for this upload
// 创建一个空对象作为 id 唯一标识符
// 它在后面会用作指向/索引相应的(新建的)挂件装饰器
let id = {};
// 创建一个空的事务
// Replace the selection with a placeholder
let tr = view.state.tr;
if (!tr.selection.empty) tr.deleteSelection(); // 首先删除选中的内容
// 为该事务添加元信息(key-value 的形式
// 使用 placeholderPlugin 插件作为键名
tr.setMeta(placeholderPlugin, {
add: {
id, // 空对象 id 标识这一次的操作,会被用于指向/索引相应(新建)的装饰器
pos: tr.selection.from // 该操作所发生的位置,会在该位置插入(新建)的装饰器
}
})
view.dispatch(tr);
// `uploadFile` 是一个异步方法
// 具体可以参考 https://github.com/Benbinbin/prosemirror-mini-example/blob/upload-editor/src/main.ts#L177-L188
// 它模拟了将文件上传到远端服务器的异步上传文件的操作
uploadFile(file).then(url => {
// 对上传文件的返回结果进行后续处理
// 基于当前的视图,获取 decoratorSet(它保存为 placeholderPlugin 的自带状态里)
const decoSet = placeholderPlugin.getState(view.state);
if(!decoSet) return;
// 则根据 id 从装饰器集合里寻找相应的装饰器
const found = decoSet.find(undefined, undefined, spec => spec.id == id);
// 获取相应的「占位」装饰器在文档中的位置
const pos = found.length ? found[0].from : null;
// 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, uploadSchema.nodes.image.create({src: url}))
// 同时为该事务设置元信息,让插件会作出响应,删除相应的挂件装饰器
.setMeta(placeholderPlugin, {remove: {id}}));
}, () => {
// On failure, just clean up the placeholder
// 如果上传失败,分发事务,该事务设置了元信息,让插件作出响应,删除相应的挂件装饰器
view.dispatch(tr.setMeta(placeholderPlugin, {remove: {id}}));
})
};
完整的示例代码
根据官方示例编写的简化版本
可交互挂件
ProseMirror 在官方文档中演示了如何通过 widget decoration 挂件装饰器为编辑器添加自定义视图(表示格式错误的图标),而且支持点击(单击和双击)交互。完整的源码可以查看相关的 Github 仓库
widget decoration 挂件装饰器的相关代码如下
// 创建 DecorationSet
function lintDeco(doc: Node) {
let decos: Decoration[] = [];
// loop for each lint error to build a decoration
lint(doc).forEach(prob => {
// each lint error use two decorations to annotation:
// * a inline decoration to show the highlight
// * a widget decoration to show an icon
decos.push(
Decoration.inline(prob.from, prob.to, {class: "problem"}),
Decoration.widget(prob.from, lintIcon(prob), {key: prob.msg})
);
})
return DecorationSet.create(doc, decos);
}
// widget decoration 所对应的 HTML 元素,表示格式错误的图标
// prob 是一个对象,符合 ProblemResult interface
// interface ProblemResult {
// msg: string; // 描述格式错误的信息
// from: number; // 该格式错误在文档中的开始位置
// to: number; // 该格式错误在文档中的结束位置
// fix?: (view: EditorView) => void; // (可选)属性,该方法用于修复格式错误
// }
function lintIcon(prob: ProblemResult) {
return () => {
// 创建一个 DOM 元素表示 widget decoration 挂件装饰器
let icon = document.createElement("span");
icon.className = "lint-icon";
icon.title = prob.msg;
// 将 prob 对象存储到 DOM 元素的自定义属性 problem 里
(icon as any).problem = prob;
return icon
}
}
// 使用 Plugin 为编辑器添加 decoration,并实现交互功能
const lintPlugin = new Plugin({
state: {
init(_, { doc }) {
return lintDeco(doc);
},
apply(tr, old) {
// 当文档发生改变,就重新扫描文档内容,并构建所有 decorations 装饰器
// 💡 重新生成所有装饰器可以确保它们都及时更新,但是全量更新会导致性能较差,可以复用未被 tr 影响的装饰器以优化性能
return tr.docChanged ? lintDeco(tr.doc) : old;
}
},
props: {
decorations(state) {
return this.getState(state);
},
// 单击 widget decoration 的 icon 图标执行一次以下函数
handleClick(view, _, event) {
if (event.target instanceof HTMLElement && /lint-icon/.test(event.target.className)) {
let { from, to } = (event.target as any).problem as ProblemResult;
// 单击图标后,选中(以高亮)带有格式错误的相应内容
// 并将选区滚动到视图中
view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, from, to)).scrollIntoView()
);
return true;
}
},
// 双击 widget decoration 的 icon 图标执行一次以下函数
handleDoubleClick(view, _, event) {
if (event.target instanceof HTMLElement && /lint-icon/.test(event.target.className)) {
let prob = (event.target as any).problem as ProblemResult;
// 双击图标后,执行修正操作
if (prob.fix) {
prob.fix(view);
// 并重新聚焦到编辑器里
view.focus();
return true;
}
}
}
}
})
让 widget decoration 挂件装饰器支持交互的大致方案:
- 为 widget decoration 所对应的 DOM 元素添加自定义属性,其中包含交互所需的信息,例如用户点击装饰器后要执行的函数
- 通过 Plugin 配置对象的属性
props为编辑器添加相应的事件监听器(例如handleClick监听单击事件、handleDoubleClick监听双击事件) - 在事件处理函数中,通过
event.target获取用户所点击的 DOM 元素,并判断所点击元素是否为 widget decoration 所对应的元素(例如检查元素是否带有相应的 CSS class 类名),然后从 DOM 元素的自定义属性里读取「预保留」的信息,执行相应的操作