富文本编辑框架Prosemirror - 如何对state进行修改

上一篇文章上讲到了 prosemirror 是如何把 state 的状态渲染到 DOM 视图的,这一篇文章来分析一下用户的编辑操作是怎么去修改 state 的。

一、prosemirror-state

prosemirror-stateprosemirror用于描述整篇文档状态的数据结构,它主要包括以下几个属性;

doc

doc就是当前整篇文档的数据内容,doc是一个Node类型元素,Node可以理解为prosemirror文章树中的一个节点,代表着一个文档中的元素。Node包含一个content属性,代表它的子节点,是一个Fragment类型的元素,它代表着一个文档片段,可以理解为当前节点的所有子元素的一个集合。

prosemirror通过Node以及Fragment类型完整地描述文档中数据内容。

selection

selection即当前文档中选取的信息,由于contenteditable对光标位置的处理不尽如人意,所以绝大多数的编辑器都会维护自己的选区信息,用于抹平浏览器原生处理带来的问题。prosemirror中提供了一个Selection基类,并在此基础上扩展出TextSelectionNodeSelectionAllSelection这几种 selection 类型,也支持自行扩展出其他类型。

与浏览器自带的 selection 类似,prosemirror 中的 selection 也存在 anchorhead 用于指示选区方向,标明选区在文档中的位置,并且提供了强大的ResolvedPos结构,可以获取当前选区位置在文档中的各种信息,包括位置,当前相对于上层元素的路径,相邻元素等等信息,可以方便地对文档内容做处理。

tr(Transaction)

Transaction 继承自Transform,Transform 代表着对文档的一系列操作,每个操作的原子为一个Step,每个 Step 都提供了 applyinvertmerge 等方法(支撑 prosemirror 的协同编辑),step 会保存当前操作的Slice内容,生成新的 doc 并把旧的 doc 信息存储在 tr.docs 属性中,tr 关注的 doc 内容本身,而不关心具体 selection 的位置。

Schema

schema 这种包含当前文档所支持的所有内容,分为 nodemarknode 即使元素类型,mark 可以理解为是 node 的附加属性,更多是相当于一种标记,常用于标记加粗,斜体这类属性。

二、State 更新过程

下面我们由用户按下一个按键的简单场景来了解prosemirror是如何对 state 进行更新的。

首先我们先了解一下prosemirror是如何监听事件的,这部分工作主要是在prosemirror-view中处理的,prosemirror-viewprosemirror中另外一个核心模块,主要负责处理着用户与编辑器交互行为,更新state以及state渲染到 DOM。

  1. 英文输入

无差异

  1. 中文输入
  • IEChromeSafari:触发keydown, 不触发keypress

  • Firefox

    • 首次按下时触发keydown,不触发keypress
    • 在停止输入并改变文本框内容(如按下回车或者空格键)后会触发keyup只有在触发 keyup 事件才能获得修改后的文本值)
  • Operakeydown, keypress和keyup都不触发

  1. 大小写
  • 大写:keydown、keypress(字母,主键盘数字、回车)、keyupwhich值相等
  • 小写:kepress获取的which不同于keydown、keyup
  1. 按住不放
  • 会一直触发keydown,只触发一次keypress
  1. 非字符
  • 会触发keydown,不会触发keypress

经过上面的分析,我们可以看到,使用keypress事件来作为输入监听是比较合适的,可以区分大小写,不会重复触发,且非字符键也不会触发。prosemirror中对于输入事件的处理也是通过的keypress的监听,keydownkeyup主要用于监听一些功能按键。

输入英文

首先看一下prosemirror中处理keypress的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
editHandlers.keypress = (view, event) => {
if (inOrNearComposition(view, event) || !event.charCode ||
event.ctrlKey && !event.altKey || browser.mac && event.metaKey) return

if (view.someProp("handleKeyPress", f => f(view, event))) {
event.preventDefault()
return
}

let sel = view.state.selection
if (!(sel instanceof TextSelection) || !sel.$from.sameParent(sel.$to)) {
let text = String.fromCharCode(event.charCode)
if (!view.someProp("handleTextInput", f => f(view, sel.$from.pos, sel.$to.pos, text)))
view.dispatch(view.state.tr.insertText(text).scrollIntoView())
event.preventDefault()
}
}

prosemirror中使用的的是charCode这个属性用于获取输入的内容,关于charCode的具体信息可以参阅这里

keypress事件发生时,prosemirror会判断当前selection是否是TextSelection或者光标的起始和中止位置是否相同(为了判断选中了内容,选中了内容需要进行删除处理,后面会讲到)。简单的输入事件prosemirror进行一个insertText的操作去修改state

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
insertText(text, from, to = from) {
let schema = this.doc.type.schema
if (from == null) {
if (!text) return this.deleteSelection()
return this.replaceSelectionWith(schema.text(text), true)
} else {
if (!text) return this.deleteRange(from, to)
let marks = this.storedMarks
if (!marks) {
let $from = this.doc.resolve(from)
marks = to == from ? $from.marks() : $from.marksAcross(this.doc.resolve(to))
}
this.replaceRangeWith(from, to, schema.text(text, marks))
if (!this.selection.empty) this.setSelection(Selection.near(this.selection.$to))
return this
}
}

insertText可以选择在当前位置插入或者选中位置插入,插入的逻辑就是替换当前选中的内容,还有一些处理就是继承插入位置的 mark 属性,插入后选区的位置处理等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
replaceSelectionWith(node, inheritMarks) {
let selection = this.selection
if (inheritMarks !== false)
node = node.mark(this.storedMarks || (selection.empty ? selection.$from.marks() : (selection.$from.marksAcross(selection.$to) || Mark.none)))
selection.replaceWith(this, node)
return this
}

// selection
replaceWith(tr, node) {
let mapFrom = tr.steps.length, ranges = this.ranges
for (let i = 0; i < ranges.length; i++) {
let {$from, $to} = ranges[i], mapping = tr.mapping.slice(mapFrom)
let from = mapping.map($from.pos), to = mapping.map($to.pos)
if (i) {
tr.deleteRange(from, to)
} else {
tr.replaceRangeWith(from, to, node)
selectionToInsertionEnd(tr, mapFrom, node.isInline ? -1 : 1)
}
}
}

// transform
replaceRangeWith = function(from, to, node) {
if (!node.isInline && from == to && this.doc.resolve(from).parent.content.size) {
let point = insertPoint(this.doc, from, node.type)
if (point != null) from = to = point
}
return this.replaceRange(from, to, new Slice(Fragment.from(node), 0, 0))
}

可以看到两个处理分支的最后都会流向replaceRangeWith这个方法,不同在于一个需要删除当前选区位置内容,一个要删除指定位置的内容而已。下面我们具体分析一下replaceRange这个方法做了什么事情。

首先我们先理解一下什么是Slice,slice 是文档的一个切片,代表文档的一个片段,保存着文档的片段信息以及节点嵌套的深度信息。

下面我们再进入replaceRange方法,看下做了什么处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// model
// 根据schema定义判断是否可以替换到当前节点
canReplaceWith(from, to, type, marks) {
if (marks && !this.type.allowsMarks(marks)) return false
let start = this.contentMatchAt(from).matchType(type)
let end = start && start.matchFragment(this.content, to)
return end ? end.validEnd : false
}

// transform
replaceRange = function(from, to, slice) {
let preferredDepth, leftNodes, targetDepths;
// ...

// 寻找适合插入的深度

for (let j = slice.openStart; j >= 0; j--) {
// ...
// 匹配到适合插入的位置
if (parent.canReplaceWith(index, index, insert.type, insert.marks))
return this.replace($from.before(targetDepth), expand ? $to.after(targetDepth) : to,
new Slice(closeFragment(slice.content, 0, slice.openStart, openDepth),
openDepth, slice.openEnd))
}

// 如果未匹配到合适的插入点,直接进入replace
let startSteps = this.steps.length
for (let i = targetDepths.length - 1; i >= 0; i--) {
this.replace(from, to, slice)
if (this.steps.length > startSteps) break
let depth = targetDepths[i]
if (i < 0) continue
from = $from.before(depth); to = $to.after(depth)
}
return this
}
}

可以看到replaceRange的主要工作就是找到到适合插入的位置,进行插入操作。下面看看replace做了什么:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// transform
function fitsTrivially($from, $to, slice) {
return !slice.openStart && !slice.openEnd && $from.start() == $to.start() &&
$from.parent.canReplace($from.index(), $to.index(), slice.content)
}

replace = function(from, to = from, slice = Slice.empty) {
let step = replaceStep(this.doc, from, to, slice)
if (step) this.step(step)
return this
}

replaceStep = function(doc, from, to = from, slice = Slice.empty) {
if (from == to && !slice.size) return null

let $from = doc.resolve(from), $to = doc.resolve(to)

// 在第一层简单插入
if (fitsTrivially($from, $to, slice)) return new ReplaceStep(from, to, slice)
// ...
}


export class ReplaceStep extends Step {
// ...

apply(doc) {
if (this.structure && contentBetween(doc, this.from, this.to))
return StepResult.fail("Structure replace would overwrite content")
return StepResult.fromReplace(doc, this.from, this.to, this.slice)
}
}

class StepResult {

static ok(doc) { return new StepResult(doc, null) }

fromReplace(doc, from, to, slice) {
try {
return StepResult.ok(doc.replace(from, to, slice))
} catch (e) {
if (e instanceof ReplaceError) return StepResult.fail(e.message)
throw e
}
}
}

replace创建了一个step去修改doc的内容。在prosemirror中,所有对文档的修改都是由Step去实现的,在Step.apply中调用了Node.replace的方法,下面我们看一下Node.replace具体做了什么:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// model
function close(node, content) {
if (!node.type.validContent(content))
throw new ReplaceError("Invalid content for node " + node.type.name)
return node.copy(content)
}

export function replace($from, $to, slice) {
if (slice.openStart > $from.depth)
throw new ReplaceError("Inserted content deeper than insertion position")
if ($from.depth - slice.openStart != $to.depth - slice.openEnd)
throw new ReplaceError("Inconsistent open depths")
return replaceOuter($from, $to, slice, 0)
}

function replaceOuter($from, $to, slice, depth) {
let index = $from.index(depth), node = $from.node(depth)
if (index == $to.index(depth) && depth < $from.depth - slice.openStart) {
let inner = replaceOuter($from, $to, slice, depth + 1)
return node.copy(node.content.replaceChild(index, inner))
} else if (!slice.content.size) {
return close(node, replaceTwoWay($from, $to, depth))
} else if (!slice.openStart && !slice.openEnd && $from.depth == depth && $to.depth == depth) {
// Simple, flat case
let parent = $from.parent, content = parent.content
return close(parent, content.cut(0, $from.parentOffset).append(slice.content).append(content.cut($to.parentOffset)))
} else {
let {start, end} = prepareSliceForReplace(slice, $from)
return close(node, replaceThreeWay($from, start, end, $to, depth))
}
}

先不管什么其他具体的 replace 操作的逻辑,实际上的操作都是对Node的操作, 通过cutreplaceslice等方法构造出一个新的节点。可以看到了close返回的一个新的节点,这个节点的content是复用旧的数据。这样 prosemirror 就实现了一个简单的 immutable 结构,既可以保存修改前的文档,又不会占用过多的内存。

输入中文

对于中文的输入,由上文可以知道,各个浏览器的实现不一致,prosemirror判断到当前在composition事件时,并不做具体处理,而是使用MutationObserver,监听到到 DOM 的变化,具体的判断比较复杂,这里不做详细说明,但最后还是会转换成对state的修改。

上面的流程可以简单归结为 拿到插入的内容 -> 寻找适合插入的位置 -> 返回新的文档。这里面主要的难点是在寻找适合的插入位置这一步,由于简单的输入depth都是 0,下面详细剖析若depth非零状态下,如何寻找适合的插入位置。

如何寻找插入位置

这一步从复制粘贴内容出发,看在prosemirror中如何寻找到合适的插入位置的。

  1. 首先从一个三级嵌套的列表节点复制出内容

img

此时切出来的片段内content为一个FragmentopenStartopenEnd指的是当前切片的深度,可以看到嵌套了三个ol以及一个li,所以值为 4size1,指的是实际复制的切片内容大小为 1,但content的内容有 9 的大小,是因为slice同时包含了复制内内容到顶层节点的路径内容。

img

  1. 开始匹配适合的位置

此时我们在文档的直接子节点内执行粘贴操作:

img

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{
// ...
// 此时的操作是直接在文档直接下级元素上粘贴,所以$from和$to的depth都为1
let targetDepths = coveredDepths($from, this.doc.resolve(to));
// ...
}

function coveredDepths($from, $to) {
let result = [], minDepth = Math.min($from.depth, $to.depth)
for (let d = minDepth; d >= 0; d--) {
// start是找到指定深度节点的内容起始的位置,end则是结束位置
let start = $from.start(d)
if (start < $from.pos - ($from.depth - d) ||
$to.end(d) > $to.pos + ($to.depth - d) ||
$from.node(d).type.spec.isolating ||
$to.node(d).type.spec.isolating) break
// 如果$from和$to属于同一个节点,储存起来
if (start == $to.start(d)) result.push(d)
}
return result
}

先拿到拿到当前的路径的深度信息,然后开始匹配适合的插入节点:

1
2
3
4
5
6
7
8
9
10
11
12
13
let preferredTarget = -($from.depth + 1);
targetDepths.unshift(preferredTarget); // targetDepths: [-2, 1]

for (let d = $from.depth, pos = $from.pos - 1; d > 0; d--, pos--) {
// 寻找非关键节点,且深度存在,设为默认插入深度
let spec = $from.node(d).type.spec
if (spec.defining || spec.isolating) break
if (targetDepths.indexOf(d) > -1) preferredTarget = d
// 如果当前插入位置在节点前,则在首位插入当前深度
else if ($from.before(d) == pos) targetDepths.splice(1, 0, -d)
}

let preferredTargetIndex = targetDepths.indexOf(preferredTarget) // preferredTargetIndex: 1

到这一步找到了默认的插入深度,根据我们的场景,此时找到的preferredTarget === 1,下一步

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
let leftNodes = [], preferredDepth = slice.openStart // preferredDepth: 4
// 把当前slice的节点收集起来
for (let content = slice.content, i = 0;; i++) {
// 只收集firstChild是因为只需要关心第一个节点能不插入
// 其余节点能不能插入是在node.replace中去判断的
let node = content.firstChild
leftNodes.push(node)
if (i == slice.openStart) break
content = node.content
}
// leftNodes: [ol, ol, ol, li, text]

// 这一步开始寻找适合插入深度,跳过关键节点,
if (preferredDepth > 0 && leftNodes[preferredDepth - 1].type.spec.defining &&
$from.node(preferredTargetIndex).type != leftNodes[preferredDepth - 1].type) {
preferredDepth -= 1
} else if (preferredDepth >= 2 && leftNodes[preferredDepth - 1].isTextblock && leftNodes[preferredDepth - 2].type.spec.defining && $from.node(preferredTargetIndex).type != leftNodes[preferredDepth - 2].type) {
preferredDepth -= 2
}
// preferredDepth: 2

到这步跳过了关键节点,找到了默认插入深度,之后就是看看具体的节点是否能接受内容作为子节点,否则就往上寻找

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//  targetDepths: [-2, 1],preferredDepth: 2, preferredTargetIndex: 1
for (let j = slice.openStart; j >= 0; j--) {
let openDepth = (j + preferredDepth + 1) % (slice.openStart + 1)
let insert = leftNodes[openDepth]
if (!insert) continue
for (let i = 0; i < targetDepths.length; i++) {
// 从上面找到的默认深度开始,需要适合的节点
let targetDepth = targetDepths[(i + preferredTargetIndex) % targetDepths.length]
let expand = true
if (targetDepth < 0) { expand = false; targetDepth = -targetDepth }
let parent = $from.node(targetDepth - 1) // 当前深度的上级元素
let index = $from.index(targetDepth - 1) // 当前深度的元素在父元素中的索引,即在parent中索引
if (parent.canReplaceWith(index, index, insert.type, insert.marks)) {
return this.replace($from.before(targetDepth), expand ? $to.after(targetDepth) : to,
new Slice(closeFragment(slice.content, 0, slice.openStart, openDepth),
openDepth, slice.openEnd))
}
}
}

到这一步,就完成了插入位置寻找,剩下的工作就是节点内容的替换,在上文已经了解过。

三、总结

prosemirror通过维护自己的文档数据树,并把所有对 dom 的操作都转换成对state的操作,抹平了不同浏览器带来的数据结构不一致的问题,并提升了prosemirror本身的视图层的可移植性,理论上可以通过自身实现视图层在任何平台上都实现编辑器。维护自身文档数据结构也可以说是一个比较主流解决方案,因为它让内容可控,状态可以变,并且为实现协同编辑提供了结构上的支撑。