- 大脑(State) :负责存储和管理文档的内容、光标位置等信息。这就是 Editor State。
- 脸和手脚(View) :负责把文档显示给你看,并且接收你的操作(打字、点击等)。这就是 Editor View。
5.1 Editable DOM
ProseMirror 最终需要将它的文档模型渲染成浏览器能理解的 HTML(也就是 DOM)。它默认会使用你在 Schema 中定义的 toDOM 方法来生成这个 DOM 结构,并给这个 DOM 元素加上 contenteditable="true" 属性,使其变成可编辑区域。
5.1 Data flow
数据流描述的是信息在系统中如何流动和变化 。在 ProseMirror 中,它特指状态(State)、视图(View)和事务(Transaction) 三者之间如何相互作用。
- View 显示 State:视图负责将当前的状态(文档内容、光标位置)渲染到页面上。
- 用户触发事件:你在视图上进行了操作(如打字、点击)。
- View 产生 Transaction :视图将这个操作"翻译"成一个 Transaction(事务)。Transaction 描述了"发生了什么变化"。
- State 应用 Transaction :这个 Transaction 被传递给当前的状态,状态根据 Transaction 的指示计算出一个新的状态。
- 新 State 更新 View :新的状态被设置回视图 (
view.updateState(newState)),视图根据新状态重新渲染,更新显示。
第1步:定义应用总状态
csharp
let appState = {
editor: EditorState.create({schema}), // ProseMirror的编辑器状态
score: 0 // 应用自己的状态,比如一个游戏分数
}
第2步:创建视图并"交出控制权"
javascript
let view = new EditorView(document.body, {
state: appState.editor, // 初始状态来自appState
dispatchTransaction(transaction) { // 关键!拦截事务
update({type: "EDITOR_TRANSACTION", transaction}) // 交给全局更新函数
}
})
第3步:创建中央更新函数
csharp
function update(event) {
if (event.type == "EDITOR_TRANSACTION")
// 只有这里才能更新编辑器状态!
appState.editor = appState.editor.apply(event.transaction)
else if (event.type == "SCORE_POINT")
// 也可以处理其他类型的更新,比如加分
appState.score++
draw() // 状态更新后,统一刷新UI
}
第4步:创建UI刷新函数
javascript
function draw() {
// 更新分数显示
document.querySelector("#score").textContent = appState.score
// 更新编辑器视图!这是连接回ProseMirror的关键一步
view.updateState(appState.editor)
}
5.2 Decorations
不改变文档内容,只改变"视图的渲染样式/DOM"。适合做高亮、下划线、提示气泡、占位符等。
- 三种类型:
- Node 装饰:给某个节点的 DOM 添属性/样式(如给所有段落加 class)。
- Inline 装饰:给一段范围内的"行内内容"加样式(如高亮关键词)。
- Widget 装饰:在某个文档位置插入一个独立的 DOM 节点(不属于文档),比如光标提示、占位符、按钮。
javascript
import { Plugin } from 'prosemirror-state'
import { Decoration, DecorationSet } from 'prosemirror-view'
const purplePlugin = new Plugin({
props: {
decorations(state) {
return DecorationSet.create(state.doc, [
Decoration.inline(0, state.doc.content.size, { style: 'color: purple' })
])
}
}
})
每次重绘都"重新创建",当装饰很多时会变慢。
正确做法(维护在插件 state 中 + 映射)
- 把 DecorationSet 存在插件的 state 里。
- 在 apply(tr, set) 中用 set.map(tr.mapping, tr.doc) 把装饰映射到新文档(文本插入/删除后仍能"跟着走")。
- 只有需要时才"增删修改"装饰,避免每帧重建。
javascript
import { Plugin } from 'prosemirror-state'
import { Decoration, DecorationSet } from 'prosemirror-view'
export const specklePlugin = new Plugin<DecorationSet>({
state: {
init(_, { doc }) {
const speckles: Decoration[] = []
for (let pos = 1; pos < doc.content.size; pos += 4) {
speckles.push(
Decoration.inline(pos - 1, pos, { style: 'background: yellow' })
)
}
return DecorationSet.create(doc, speckles)
},
apply(tr, set) {
// 让装饰"映射"到新文档结构(跟随插入/删除移动)
const mapped = set.map(tr.mapping, tr.doc)
// 这里还可以基于 tr 添加/移除装饰(如响应搜索结果、校验结果等)
return mapped
}
},
props: {
decorations(state) {
return specklePlugin.getState(state)
}
}
})
三类装饰直观例子
- Node 装饰:给所有段落加类名
javascript
const paragraphClassPlugin = new Plugin({
props: {
decorations(state) {
const decos: Decoration[] = []
state.doc.descendants((node, pos) => {
if (node.type.name === 'paragraph') {
decos.push(
Decoration.node(pos, pos + node.nodeSize, { class: 'pm-paragraph' })
)
}
})
return DecorationSet.create(state.doc, decos)
}
}
})
- Inline 装饰:高亮关键字(简单搜索)
kotlin
// 例子:高亮选中文本
const highlightDecoration = Decoration.inline(
10, // 开始位置
20, // 结束位置
{
style: 'background-color: yellow; padding: 2px;',
class: 'highlight'
},
{
inclusiveStart: true, // 包含起始位置的新内容
inclusiveEnd: false // 不包含结束位置的新内容
}
)
-
装饰不影响文档内容 - 只是视觉表现
-
三种装饰类型:
- Widget: 插入自定义DOM元素
- Inline: 给文本范围添加样式
- Node: 给整个节点添加样式
5.3 Node views
为文档中的特定节点类型创建"自定义 UI 组件"。就像 React 组件一样,你可以完全控制某个节点的渲染、更新和交互
- 核心概念:
- dom:节点的 DOM 表示
- contentDOM:内容渲染区域(可选)
- update():节点更新时的处理逻辑
- stopEvent():是否阻止事件冒泡
- 自定义图片节点
kotlin
import { EditorView } from 'prosemirror-view'
import { Node as ProseMirrorNode } from 'prosemirror-model'
class ImageView {
dom: HTMLImageElement
node: ProseMirrorNode
view: EditorView
getPos: () => number
constructor(node: ProseMirrorNode, view: EditorView, getPos: () => number) {
this.node = node
this.view = view
this.getPos = getPos
// 创建自定义 DOM
this.dom = document.createElement('img')
this.dom.src = node.attrs.src
this.dom.alt = node.attrs.alt || ''
this.dom.style.maxWidth = '100%'
this.dom.style.height = 'auto'
// 添加点击事件
this.dom.addEventListener('click', (e) => {
e.preventDefault()
console.log('图片被点击了!')
this.handleImageClick()
})
}
// 阻止事件冒泡,让编辑器忽略这个节点的事件
stopEvent() {
return true
}
// 处理图片点击:修改 alt 文本
private handleImageClick() {
const newAlt = prompt('请输入图片描述:', this.node.attrs.alt || '')
if (newAlt !== null) {
// 使用 getPos() 获取当前位置,然后更新节点属性
const tr = this.view.state.tr.setNodeMarkup(this.getPos(), null, {
...this.node.attrs,
alt: newAlt
})
this.view.dispatch(tr)
}
}
}
// 在 EditorView 中注册
const view = new EditorView(document.querySelector('#editor'), {
state,
nodeViews: {
image(node, view, getPos) {
return new ImageView(node, view, getPos)
}
}
})
- 图片:点击编辑、拖拽调整大小
- 表格:行/列操作、样式调整
- 代码块:语法高亮、复制按钮
- 视频:播放控制、字幕编辑
- 数学公式:LaTeX 渲染、编辑界面
5.4. commands
-
核心概念:Command 是一个函数,实现编辑操作(如删除、格式化、插入等)。用户通过快捷键或菜单触发。
-
接口设计:(state, dispatch?, view?) => boolean
- state:当前编辑器状态
- dispatch:可选,用于执行事务
- view:可选,编辑器视图实例
- 返回值:true 表示命令执行成功,false 表示不适用
eg
javascript
import { EditorState, Transaction } from 'prosemirror-state'
import { EditorView } from 'prosemirror-view'
****
function deleteSelection(state: EditorState, dispatch?: (tr: Transaction) => void): boolean {
// 如果没有选中内容,命令不适用
if (state.selection.empty) return false
// 如果有 dispatch 函数,执行删除操作
if (dispatch) {
dispatch(state.tr.deleteSelection())
}
return true // 命令执行成功
}
// 使用示例
const view = new EditorView(document.querySelector('#editor'), { state })
// 检查命令是否可用(不执行)
const canDelete = deleteSelection(view.state, null) // 返回 true/false
// 实际执行命令
deleteSelection(view.state, view.dispatch) // 删除选中内容