从零实现富文本编辑器#2-基于MVC模式的编辑器架构设计
在先前的规划中我们是需要实现MVC
架构的编辑器,将应用程序分为控制器、模型、视图三个核心组件,通过控制器执行命令时会修改当前的数据模型,进而表现到视图的渲染上。简单来说就是构建一个描述文档结构与内容的数据模型,并且使用自定义的execCommand
对数据描述模型进行修改。以此实现的L1
级富文本编辑器,通过抽离数据模型,解决了富文本中脏数据、复杂功能难以实现的问题。
- 开源地址: https://github.com/WindRunnerMax/BlockKit
- 在线编辑: https://windrunnermax.github.io/BlockKit/
- 项目笔记: https://github.com/WindRunnerMax/BlockKit/blob/master/NOTE.md
从零实现富文本编辑器项目的相关文章:
- 深感一无所长,准备试着从零开始写个富文本编辑器
- 从零实现富文本编辑器#2-基于MVC模式的编辑器架构设计
精简的编辑器
在整套系统架构的设计中,最重要的核心理念便是状态同步,如果以状态模型为基准,那么我们需要维护的状态同步就可以归纳为下面的两方面:
- 将用户操作状态同步到状态模型中,当用户操作文本状态时,例如用户的选区操作、输入操作、删除操作等,需要将变更操作到状态模型中。
- 将状态模型状态同步到视图层中,当在控制层中执行命令时,需要将经过变更后生成的新状态模型同步到视图层中,保证数据状态与视图的一致。
其实这两个状态同步是个正向依赖的过程,用户操作形成的状态同步到状态模型,状态模型的变更同步到视图层,视图层则又是用户操作的基础。举个例子,当用户通过拖拽选择部分文本时,需要将其选中的范围同步到状态模型。当此时执行删除操作时,需要将数据中的这部分文本删除,之后再刷新视图的到新的DOM
结构。下次循环就需要继续保证状态的同步,然后执行输入、刷新视图等操作。
由此我们的目标主要是状态同步,虽然看起来仅有简单的两个原则,但是这件事做起来并没有那么简单。当我们执行状态同步时,是非常依赖浏览器的相关API
的,例如选区、输入、键盘等事件。然而此时我们必须要处理浏览器的相关问题,例如截止目前ContentEditable
无法真正阻止IME
的输入,EditContext
的兼容性也还有待提升,这些都是我们需要处理的问题。
实际上当我们用到了越多的浏览器API
实现,我们就需要考虑越多的浏览器兼容性问题。因此富文本编辑器的实现才会出现很多非ContentEditable
的实现,例如如钉钉文档的自绘选区、Google Doc
的Canvas
文档绘制等。但是这样虽然能够降低部分浏览器API
的依赖,但是也无法真正完全脱离浏览器的实现,因此即使是Canvas
绘制的文档,也必须要依赖浏览器的API
来实现输入、位置计算等等。
回到我们的精简编辑器模型,先前的文章已经提到了ContentEditable
属性以及execCommand
命令,通过document.execCommand
来执行命令修改HTML
的方案虽然简单,但是很明显其可控性比较差。execCommand
命令的行为在各个浏览器的表现是不一致的,这也是之前我们提到的浏览器兼容行为的一种,然而这些行为我们也没有任何办法去控制,这都是其默认的行为。
<div><button id="$1">加粗</button><div style="border: 1px solid #eee; outline: none" contenteditable>123123</div>
</div>
<script>$1.onclick = () => {document.execCommand("bold");};
</script>
因此为了更强的扩展以及可控性,也解决数据与视图无法对应的问题,L1
的富文本编辑器使用了自定义数据模型的概念。即在DOM
树的基础上抽离出来的数据结构,相同的数据结构可以保证渲染的HTML
也是相同的,配合自定义的命令直接控制数据模型,最终保证渲染的HTML
文档的一致性。对于选区的表达,则需要根据DOM
选区来不断normalize
选区Model
。
这也就是我们今天要聊的MVC
模型架构,我们组织编辑器项目是通过monorepo
的形式来管理的相关包,这样就自然而然地可以形成分层的架构。不过在此之前,我们可以在HTML
文件中实现最基准的编辑器 simple-mvc.html,当然我们还是实现最基本的加粗能力,主要关注点在于整个流程的控制。而针对输入的能力则是更加复杂的问题,我们暂时就不处理了,这部分需要单独开章节来叙述。
数据模型
首先我们需要定义数据模型,这里的数据模型需要有两部分,一部分是描述文档内容的节点,另一部分是针对数据结构的操作。首先来看描述文档的内容,我们仍然以扁平的数据结构来描述内容,此外为了简单描述DOM
结构,此处不会存在多级的DOM
嵌套。
let model = [{ type: "strong", text: "123", start: 0, len: 3 },{ type: "span", text: "123123", start: 3, len: 6 },
];
在上述的数据中,type
即为节点类型,text
则为文本内容。而数据模型仅是描述数据结构还不够,我们还需要额外增加状态来描述位置信息,也就是上述数据中的start
和len
,这部分数据对于我们计算选区变换很有用。
因此数据模型这部分不仅仅是数据,更应该被称作为状态。接下来则是针对数据结构的操作,也就是说针对数据模型的插入、删除、修改等操作。在这里我们简单定义了数据截取的操作,而完整的compose
操作则可以参考 delta.ts。
截取数据的操作是执行compose
操作的基础,当我们存在原文和变更描述时,需要分别将其转换为迭代器对象来截取数据,以此来构造新的数据模型。这里的迭代器部分先定义了peekLength
和hasNext
两个方法,用于判断当前数据是否存在剩余可取得的部分,以及是否可继续迭代。
peekLength() {if (this.data[this.index]) {return this.data[this.index].text.length - this.offset;} else {return Infinity;}
}hasNext() {return this.peekLength() < Infinity;
}
next
方法的处理方式要复杂一些,这里我们的目标主要就是取text
的部分内容。注意我们每次调用next
是不会跨节点的,也就是说每次next
最多取当前index
的节点所存储的insert
长度。因为如果取的内容超过了单个op
的长度,理论上其对应属性是不一致的,所以不能直接合并。
调用next
方法时,如果不存在length
参数,则默认为Infinity
。然后我们取当前index
的节点,计算出当前节点的剩余长度,如果取length
大于剩余长度,则取剩余长度,否则取希望取得的length
长度。然后根据offset
和length
来截取text
内容。
next(length) {if (!length) length = Infinity;const nextOp = this.data[this.index];if (nextOp) {const offset = this.offset;const opLength = nextOp.text.length;const restLength = opLength - offset;if (length >= restLength) {length = restLength;this.index = this.index + 1;this.offset = 0;} else {this.offset = this.offset + length;}const newOp = { ...nextOp };newOp.text = newOp.text.slice(offset, offset + length);return newOp;}return null;
}
以此我们简单定义了描述数据模型的状态,以及可以用来截取数据结构的迭代器。这部分是描述数据结构内容以及变更的基础,当然在这里我们精简了非常多的内容,因此看起来比较简单。实际上这里还有非常复杂的实现,例如如何实现immutable
来减少重复渲染保证性能。
视图层
视图层主要负责渲染数据模型,这部分我们是可以使用React
来渲染的,只不过在这个简单例子中,我们可以直接全量创建DOM
即可。因此在这里我们直接遍历数据模型,根据节点类型来创建对应的DOM
节点,然后将其插入到contenteditable
的div
中。
const render = () => {container.innerHTML = "";for (const data of model) {const node = document.createElement(data.type);node.setAttribute("data-leaf", "true");node.textContent = data.text;container.appendChild(node);MODEL_TO_DOM.set(data, node);DOM_TO_MODEL.set(node, data);}editor.updateDOMselection();
};
这里我们还额外增加了data-leaf
属性,以便于标记叶子结点。我们的选区更新是需要标记叶子结点,以便于能够正确计算选区需要落在某个DOM
节点上。而MODEL_TO_DOM
和DOM_TO_MODEL
则是用来维护Model
与DOM
的映射关系,因为我们需要根据DOM
和MODEL
来相互获取对应值。
以此我们定义了非常简单的视图层,示例中我们不需要考虑太多的性能问题。但是在React
真正完成视图层的时候,由于非受控的ContentEditable
的表现,我们就需要考虑非常多的问题,例如key
值的维护、脏DOM
的检查、减少重复渲染、批量调度刷新、选区修正等等。
控制器
控制器则是我们的架构中最复杂的部分,这里存在了大量的逻辑处理。我们的编辑器控制器模型需要在数据结构和视图层的基础上实现,因此我们就在最后将其叙述,恰好在这里的MVC
模型顺序的最后即是Controller
。在控制器层,总结起来最主要的功能就是同步,即同步数据模型和视图层的状态。
举个例子,我们的视图层是基于数据模型来渲染的,假如此时我们在某个节点上输入了内容,那么我们需要将输入的内容同步到数据模型中。而如果此时我们没有正确同步数据模型,那么选区的长度计算就会出现问题,这种情况下自然还会导致选区的索引同步出现问题,这里还要区分受控和非受控问题。
那么首先我们需要关注选区的同步,选区是编辑器操作的基础,选中的状态则是操作的基准位置。同步的本质实现则是需要用浏览器的API
来同步到数据模型中,浏览器的选区存在selectionchange
事件,通过这个事件我们可以关注到选区的变化,此时便可以获取最新的选区信息。
通过window.getSelection
方法我们可以获取到当前选区的信息,然后通过getRangeAt
就可以拿到选区的Range
对象,我们自然就可以通过Range
对象来获取选区的开始和结束位置。有了选区的起始和结束位置,我们就可以通过先前设置的映射关系来取的对应的位置。
document.addEventListener("selectionchange", () => {const selection = window.getSelection();const range = selection.getRangeAt(0);const { startContainer, endContainer, startOffset, endOffset } = range;const startLeaf = startContainer.parentElement.closest("[data-leaf]");const endLeaf = endContainer.parentElement.closest("[data-leaf]");const startModel = DOM_TO_MODEL.get(startLeaf);const endModel = DOM_TO_MODEL.get(endLeaf);const start = startModel.start + startOffset;const end = endModel.start + endOffset;editor.setSelection({ start, len: end - start });editor.updateDOMselection();
});
这里通过选区节点获取对应的DOM
节点并不一定是我们需要的节点,浏览器的选区位置规则对我们的模型来说是不确定的,因此我们需要根据选区节点来查找目标的叶子节点。举个例子,普通的文本选中情况下选区是在文本节点上的,三击选中则是在整个行DOM
节点上的。
因此这里的closest
只是处理最普通的文本节点选区,复杂的情况还需要进行normalize
操作。而DOM_TO_MODEL
则是状态映射,获取到最近的[data-leaf]
节点就是为了拿到对应的状态,当获取到最新选区位置之后,是需要更新DOM
的实际选区位置的,相当于校正了浏览器本身的选区状态。
updateDOMselection
方法则是完全相反的操作,上述的事件处理是通过DOM
选区更新Model
选区,而updateDOMselection
则是通过Model
选区更新DOM
选区。那么此时我们是只有start/len
,基于这两个数字的到对应的DOM
并不是简单的事情,此时我们需要查找DOM
节点。
const leaves = Array.from(container.querySelectorAll("[data-leaf]"));
这里同样会存在不少的DOM
查找,因此实际的操作中也需要尽可能地减少选择的范围,在我们实际的设计中,则是以行为基准查找span
类型的节点。紧接着就需要遍历整个leaves
数组,然后继续通过DOM_TO_MODEL
来获取DOM
对应的状态,然后来获取构造range
需要的节点和偏移。
const { start, len } = editor.selection;
const end = start + len;
for (const leaf of leaves) {const data = DOM_TO_MODEL.get(leaf);const leafStart = data.start;const leafLen = data.text.length;if (start >= leafStart && start <= leafStart + leafLen) {startLeaf = leaf;startLeafOffset = start - leafStart;// 折叠选区状态下可以 start 与 end 一致if (windowSelection.isCollapsed) {endLeaf = startLeaf;endLeafOffset = startLeafOffset;break;}}if (end >= leafStart && end <= leafStart + leafLen) {endLeaf = leaf;endLeafOffset = end - leafStart;break;}
}
当查找到目标的DOM
节点之后,我们那就可以构造出modelRange
,并且将其设置为浏览器选区。但是需要注意的是,我们需要在此处检查当前选区是否与原本的选区相同,设想一下如果再次设置选区,那么就会触发SelectionChange
事件,这样就会导致无限循环,自然是需要避免此问题。
if (windowSelection.rangeCount > 0) {range = windowSelection.getRangeAt(0);// 当前选区与 Model 选区相同, 则不需要更新if (range.startContainer === modelRange.startContainer &&range.startOffset === modelRange.startOffset &&range.endContainer === modelRange.endContainer &&range.endOffset === modelRange.endOffset) {return void 0;}
}
windowSelection.setBaseAndExtent(startLeaf.firstChild,startLeafOffset,endLeaf.firstChild,endLeafOffset
);
实际上选区的问题不比输入法的问题少,在这里我们就是非常简单地实现了浏览器选区与我们模型选区的同步,核心仍然是状态的同步。接下来就可以实现数据模型的同步,在这里也就是我们实际执行命令的实现,而不是直接使用document.execCommand
。
此时我们先前定义的数据迭代器就派上用场了,我们操作的目标也是需要使用range
来实现,例如123123
这段文本在start: 3, len: 2
的选区,以及strong
的类型,在这区间内的数据类型就会变成123[12 strong]3
,这也就是将长数据进行裁剪的操作。
我们首先根据需要操作的选区来构造retain
数组,虽然这部分描述本身应该构造ops
来操作,然而这里就需要更多的补充compose
的实现,因此这里我们只使用一个数组和索引来标识了。
let retain = [start, len, Infinity];
let retainIndex = 0;
然后则需要定义迭代器和retain
来合并数据,这里我们的操作是0
索引来移动指针以及截取索引内的数据,1
索引来实际变化类型的type
,2
索引我们将其固定为Infinity
,在这种情况下我们是取剩余的所有数据。这里重要的length
则是取两者较短的值,以此来实现数据的截取。
const iterator = new Iterator(model);
while (iterator.hasNext()) {const length = Math.min(iterator.peekLength(), retain[retainIndex]);const isApplyAttrs = retainIndex === 1;const thisOp = iterator.next(length);const nextRetain = retain[retainIndex] - length;retain[retainIndex] = nextRetain;if (retain[retainIndex] === 0) {retainIndex = retainIndex + 1;}if (!thisOp) break;isApplyAttrs && (thisOp.type = type);newModel.push(thisOp);
}
在最后,还记得我们维护的数据不仅是数据表达,更是描述整个数据的状态。因此最后我们还需要将所有的数据刷新一遍,以此来保证最后的数据模型正确,此时还需要调用render
来重新渲染视图层,然后重新刷新浏览器选区。
let index = 0;
for (const data of newModel) {data.start = index;data.len = data.text.length;index = index + data.text.length;
}
render();
editor.updateDOMselection();
以此我们定义了相对复杂的控制器层,这里的控制器层主要是同步数据模型和视图层的状态,以及实现了最基本的命令操作,当然没有处理很多复杂的边界情况。在实际的编辑器实现中,这部分逻辑会非常复杂,因为我们需要处理非常多的问题,例如输入法、选区模型、剪贴板等等。
项目架构设计
那么我们基本编辑器MVC
模型已经实现,因此自然而然就可以将其抽象为独立的package
,恰好我们也是通过monorepo
的形式来管理项目的。因此在这里就可以将其抽象为core
、delta
、react
、utils
四个核心包,分别对应编辑器的核心逻辑、数据模型、视图层、工具函数。而具体的编辑器模块实现,则全部以插件的形式定义在plugin
包中。
Core
Core
模块封装了编辑器的核心逻辑,包括剪贴板模块、历史操作模块、输入模块、选区模块、状态模块等等,所有的模块通过实例化的editor
对象引用。这里除了本身分层的逻辑实现外,还希望能够实现模块的扩展能力,可以通过引用编辑器模块并且扩展能力后,可以重新装载到编辑器上。
Core├── clipboard├── collect├── editor├── event├── history├── input├── model├── perform├── plugin├── rect├── ref├── schema├── selection├── state└── ...
实际上Core
模块中存在本身的依赖关系,例如选区模块依赖于事件模块的事件分发,这主要是由于模块在构造时需要依赖其他模块的实例,以此来初始化本身的数据和事件等。因此事件实例化的顺序会比较重要,但是我们在实际聊下来的时候则直接按上述定义顺序,并未按照直接依赖的有向图顺序。
clipboard
模块主要负责数据的序列化与反序列化,以及剪贴板的操作。通常来说,富文本编辑器的DOM
结构并没有那么的规范,举个例子,在slate
中我们可以看到诸如data-slate-node
、data-slate-leaf
等节点属性,我们可以将其理解为模版结构。
<p data-slate-node="element"><span data-slate-node="text"><span data-slate-leaf="true"><span data-slate-string="true">text</span></span></span>
</p>
那么我们通过react
来构建视图层自然也会存在这样的模版结构,因此在序列化的过程中就是需要将这部分复杂的结构序列化为相对规范的HTML
。特别是很多样式我们并不是使用规范的语义标签,而是通过style
属性来实现的,因此将其规整化是非常重要的。
反序列化则将HTML
转换为编辑器的数据模型,这部分实现则是为了跨编辑器的内容粘贴。编辑器内建的数据结构通常都不一致,因此跨编辑器就需要较为规范的中间结构。这其实也是编辑器中不成文的规定,A
编辑器序列化的时候尽可能规范,B
编辑器反序列化才可以更好地处理。
collect
模块是可以根据选区数据来得到相关的数据,举个例子,当用户选中了一段文本,执行复制的时候就需要将选中的这部分数据内容取出来,然后才能进行序列化操作。此外,collect
模块还可以取得某个位置的op
节点、marks
继承处理等等。
editor
模块是编辑器的模块聚合类,其本身主要是管理整个编辑器的生命周期,例如实例化、挂载DOM
、销毁等状态。此模块需要组合所有的模块,并且还需要关注模块的有向图组织依赖关系,主要的编辑器API
都应该从此模块暴露出来。
event
模块是事件分发模块,原生事件的绑定都是在该模块中实现,编辑器内所有的事件都应该从该模块来分发。这种方式可以有更高度的自定义空间,例如扩展插件级别的事件执行,并且可以减少内存泄漏的概率,毕竟只要我们能够保证编辑器的销毁方法调用,那么所有的事件都可以被正确卸载。
history
模块是维护历史操作的模块,在编辑器中实现undo
和redo
是比较复杂的,我们需要基于原子化的操作执行,而不是存储编辑器的全量数据快照,并且需要维护两个栈来处理数据转移。此外我们还需要在此基础上实现扩展,例如自动组合、操作合并、协同处理等。
这里的自动组合指的是用户进行高频连续操作时,我们需要将其合并为一个操作。操作合并则是指我们可以通过API
来实现合并,例如用户上传图片后,执行了其他输入操作,然后上传成功后产生的操作,最后这个操作应该合并到上传图片的这个操作上。协同处理则是需要遵循一个原则,即我们仅能撤销属于自己的操作,而不能撤销其他人协同过来的操作。
input
模块是处理输入的模块,输入是编辑器的核心操作之一,我们需要处理输入法、键盘、鼠标等输入操作。输入法的交互处理是需要非常多的兼容处理,例如输入法还存在候选词、联想词、快捷输入、重音等等。甚至是移动端的输入法兼容更麻烦,在draft
中还单独列出了移动端输入法的兼容问题。
举个目前比较常见的例子,ContentEditable
无法真正阻止IME
的输入,这就导致了我们无法真正阻止中文的输入。在下面的这个例子中,输入英文和数字是不会有响应的,但是中文却是可以正常输入的,这也是很多编辑器选择自绘选区和输入的原因之一,例如VSCode
、钉钉文档等。
<div contenteditable id="$1"></div>
<script>const stop = (e) => {e.preventDefault();e.stopPropagation();};$1.addEventListener('beforeinput', stop);$1.addEventListener('input', stop);$1.addEventListener('keydown', stop);$1.addEventListener('keypress', stop);$1.addEventListener('keyup', stop);$1.addEventListener('compositionstart', stop);$1.addEventListener('compositionupdate', stop);$1.addEventListener('compositionend', stop);
</script>
model
模块是用来映射DOM
视图和状态模型的关系,这部分是视图层和数据模型的桥梁,在很多时候我们需要通过DOM
来获取状态模型,同样也会需要通过状态模型在获取对应的DOM
视图。这部分就是利用WeakMap
来维护映射,以此来实现状态的同步。
perform
模块是封装了针对数据模型执行变更的基础模块,由于构造基本的delta
操作会比较复杂,例如执行属性marks
的变更,是需要过滤掉\n
的这个op
,反过来对行属性的操作则是需要过滤掉普通文本op
。因此需要封装这部分操作,来简化执行变更的成本。
plugin
模块实现了编辑器的插件化机制,插件化是非常有必要的,理论上而言普通文本外的所有格式都应该由插件来实现。那么这里的插件化主要是提供了基础的插件定义和类型,管理了插件的生命周期,以及诸如按方法调用分组、方法调度优先级等能力。
rect
模块是用来处理编辑器的位置信息,在很多时候我们需要根据DOM
节点来计算位置,并且需要提供节点在编辑器的相对位置,特别是很多附加能力中,例如虚拟滚动的视口锁定、对比视图的虚拟图层、评论能力的高度定位等等。此外,选区的位置信息也是很重要的,例如浮动工具栏的弹出位置。
ref
模块是实现了编辑器的位置转移引用,这部门其实是利用了协同的transform
来处理的索引信息,类似于slate
的PathRef
。举个例子,当用户上传图片后,此时可能会进行其他的内容插入操作,此时图片的索引值会发生变化,而使用ref
模块则可以拿到最新的索引值。
schema
模块是用来定义编辑器的数据应用规则,我们需要在此处定义数据属性需要处理的方法,例如加粗的属性marks
需要在输入后继续继承加粗属性,而行内代码inline
类型则不需要继续继承,类似于图片、分割线则需要被定义为独占整行的Void
类型,Mention
、Emoji
等则需要被定义为Embed
类型。
selection
模块是用来处理选区的模块,选区是编辑器的核心操作基准,我们需要处理选区同步、选区校正等等。实际上选区的同步是非常复杂的事情,从浏览器的DOM
映射到选区模型本身就是需要精心设计的事情,而选区的校正则是需要处理非常多的边界情况。
在先前我们也提到了相关的问题,以下面的DOM
结构为例,如果我们要表达选区折叠在4
这个字符左侧时,同样会出现多种表达可以实现这个位置,这实际上就会很依赖浏览器的默认行为。因此这样就需要我们自己来保证这个选区的映射,以及在非常规状态下的校正逻辑。
// <span>123</span><b><em>456</em></b><span>789</span>
{ node: 123, offset: 3 }
{ node: <em></em>, offset: 0 }
{ node: <b></b>, offset: 0 }
state
模块维护了编辑器的核心状态,在实例化编辑器时传递基本数据后,我们后续维护的内容就变成了状态,而不是最开始传递的数据内容。我们的状态变更方法同样会在此处实现,特别是Immutable/Key
的状态维护,我们需要保证状态的不可变性,以此来减少重复渲染。
|-- LeafState|-- LineState --|-- LeafState| |-- LeafState
BlockState --|| |-- LeafState|-- LineState --|-- LeafState|-- LeafState
Delta
Delta
模块封装了编辑器的数据模型,我们需要基于数据模型来描述编辑器的内容,以及编辑器内容的变更。除此之外,还封装了数据模型的诸多操作,例如compose
、transform
、invert
、diff
、Iterator
等等。
Delta├── attributes├── delta├── mutate├── utils└── ...
这里的Delta
实现是基于Quill
的数据模型改造的,Quill
的数据模型设计非常优秀,特别是封装了基于OT
的操作变换等方法。但是设计上还是存在不方便的地方,因此参考了EtherPad
的数据实现,在此基础上改造了部分实现,我们后续会详细讲述数据结构设计。
此外需要注意的是,我们的Delta
实现最主要的是用来描述文档以及变更,相当于一种序列化和反序列化的实现。上边也提到了在初始化编辑器之后,我们维护的数据就变成了内建的状态,而非最初初始化的数据内容。因此很多方法在控制器层面上,都会有单独的设计,例如immutable
的状态维护。
attributes
模块维护了针对文本描述属性的操作,我们在这里简化了属性的实现,即AttributeMap
类型定义了为<string, string>
的类型。而具体的模块中则定义了compose
、invert
、transform
、diff
等方法,以此来实现属性的合并、反转、变换、差异等操作。
delta
模块实现了整个编辑器的数据模型,delta
通过ops
实现了线形结构的数据模型。ops
的结构主要包括三种操作,insert
用来描述插入文本、delete
用来描述删除文本、retain
用来描述保留文本/移动指针,以及在此基础上的compose
、transform
等等方法。
mutate
模块则实现了immutable
的delta
模块实现,并且独立了\n
作为独立的op
。最初的控制器设计实现是基于数据变更实现的,后续将其改造为原始状态的维护,因此这部分实现移动到了delta
模块中,因此这部分可以直接对应编辑器的状态维护,可以用于单元测试等等。
utils
模块则封装了对于op
以及delta
的辅助方法,clone
的相关方法实现了诸如op
、delta
等深拷贝以及对等方法,当然由于我们新的设计则无需引入lodash
的相关方法。此外还实现了一些数据的判断以及格式化方法,例如数据的起始/结束字符串判断、分割\n
的方法等等。
React
React
模块实现了视图层的适配器,提供了基本的Text
、Void
、Embed
等类型的节点,以及相关的渲染模式,相当于封装了符合Core
模式的调度范式。并且还提供了相关包装的HOC
节点,以及Hooks
等方法,以此来实现插件的视图层扩展。
React├── hooks├── model├── plugin├── preset└── ...
hooks
模块实现了获取编辑器实例的方法,方便在React
组件中获取编辑器实例,当然这依赖于我们的Provider
组件。此外还实现了readonly
的状态方法,这里的只读状态维护本身是维护在插件中的,但是后来将其提取到了React
组件中,这样能更容易切换编辑/只读状态。
model
模块实现了编辑器内建的数据模型,实际上是对应了Core
层的State
,即Block/Line/Leaf
的数据模型,这其中除了DOM
节点需要遵循的模式外,还实现了诸如脏DOM
检测的方式等。此外这里还存在了特殊的EOL
节点,是个特殊的LeafModel
,会根据策略调度行尾节点的渲染。
plugin
模块实现了编辑器的插件化机制,这里的插件化主要是扩展了基础的插件定义和类型,例如在Core
中定义的插件方法类型返回值是any
,在这里我们需要将其定义为具体的ReactNode
类型。此外,这里还实现了渲染时的插件,即没有在核心层维护状态的类型,主要是Wrap
类型的节点插件化。
preset
模块预设了编辑器对外暴露的API
组件,诸如编辑器的Context
、Void
、Embed
、Editable
组件等等,主要是提供构建编辑器视图的基础组件,以及插件层的组件扩展等。当然还封装了很多交互实现,例如自动聚焦、选区同步、视图刷新适配器等等。
Utils
Utils
模块实现了诸多通用的工具函数,主要是处理编辑器内的通用逻辑,例如防抖、节流等等,也有处理DOM
结构的辅助方法,还有事件分发的处理方法、事件绑定的装饰器等,以及诸如列表的操作、国际化、剪贴板操作等等。
Utils├── debounce.ts├── decorator.ts├── dom.ts└── ...
总结
在这里我们实现了简单的编辑器MVC
架构示例,然后在此基础上自然而然地抽象出了编辑器的核心模块、数据模型、视图层、工具函数等,并且将其做了简单的叙述。在后续我们会描述编辑器的数据模型设计,介绍我们的Delta
数据结构方法,以及在编辑器中的相关应用场景。数据结构是非常重要的设计,因为编辑器的核心操作都是基于数据模型的,若不能够理解数据结构的设计,则会导致难以理解编辑器的很多操作模型。
每日一题
- https://github.com/WindRunnerMax/EveryDay
参考
- https://developer.mozilla.org/en-US/docs/Web/API/EditContext
- https://www.oschina.net/translate/why-contenteditable-is-terrible
- https://stackoverflow.com/questions/78268797/how-to-prevent-ime-input-method-editor-to-mutate-the-contenteditable-element
相关文章:
从零实现富文本编辑器#2-基于MVC模式的编辑器架构设计
在先前的规划中我们是需要实现MVC架构的编辑器,将应用程序分为控制器、模型、视图三个核心组件,通过控制器执行命令时会修改当前的数据模型,进而表现到视图的渲染上。简单来说就是构建一个描述文档结构与内容的数据模型,并且使用自…...
SAP S4HANA embedded analytics
SAP S4HANA embedded analytics...
linux多线(进)程编程——(7)消息队列
前言 现在修真界大家的沟通手段已经越来越丰富了,有了匿名管道,命名管道,共享内存等多种方式。但是随着深入使用人们逐渐发现了这些传音术的局限性。 匿名管道:只能在有血缘关系的修真者(进程)间使用&…...
STM32 HAL库 实现485通信
一、引言 在工业自动化、智能家居等众多领域中,RS - 485 通信因其长距离、高抗干扰能力等优点被广泛应用。STM32F407 是一款性能强大的微控制器,其丰富的外设资源为实现 RS - 485 通信提供了良好的硬件基础。本文将详细介绍基于 STM32F407 HAL 库实现 R…...
用 Vue 3 + OpenAI API 实现一个智能对话助手(支持上下文、多角色)
文章目录 一、项目背景与功能介绍二、技术选型与准备工作环境准备 三、智能对话助手的实现第一节:封装 OpenAI 接口请求第二节:构建消息上下文结构第三节:构建对话 UI 组件第四节:滚动自动到底部(可选优化)…...
ollama修改配置使用多GPU,使用EvalScope进行模型压力测试,查看使用负载均衡前后的性能区别
文章目录 省流结论机器配置不同量化模型占用显存1. 创建虚拟环境2. 创建测试jsonl文件3. 新建测试脚本3. 默认加载方式,单卡运行模型3.1 7b模型输出213 tok/s3.1 32b模型输出81 tok/s3.1 70b模型输出43tok/s 4. 使用负载均衡,多卡运行4.1 7b模型输出217t…...
vue3 setup vite 配置跨域了proxy,部署正式环境的替换
在开发环境中使用 Vite 的 proxy 配置来解决跨域问题是一种常见的做法。然而,在部署到正式环境时,通常需要对接口地址进行调整,具体是否需要更改接口名称取决于你的部署环境和后端服务的配置。以下是几种常见的情况和建议: 1. 正…...
目标检测:YOLOv11(Ultralytics)环境配置
1、前言 YOLO11是Ultralytics公司YOLO系列实时目标检测器的最新迭代版本,它以尖端的准确性、速度和效率重新定义了可能实现的性能。在之前YOLO版本取得的显著进步基础上,YOLO11在架构和训练方法上进行了重大改进,使其成为各种计算机视觉任务中…...
如何高效压缩GIF动图?
GIF动图因其兼容性强、易于传播的特点,成为网络交流的热门选择。然而,过大的文件体积常常导致加载缓慢、分享困难等问题。本文将为您详细介绍几种实用的GIF压缩技巧,帮助您在保持画面质量的同时显著减小文件大小。 压缩方法 1. 在线压缩工具…...
视频融合平台EasyCVR可视化AI+视频管理系统,打造轧钢厂智慧安全管理体系
一、背景分析 在轧钢厂,打包机负责线材打包,操作人员需频繁进入内部添加护垫、整理包装、检修调试等。例如,每班产线超过300件,12小时内人员进出打包机区域超过300次。若员工安全意识薄弱、违规操作,未落实安全措施就…...
通过命令行操作把 本地IDE 项目上传到 GitHub(小白快速版)
通过命令行操作把 本地IDE 项目上传到 GitHub(小白版) 你是不是在用 本地IDE 做项目,但不知道怎么把自己的代码上传到 GitHub?今天我们用最简单的命令行方式(不用 SSH、不用复杂配置)教你一步一步把本地项…...
【c语言基础学习】qsort快速排序函数介绍与使用
在C语言中,qsort 函数用于对数组进行快速排序。以下是详细的使用方法及示例: 一、函数原型 #include <stdlib.h>void qsort(void *base, size_t nmemb, size_t size, int (*compar)(const void *, const void *) );二、参数说明 参数说明base指向…...
今日github AI科技工具汇总(20250415更新)
以下是2025年4月15日GitHub上值得关注的AI科技工具汇总及趋势分析,结合最新开源动态与开发者社区热点整理: 一、AI编程工具重大更新 GitHub Copilot Agent Mode 全量发布 核心功能:在VS Code中启用后,可自主完成多文件代码重构、测试驱动开发(TDD)及自修复编译错误,支持…...
程序化广告行业(88/89):广告创意审核的法律红线与平台规则
程序化广告行业(88/89):广告创意审核的法律红线与平台规则 在程序化广告的广阔领域中,不断学习和掌握行业规范是我们稳步前行的基石。一直以来,我都期望与大家携手共进,深入探索这个行业的奥秘。今天&…...
前端VUE框架理论与应用(4)
一、计算属性 模板内的表达式非常便利,但是设计它们的初衷是用于简单运算的。在模板中放入太多的逻辑会让模板过重且难以维护。例如: <div id="example">{{ message.split().reverse().join() }}</div> 在这个地方,模板不再是简单的声明式逻辑。你…...
【经验分享】基于Calcite+MyBatis实现多数据库SQL自动适配:从原理到生产实践
基于CalciteMyBatis实现多数据库SQL自动适配:从原理到生产实践 一、引言:多数据库适配的行业痛点 在当今企业IT环境中,数据库异构性已成为常态。根据DB-Engines最新调研,超过78%的企业同时使用两种以上数据库系统。这种多样性带…...
通信算法之265: 无人机系统中的C2链路
在无人机系统设计中,我们经常听到C2链路这个名词,到底什么是C2链路呢?为什么说C2链路是无人机系统中非常重要的环节。 转载: 无人机技术是各种科技技术水平综合发展的结果,包括空气动力,机械设计…...
浙江大学:DeepSeek如何引领智慧医疗的革新之路?|48页PPT下载方法
导 读INTRODUCTION 随着人工智能技术的飞速发展,DeepSeek等大模型正在引领医疗行业进入一个全新的智慧医疗时代。这些先进的技术不仅正在改变医疗服务的提供方式,还在提高医疗质量和效率方面展现出巨大潜力。 想象一下,当你走进医院ÿ…...
Codeforces Round 1017 (Div. 4)
Codeforces Round 1017 (Div. 4) A. Trippi Troppi AC code: void solve() { string a, b, c; cin >> a >> b >> c;cout << a[0] << b[0] << c[0] << endl; } B. Bobritto Bandito 思路: 倒推模拟即可,…...
bash的特性-bash中的引号
在Linux或Unix系统中,Bash(Bourne Again SHell)作为最常用的命令行解释器之一,提供了强大的功能来处理各种任务。正确使用引号是掌握Bash脚本编写的基础技能之一,它决定了如何解析字符串、变量替换以及特殊字符的行为。…...
ubuntu上SSH防止暴力破解帐号密码
在知道设备ip的情况下,使用 Fail2Ban防止暴力破解 sudo apt install fail2ban 配置 SSH 防护规则 sudo gedit /etc/fail2ban/jail.local jail.local内容如下: [sshd] enabled true port ssh logpath /var/log/auth.log # Ubuntu/Debian maxret…...
【Bluedroid】A2DP Sink播放流程源码分析(二)
接上一篇继续分析:【Bluedroid】A2DP Sink播放流程源码分析(一)_安卓a2dp sink播放流程-CSDN博客 AVDTP接收端(Sink)流事件处理 bta_av_sink_data_cback 是 Bluedroid 中 A2DP Sink 角色的 AVDTP 数据回调函数,负责处理接收端的…...
【Code】《代码整洁之道》笔记-Chapter16-重构SerialDate
第16章 重构SerialDate 如果你找到JCommon类库,深入该类库,其中有个名为org.jfree.date的程序包。在该程序包中,有个名为SerialDate的类,我们即将剖析这个类。 SerialDate的作者是David Gilbert。David显然是一位经验丰富、能力…...
redis 内存中放哪些数据?
在 Java 开发中,Redis 作为高性能内存数据库,通常用于存储高频访问、低延迟要求、短期有效或需要原子操作的数据。以下是 Redis 内存中常见的数据类型及对应的使用场景,适合面试回答: 1. 缓存数据(高频访问,降低数据库压力) 用户会话(Session):存储用户登录状态、临时…...
【Python使用】嘿马云课堂web完整实战项目第4篇:封装异常处理,封装JSON返回值【附代码文档】
教程总体简介:项目概述 项目背景 项目的功能构架 项目的技术架构 CMS 什么是CMS CMS需求分析与工程搭建 静态门户工程搭建 SSI服务端包含技术 页面预览开发 4 添加“页面预览”链接 页面发布 需求分析 技术方案 测试 环境搭建 数据字典 服务端 前端 数据模型 页面原…...
「数据可视化 D3系列」入门第三章:深入理解 Update-Enter-Exit 模式
深入理解 Update-Enter-Exit 模式 一、数据绑定三态:Update、Enter、Exit三种状态的直观理解 二、基础概念1. Update 选区 - 处理已有元素2. Enter 选区 - 处理新增数据3. Exit 选区 - 处理多余元素 三、完整工作流程四、三种状态的底层原理数据绑定过程解析键函数&…...
中间件--ClickHouse-5--架构设计(分布式架构,列式压缩存储、并行计算)
1、整体架构设计 ClickHouse 采用MPP(大规模并行处理)架构,支持分布式计算和存储,其核心设计目标是高性能列式分析。 (1)、存储层 列式存储: 数据按列存储(而非传统行式存储&#…...
AgentGPT 在浏览器中组装、配置和部署自主 AI 代理 入门介绍
AI MCP 系列 AgentGPT-01-入门介绍 Browser-use 是连接你的AI代理与浏览器的最简单方式 AI MCP(大模型上下文)-01-入门介绍 AI MCP(大模型上下文)-02-awesome-mcp-servers 精选的 MCP 服务器 AI MCP(大模型上下文)-03-open webui 介绍 是一个可扩展、功能丰富且用户友好的…...
【开源项目】Excel手撕AI算法深入理解(三):Backpropagation、mamba、RNN
项目源码地址:https://github.com/ImagineAILab/ai-by-hand-excel.git 一、Backpropagation 1. 反向传播的本质 反向传播是通过链式法则计算损失函数对网络参数的梯度的高效算法,目的是用梯度下降优化参数。其核心思想是: 前向传播…...
uniapp的通用页面及组件基本封装
1.基本布局页面 适用于自定义Navbar头部 <template><view class"bar" :style"{height : systemInfo.statusBarHeight px, background: param.barBgColor }"></view><view class"headBox" :style"{ height: param.h…...
Ubuntu和Debian 操作系统的同与异
首先需要说明:Ubuntu 是基于 Debian 操作系统开发的。它们之间的关系如下 起源与发展:Debian 是一个社区驱动的开源 Linux 发行版,始于 1993 年,是最早的 Linux 发行版之一,以其稳定性和自由软件政策著称。Ubuntu 是基…...
【android bluetooth 协议分析 21】【ble 介绍 1】【什么是RPA】
通俗易懂地讲解一下 BLE(低功耗蓝牙)中的 Resolvable Private Address(RPA,可解析私有地址)。 1. 一句话理解 RPA 是一种“临时的、隐私保护的蓝牙设备地址”,别人无法随便追踪你,但“授权的设…...
狂神SQL学习笔记九:MyISAM 和 lnnoDB 区别
show create database school –查看创建数据库的语句 show create table student – 查看student数据表的定义语句 desc student –显示表的结构 MYISAMINNODB事务支持不支持支持数据行锁定不支持支持行锁定外键不支持支持全文索引支持不支持表空间的大小较小较大&#x…...
深度学习--神经网络的构造
在当今数字化时代,深度学习已然成为人工智能领域中最为耀眼的明星。而神经网络作为深度学习的核心架构,其构造方式决定了模型的性能与应用效果。本文将深入探讨深度学习神经网络的构造,带您领略这一前沿技术的奥秘。 一、神经网络基础概念…...
Jenkins 代理自动化-dotnet程序
两种方式 容器部署 本地部署 容器部署 可自动实现,服务器重启,容器自动运行 主要将dockerfile 写好 本地部署 1.服务器重启自动运行代理 参考下面的链接,只是把程序换成 java程序,提前确认好需要的jdk版本 Ubuntu20.04 设置开机…...
【区块链+ 人才服务】“CERX Network”——基于 FISCO BCOS 的研学资源交换网络 | FISCO BCOS 应用案例
CERX Network (Consortium-based Education Resource Exchanging Network) 是定位于面向高校科学研究与教学 的分布式研学资产交换网络, 构建一个用于数据、 算法模型、 论文和课程的研学资源价值流转平台。项目以 FISCO BCOS 联盟链为底层平…...
中间件--ClickHouse-6--SQL基础(类似Mysql,存在差异)
ClickHouse语言类似Mysql,如果熟悉Mysql,那么学习ClickHouse的语言还是比较容易上手的。 1、建表语法(CREATE TABLE) (1)、表引擎(Engine) MySQL: 默认使用 InnoDB 引…...
[MSPM0开发]MSPM0G3507番外一:关于使用外部高速晶振HFXT后程序可能不运行的问题
一、问题描述 如下图所示,MSPM0G3507时钟树配置为使用外部HFXT(外部高速晶振)作为HSCLK时钟源。 配置结果MCLK 40MHz。 另外配置PB22为输出模式,控制外部LED亮灭。 在main.c中主要代码如下: 主要完成延时并翻转LED控…...
2025年计算机领域重大技术突破与行业动态综述
——前沿技术重塑未来,开发者如何把握机遇? 2025年第一季度,全球计算机领域迎来多项里程碑式进展,从量子计算到人工智能,从芯片设计到网络安全,技术革新与产业融合持续加速。本文梳理近三个月内最具影响力…...
我的机器学习之路(初稿)
文章目录 一、机器学习定义二、核心三要素三、算法类型详解1. 监督学习(带标签数据)2. 无监督学习(无标签数据)3. 强化学习(决策优化)(我之后主攻的方向) 四、典型应用场景五、学习路线图六、常见误区警示七…...
交易模式革新:Eagle Trader APP上线,助力自营交易考试效率提升
近年来,金融行业随着投资者需求的日益多样化,衍生出了众多不同的交易方式。例如,为了帮助新手小白建立交易基础,诞生了各类跟单社区;而与此同时,一种备受瞩目的交易方式 —— 自营交易模式,正吸…...
emotn ui桌面tv版官网-emotn ui桌面使用教程
在智能电视和盒子的使用中,出色的桌面系统能大幅提升体验,Emotn UI桌面TV版便是其中的佼佼者。 访问Emotn UI桌面TV版官网,首页简洁清晰,“产品介绍”“下载中心”等板块一目了然。官网对其功能优势详细阐述,在“下载中…...
Django之modelform使用
Django新增修改数据功能优化 目录 1.新增数据功能优化 2.修改数据功能优化 在我们做数据优化处理之前, 我们先回顾下传统的写法, 是如何实现增加修改的。 我们需要在templates里面新建前端的页面, 需要有新增还要删除, 比如说员工数据的新增, 那需要有很多个输入框, 那html…...
Hadoop:大数据时代的基石
在当今数字化浪潮中,数据量呈爆炸式增长,企业和组织面临着前所未有的数据处理挑战。从社交媒体的海量信息到物联网设备的实时数据,如何高效地存储、管理和分析这些数据成为了一个关键问题。Apache Hadoop 作为大数据处理领域的核心框架&#…...
定制开发还是源码搭建?如何快速上线同城外卖跑腿APP?
在“万物皆可同城配送”的时代,同城外卖跑腿APP成为众多创业者和本地服务商的热门选择。无论是打造本地生活服务平台,还是拓展快送业务,拥有一款功能完善、体验流畅的外卖跑腿APP,已经成为进入市场的标配。 然而,对于…...
How AI could empower any business - Andrew Ng
How AI could empower any business - Andrew Ng References 人工智能如何为任何业务提供支持 empower /ɪmˈpaʊə(r)/ vt. 授权;给 (某人) ...的权力;使控制局势;增加 (某人的) 自主权When I think about the rise of AI, I’m reminded …...
SpringBoot-基础特性
1.SpringApplication 1.1.自定义banner 类路径添加banner.txt或设置spring.banner.location就可以定制 banner 1.2.自定义 SpringApplication import org.springframework.boot.Banner; import org.springframework.boot.SpringApplication; import org.springframework.bo…...
系统环境变量有什么实际作用,为什么要配置它
系统环境变量有什么实际作用,为什么要配置它 系统环境变量具有以下重要实际作用: 指定程序路径:操作系统通过环境变量来知晓可执行文件、库文件等的存储位置例如,当你在命令提示符或终端中输入一个命令时,系统会根据环境变量PATH中指定的路径去查找对应的可执行文件。如果…...
C++ | STL之list详解:双向链表的灵活操作与高效实践
引言 std::list 是C STL中基于双向链表实现的顺序容器,擅长高效插入和删除操作,尤其适用于频繁修改中间元素的场景。与std::vector不同,std::list的内存非连续,但提供了稳定的迭代器和灵活的元素管理。本文将全面解析std::list的…...
Spring Cloud 服务间调用深度解析
前言 在构建微服务架构时,服务间的高效通信是至关重要的。Spring Cloud 提供了一套完整的解决方案来实现服务间的调用、负载均衡、服务发现等功能。本文将深入探讨 Spring Cloud 中服务之间的调用机制,并通过源码片段和 Mermaid 图表帮助读者更好地理解…...