首页 程序人生 从零实现富文本编辑器#3-基于Delta的线性数据结构模型

从零实现富文本编辑器#3-基于Delta的线性数据结构模型

数据模型的设计是编辑器的核心基础,其直接影响了选区模型、DOM模型、状态管理等模块的设计。例如在quill中的选区模型是index + len的表达,而slate中则是anchor + focus的表达,这些都是基于数据模型的设计而来的。因此我们从零实现的富文本编辑器就需要从数据模型的设计开始,之后就可以逐步实现其他模块。

开源地址: https://github.com/WindRunnerMax/BlockKit
在线编辑: https://windrunnermax.github.io/BlockKit/
项目笔记: https://github.com/WindRunnerMax/BlockKit/blob/master/NOTE.md
从零实现富文本编辑器项目的相关文章:

深感一无所长,准备试着从零开始写个富文本编辑器
从零实现富文本编辑器#2-基于MVC模式的编辑器架构设计
从零实现富文本编辑器#3-基于Delta的线性数据结构模型
Delta#
在先前的架构设计中我们已经提到了,我们实现的扁平数据结构且独立分包设计,无论是在编辑器中操作,还是在服务端数据解析,都可以更加方便地处理。相比较来说,嵌套的数据结构能够更好地对齐DOM表达,然而这样对于数据的操作却变得更加复杂。

因此在数据结构的设计上,我们是基于quill的delta结构进行了改造。最主要的部分是将其改造为immutable的实现,在编辑器中实际是维护的状态而不是本身的delta结构。并且精简了整个数据模型的表达,将复杂的insert与Attribute类型缩减,那么其操作逻辑的复杂度也会降低。

delta是一种简洁而功能强大的格式,用于描述文档内容及其变化。该格式基于JSON,不仅便于阅读,同时也易于机器解析。通过使用delta描述的内容,可以准确地描述任何富文本文档的内容和格式信息,避免了HTML中常见的歧义和复杂性。

delta由一系列操作组成,这些操作描述了对文档进行的更改。常见的操作包括insert、delete、retain。需要注意的是,这些操作不依赖于具体的索引位置,其总是描述当前索引位置的更改,并且可以通过retain来移动指针位置。

delta既可以表示整个文档,也可以表示对文档进行的更改。那么在这里我们将delta的主要类对象及相关操作逻辑进行描述,特别是在编辑器中实际应用场景,以及主要的改造和相关类型声明。

insert#
insert方法是将数据插入到delta的操作,这就是delta。当描述整个文档内容时,整个数据的内容应该全部都是insert。首个参数是要插入的文本内容,第二个参数是可选的属性对象,用于描述文本的格式信息。

Copy
const delta = new Delta();
delta.insert("123").insert("567", { a: "1" });
// [{"insert":"123"},{"insert":"567","attributes":{"a":"1"}}]
原始的insert参数是可以对象类型的Embed结构,这种结构可以表达Image、Video、Mention等非文本结构的数据,而属性AttributeMap参数是Record<string, unknown>类型,这样用来描述复杂属性值。

在这里我们将其精简了,insert参数仅支持string类型,而具体的schema则在编辑器的初始化时定义,格式信息则收归于Attrs中描述。而AttributeMap则改为Record<string, string>类型,并且可以避免诸如CloneDeep、isEqual等对于复杂数据结构的实现。

其实在EtherPad中就是将Attribute就是[string, string]类型,在这里我们也是使用了类似的设计。在这种基础结构设计下,我们更推荐将属性值扁平地放置于attributes属性中,而不是使用单个属性值作为key,将所有属性值嵌套地放置于value中。

Copy
export interface AttributeMap {
[key: string]: string;
}
delta整个名字通常会用于描述变更,那么除了描述整个文档内容外,当然还可以描述文档内容的变更。不过应用变更的内容需要用到compose,这个方法的描述我们在后边再看。

Copy
const delta1 = new Delta().insert("123");
const delta2 = new Delta().insert("456");
delta1.compose(delta2); // [{"insert":"456123"}]
delete#
delete方法描述了删除内容的长度,由于上述的定义我们现在的内容全部都是文本,在原始的数据定义中嵌入Embed的长度为1。

Copy
const delta = new Delta().insert("123");
delta.compose(new Delta().delete(1)); // [{"insert":"23"}]
其实这里是比较有趣的事情,通过delete来描述变更时,无法得知究竟删除了哪些内容。那么这种情况下,进行invert的时候就需要额外的数据来构造insert操作。类似的场景在OT-JSON中,内容描述是直接写入op的,因此可以直接根据op来进行invert操作。

Copy
const delta = new Delta().insert("123");
const del = new Delta().delete(1);
const invert1 = del.invert(delta); // [{"insert":"1"}]
const delta2 = delta.compose(del); // [{"insert":"23"}]
delta2.compose(invert1); // [{"insert":"123"}]
retain#
retain方法描述了保留内容的长度,换句话说,这个操作可以用来移动指针。

Copy
const delta = new Delta().insert("123");
delta.compose(new Delta().retain(1).insert("a")); // [{"insert":"1a23"}]
同时retain操作也可以用于修改内容的属性值,此外如果想删除某个属性,只需要将value设置为""即可。

Copy
const delta = new Delta().insert("123");
const d2 = new Delta().retain(1).retain(1, { "a": "1" });
const d3 = delta.compose(d2); // [{"insert":"1"},{"insert":"2","attributes":{"a":"1"}},{"insert":"3"}]
d3.compose(new Delta().retain(1).retain(1, { "a": "" })); // [{"insert":"123"}]
push#
push方法是上述的insert、delete、retain依赖的基础方法,主要实现是将内容推入到delta维护的数组中。这里的实现非常重要部分是op的合并,当属性值相同时,则需要将其合并为单个op。

Copy
const delta = new Delta();
delta.push({ insert: "123" }).push({ insert: "456" }); // [{"insert": "123456"}]
当然这里不仅仅是insert操作会合并,对于delete、retain操作也是一样的。这里的合并操作是基于op的attributes属性值,如果attributes属性值不同,则会被视为不同的op,不会自动合并。

Copy
const delta = new Delta();
delta.push({ delete: 1 }).push({ delete: 1 }); // [{"delete": 2}]
const delta2 = new Delta();
delta2.push({ retain: 1 }).push({ retain: 1 }); // [{"retain": 2}]
const delta3 = new Delta();
delta3.push({ retain: 1 }).push({ retain: 1, attributes: { a: "1"} }); // [{"retain": 1}, {"retain": 1, "attributes": {"a": "1"}}]
slice#
slice方法是用于截取delta的操作,这个方法是基于op的length属性值进行截取的。

Copy
const delta = new Delta().insert("123").insert("456", {a: "1"});
delta.slice(2, 4); // [{"insert":"3"},{"insert":"4","attributes":{"a":"1"}}]
eachLine#
eachLine方法用于按行迭代整个delta,我们的整体数据结构是线性的,但是编辑器DOM需要按行来划分内容,因此基于\n来划分行就是比较常规的操作了。

这个方法对于编辑器的初始化非常重要,而当初始化完毕后,我们的变更就需要基于状态来实现,而不是每次都需要经过该方法。在这里我们也对其进行了改造,原始的eachLine方法是不会携带\n节点。

Copy
const delta = new Delta().insert("123\n456\n789");
delta.eachLine((line, attributes) => {
console.log(line, attributes);
});
// [{insert:"123"},{insert:"\n"}] {}
// [{insert:"456"},{insert:"\n"}] {}
// [{insert:"789"},{insert:"\n"}] {}
diff#
diff方法用于对比两个delta之间的差异,这个方法实际上是基于纯文本的myers diff来实现。通过将delta转换为纯文本,在diff过后不断挑选较短的操作部分来实现delta之间的diff。

其实在我们的实现中完全可以将diff方法独立出来,这里唯一引用了外部的fast-diff依赖。在quill中diff是必要的,因为其完全是非受控的输入方式,文本的输入依赖于对DOM文本的diff来实现,而我们的输入是依赖beforeinput事件的半受控输入,因此并不强依赖diff。

Copy
const delta1 = new Delta().insert("123");
const delta2 = new Delta().insert("126");
delta1.diff(delta2); // [{"retain":2},{"insert":"6"},{"delete":1}]
chop#
chop方法用于裁剪末尾的retain操作,当存在末尾的retain且没有属性操作时,其本身是没有意义的,因此可以调用该方法检查并移除。

Copy
const delta = new Delta().insert("123").retain(1);
delta.chop(); // [{"insert":"123"}]
compose#
compose方法可以将两个delta合并为一个delta,具体来说则是将B的delta操作应用到A的delta上,此时返回的是一个新的delta对象。当然在我们的实现中,继承了原始Delta类重写了compose方法,做到了immutable。

compose在编辑器中的应用场景非常多,例如输入事件、内容粘贴、历史操作等场景中,类似于编辑器的apply方法,相当于应用内容变更。

Copy
const delta1 = new Delta().insert("123");
const delta2 = new Delta().retain(1).delete(1);
delta1.compose(delta2); // [{"insert":"13"}]
invert#
invert方法是将delta的操作进行反转,这个方法在历史操作中非常重要,因为本身undo就是需要将当前的操作进行反转。此外,在实现OT的local-cs中,invert也是非常重要的方法。

值得关注的是,上边也提到了delete操作和retain操作在本身执行时是不会记录原始内容的,因此在invert是需要原始的delta作为数据源来进行操作,注意这里的delta是最初的delta,而不是invert后的delta。

Copy
const delta = new Delta().insert("123");
const del = new Delta().delete(1);
const invert1 = del.invert(delta); // [{"insert":"1"}]
const delta2 = delta.compose(del); // [{"insert":"23"}]
delta2.compose(invert1); // [{"insert":"123"}]
concat#
concat方法可以连接两个delta到新的delta中。这个操作与compose不同,compose是将B的操作应用到A上,而concat则是将B的操作追加到A的操作后。

Copy
const delta1 = new Delta().insert("123");
const delta2 = new Delta().insert("456");
delta1.compose(delta2); // [{"insert":"456123"}]
delta1.concat(delta2); // [{"insert":"123456"}]
transform#
transform方法是实现操作OT协同的基础,即使不实现协同编辑,在编辑器中的历史操作模块中也会需要这部分实现。假设我们现在有用户A[uid:1]和用户B[uid:2],此时我们以uid定义优先级,则A的操作优先级高于B,且当前的文档内容为12。

如果是在协同中的话,b'=a.t(b)的意思是,假设a和b都是从相同的draft分支出来的,那么b'就是假设a已经应用了,此时b需要在a的基础上变换出b'才能直接应用,我们也可以理解为transform解决了a操作对b操作造成的影响。

那么我们假设A在12后的位置插入了A字符,B在12后的位置插入了B字符。如果进行协同操作,那么两者相当于同时在同一个位置插入了字符,如果不进行操作变换而直接应用的话,两者的数据就会出现冲突,A的数据是12BA,而B的数据是12AB,因此就需要先转换再应用。

Copy
// User A
const base = new Delta().insert("12");
const delta = new Delta().retain(2).insert("A");
let current = base.compose(delta); // 12A
// Accept Remote B
const remote = new Delta().retain(2).insert("B");
// ob1=OT(oa, ob)
const remote2 = delta.transform(remote, true); // [{"retain":3},{"insert":"B"}]
current = current.compose(remote2); // 12AB
Copy
// User B
const base = new Delta().insert("12");
const delta = new Delta().retain(2).insert("B");
let current = base.compose(delta); // 12B
// Accept Remote A
const remote = new Delta().retain(2).insert("A");
// oa2=OT(ob, oa)
const remote2 = delta.transform(remote, false); // [{"retain":2},{"insert":"A"}]
current = current.compose(remote2); // 12AB
transformPosition#
transformPosition方法用于将指定的位置进行转换,这个方法的主要场景是编辑器中的选区/光标的位置变换,例如光标此时在1后面,构造delta在1之前增加了内容的话,那么光标就需要跟随移动。

Copy
const delta = new Delta().retain(5).insert("a");
delta.transformPosition(4); // 4
delta.transformPosition(5); // 6
OpIterator#
OpIterator类定义一个迭代器,用于迭代delta中的op操作。迭代器大量用于diff、compose、transform等方法中,需要注意的是该迭代器调用next时不会跨越op,即使传递的length大于当前op的长度。

Copy
const delta = new Delta()
.insert("Hello", { bold: "true" })
.insert(" World", { italic: "true" });
.retain(3);
iter.next(2); // { insert: "He", attributes: { bold: "true" } }
iter.next(10); // { insert: "llo", attributes: { bold: "true" } }
EtherPad#
EtherPad同样是非常优秀的协同编辑器,其内置实现的数据结构同样是线性的,文档整体描述称为ClientVars,数据结构变更被称为ChangeSet。协同算法的实现是EasySync,且其文档中对如何进行服务端调度也有较为详细的描述。

ClientVars/Changeset同样是一种基于JSON的数据格式,用于描述文档的内容及其变更。但是其并没有像Delta那么清晰的表达,JSON结构主要是AttributePool内,而对于文本内容的表达则是纯文本的结构。

文档描述#
文档内容是使用ClientVars的数据结构表示的,其中包含了三个部分,apool文本属性池、text文本内容、attribs属性描述。在下面的例子中,我们描述了标题、加粗、斜体、纯文本的内容,那么这其中的内容如下所示。

Copy
({
initialAttributedText: {
text: "short description\n*Heading1\ntext\n*Heading2\nbold italic\nplain text\n\n",
attribs: "*0|1+i*0*1*2*3+1*0|2+e*0*4*2*3+1*0|1+9*0*5+4*0+1*0*6+6*0|2+c|1+1",
},
apool: {
numToAttrib: {
"0": ["author", "a.XYe86foM7oYgmpuu"],
"1": ["heading", "h1"],
"2": ["insertorder", "first"],
"3": ["lmkr", "1"],
"4": ["heading", "h2"],
"5": ["bold", "true"],
"6": ["italic", "true"],
},
nextNum: 7,
},
});
对于这个内容直接看上去是比较复杂的,当然实际上也是比较复杂的。apool是一个属性池,所有对于文本内容的装饰都是在这里存储的,也就是其中的numToAttrib属性存储的[string, string]值,nextNum则是下个要放置的索引。text则是纯文本的内容,相当于此时文档的纯文本内容。attribs则是根据text的纯文本内容,并且取得apool中的属性值,相当于装饰文本内容的编码。

因此attribs需要单独拿出来解析,*n表示取第n个属性应用到文本上,通常需要配合|n和+n属性使用,|n表示影响n行,仅对于\n属性需要使用该属性,+n则表示取出n个字符数,相当于retain操作,不仅可以移动指针,还可以用来持有属性变更。特别需要注意的是|m不会单独出现,其总是与+n一同表达,表示这n个字符中存在m个换行,且最后一个应用的字符必然是\n。

此外,EasySync里面的数字大都是36进制的,因此这里的+i/+e等都不是特殊的符号,需要用0-9数字来表示0-9的字符,而10-35则是表示a-z,例如+i就是i - a = 8 => 8 + 10 = 18。

*0表示取出author的属性,|1+i表示将其应用到了i长度即18,字符为short description\n,由于其包含\n则定义|1。
*0*1*2*3表示取出前4个属性,+1表示将其应用1个字符,即*字符,在EasySync中行首的该字符承载了行属性,而非放置\n中。
*0表示取出author的属性,|2+e表示应用了e长度即14,字符为Heading1\ntext\n,其包含两个\n则定义|2。
*0*4*2*3表示取出相关属性,+1表示将其应用1个字符,即*字符表示行属性内容。
*0|1+9表示取出author的属性,+9表示将其应用9个字符,即Heading2\n,末尾是\n则定义|1。
*0*5+4表示取出加粗等属性,应用4个字符,即bold。
*0+1表示取出author的属性,应用1个字符即空格。
*0*6+6表示取出斜体等属性,应用6个字符,即italic。
*0|2+c表示取出相关属性,应用12个字符即\nplain text\n,存在两个\n则定义|2。
|1+1表示末尾的\n属性,在EasySync中行末的该字符需要单独定义。
变更描述#
OT操作变换的基准原子实现就是insert、delete、retain三种操作,那么ChangeSet的内容描述自然也是类似,但是数据的变更描述并非像delta的结构那么清晰,而是特别设计了一套数据结构描述。

文档在最开始创建或是导入的时候是初始的ClientVars,而此后每次对于文档内容的修改则是会生成ChangeSet。针对于上述的三种操作对应了三种符号,=表示retain、+表示insert、-表示delete,这三种符号的组合就可以描述文档内容的变更,除此之外还有额外的定义:

Z: 首字母为MagicNumber,表示为符号位。
:N: 文档原始内容长度为N。
>N: 最终文档长度会比原始文档长度长N。
<N: 最终文档长度会比原始文档长度短N。
+N: 实际执行操作,表示插入了N个字符。
-N: 实际实际操作,表示操作删除了N个字符。
=N: 实际执行操作,表示操作保留了N个字符,移动指针或应用属性。
|N: 表示影响了N行,与上述文档描述一致需要与+/-/=N使用,操作的长度包含N个\n,且末尾操作必须是\n。文档最末尾的\n需要表示的话,则必须要用|1=1表示。
*I: 表示应用属性,I为apool中的索引值,在一个+、=或|之前可以有任意数量的*操作。
$: 表示结束符号,用于标记Operation部分的结束。
char bank: 用于存储insert操作具体的字符内容,在执行插入操作时按序取用。
同样是上述的例子,现在的文档中已经存在exist text\n\n的文本内容,紧接着将上述的内容粘贴到文档中,那么发生的User ChangeSet的变更描述如下:

Copy
({
changeset:
"Z:c>1t|1=b*0|1+i*0*1*2*3+1*0|2+e*0*4*2*3+1*0|1+9*0+5*0*5+6*0|1+1*0+a$short description\n*Heading1\ntext\n*Heading2\nbold italic\nplain text",
apool: {
numToAttrib: {
"0": ["author", "a.XYe86foM7oYgmpuu"],
"1": ["heading", "h1"],
"2": ["insertorder", "first"],
"3": ["lmkr", "1"],
"4": ["heading", "h2"],
"5": ["italic", "true"],
},
nextNum: 6,
},
});
Z表示MagicNumber,即符号位。
c表示文档原始内容长度为12,即exist text\n\n内容长度。
>1t表示最终文档会比原始内容长度多1t,36进制转换1t为64,具体为char bank索引。
|1=b表示移动指针长度为b,转换长度为11,文本内容为exist text\n,末尾为\n定义|1。
*0|1+i表示从apool中取出0属性,应用i转换长度为18,文本内容为short description\n,末尾为\n定义|1。
*0*1*2*3+1表示取出4个属性,应用为1,文本内容为*,具体则是行属性的起始标记。
*0|2+e表示取出0属性,应用e转换长度为14,文本内容为Heading1\ntext\n,末尾为\n且包含两个\n定义|2。
*0*4*2*3+1表示取出4个属性,应用为1,文本内容为*,同样是行属性的起始标记。
*0|1+9表示取出0属性,应用长度为9,文本内容为Heading2\n,末尾为\n定义|1。
*0+5表示取出0属性,应用长度为5,文本内容为bold。
*0*5+6表示取出斜体等属性,应用长度为6,文本内容为italic。
*0|1+1表示取出0属性,应用长度为1,末尾为\n则定义|1,文本内容为\n。
*0+a表示取出0属性,应用长度为a即10,文本内容为plain text。
$表示结束符号,后续的内容符号则为char bank,最末尾的\n通常不需要表示,即使表示也需要|1=1单独表示。
Slate#
slate的数据结构以及选区的设计几乎完全对齐了DOM结构,且数据结构设计并未独立出来,同样基于JSON的结构,非常类似于低代码的结构设计。操作变换是直接在slate的核心模块Transform中实现,且位置相关操作变换的实现分散在Point、Path对象中。

Copy
[
{
type: "paragraph",
children: [
{ text: "This is editable " },
{ text: "rich", bold: true },
{ text: " text." },
],
},
{ type: "block-quote", children: [{ text: "A wise quote." }] },
];
Operation#
同样是基于OT实现操作变换算法,线性的数据结构仅需要insert、delete、retain三种基本操作即可实现,而在slate中则实现了9种原子操作来描述变更,这其中包含了文本处理、节点处理、选区变换的操作等。

insert_node: 插入节点。
insert_text: 插入文本。
merge_node: 合并节点。
move_node: 移动节点。
remove_node: 移除节点。
remove_text: 移除文本。
set_node: 设置节点。
set_selection: 设置选区。
split_node: 分割节点。
实际上仅实现应用还好,其相对应的invert、transform则会更加复杂。在slate中的inverse相关操作在operation.ts中实现,与位置相关的transform在path.ts、point.ts中有相关实现。

而实际上这些操作通常都不会在编辑器中直接调用,slate针对这些最基础的操作进行了封装,实现了Transforms模块。在这个模块中实现了诸多具体的操作,例如insertNodes、liftNodes、mergeNodes、moveNodes、removeNodes等等,这里的操作就远不止9种类型了。

insertFragment: 在指定的位置插入节点的片段。
insertNodes: 在指定的位置插入节点。
removeNodes: 在文档中指定的位置删除节点。
mergeNodes: 在某个节点与同级的前节点合并。
splitNodes: 在某个节点中的指定位置分割节点。
wrapNodes: 在某个节点中的指定位置包裹一层节点。
unwrapNodes: 在某个节点中的指定位置解除一层包裹节点。
setNodes: 在某个节点中的指定位置设置节点属性。
unsetNodes: 在某个节点中的指定位置取消节点属性。
liftNodes: 在某个节点中的指定位置提升一层节点。
moveNodes: 在文档中的指定位置移动节点。
collapse: 将选区折叠为插入符。
select: 主动设置选区位置。
deselect: 取消选区位置。
move: 移动选区位置。
setPoint: 设置选区的单侧位置。
setSelection: 设置新选区位置。
delete: 删除选区内容。
insertText: 在选区位置插入文本。
transform: 在编辑器上immutable地执行op。
OT-JSON#
类似的,在OT-JSON(json0)中实现了11种操作,富文本场景中SubType仍然需要扩展,那自然就需要更多的操作来描述变更。因此,实际上以JSON嵌套的数据格式来描述内容变更,要比线形的操作复杂得多。

在slate中是自行封装了编辑器的基础op,如果其本身是在OT-JSON的基础上封装Transforms的话,对于实现OT的协同会更方便一些,ShareDB等协同框架都是要参考OTTypes的定义的。当然,基于CRDT实现的协同看起来更加容易处理。

{p:[path], na:x}: 在指定的路径[path]值上加x数值。
{p:[path,idx], li:obj}: 在列表[path]的索引idx前插入对象obj。
{p:[path,idx], ld:obj}: 从列表[path]的索引idx中删除对象obj。
{p:[path,idx], ld:before, li:after}: 用对象after替换列表[path]中索引idx的对象before。
{p:[path,idx1], lm:idx2}: 将列表[path]中索引idx1的对象移动到索引idx2处。
{p:[path,key], oi:obj}: 向路径[path]中的对象添加键key和对象obj。
{p:[path,key], od:obj}: 从路径[path]中的对象中删除键key和值obj。
{p:[path,key], od:before, oi:after}: 用对象after替换路径[path]中键key的对象before。
{p:[path], t:subtype, o:subtypeOp}: 对路径[path]中的对象应用类型为t的子操作o,子类型操作。
{p:[path,offset], si:s}: 在路径[path]的字符串的偏移量offset处插入字符串s,内部使用子类型。
{p:[path,offset], sd:s}: 从路径[path]的字符串的偏移量offset处删除字符串s,内部使用子类型。
总结#
数据结构的设计是非常重要的,对于编辑器来说,数据结构的设计直接影响着选区模型、DOM模型、状态管理等模块的设计。在这里我们聊到了很多的数据结构设计,Delta、Changeset的线性结构,Slate的嵌套结构,每种数据都有着各自的设计与考量。

那么在选定好了数据结构后,就可以在此基础上实现编辑器的各个模块。我们接下来会从数据模型出发,设计选区模型的表示,然后在此基础上实现浏览器选区与编辑器选区模型的同步。通过选区模型作为操作的目标,来实现编辑器的基础操作,例如插入、删除、格式化等操作。

每日一题#
https://github.com/WindRunnerMax/EveryDay
参考#
https://github.com/slab/delta/blob/main/src/Delta.ts
https://github.com/slab/delta/blob/main/src/AttributeMap.ts
https://github.com/ether/etherpad-lite/tree/develop/doc/public/easysync
https://github.com/ether/etherpad-lite/blob/develop/src/static/js/Changeset.ts
https://github.com/ether/etherpad-lite/blob/develop/src/static/js/AttributePool.ts
https://github.com/ianstormtaylor/slate/blob/main/packages/slate/src/interfaces/operation.ts
https://github.com/ianstormtaylor/slate/blob/main/packages/slate/src/interfaces/transforms/general.ts

站心网

数据模型的设计是编辑器的核心基础,其直接影响了选区模型、DOM模型、状态管理等模块的设计。例如在quill中..

为您推荐

交易系统:退款单模型设计详解

大家好,我是汤师爷~和退款单作为整个交易逆向系统的核心,支撑着售后管理环节。售后域核心概念模型1、退款单退款单是记录和跟踪退款处理过程的核心业务单据,包含以下关键信息:租户ID:标识所属商户或组织退款单ID..

Redis 数据结构详解

Redis 数据类型分为:字符串类型、散列类型、列表类型、集合类型、有序集合类型。Redis 这么火,它运行有多块?一台普通的笔记本电脑,可以在1秒钟内完成十万次的读写操作。原子操作:最小的操作单位,不能继续拆分..

通过js修改tinymce的编辑器的内容

在网页开发中,TinyMCE是一个流行的富文本编辑器。它允许用户轻松地创建和编辑HTML内容,而无需直接操作代码。然而,有时我们可能需要通过JavaScript来动态修改编辑器中的内容。本文将介绍如何使用JavaScript来修改T..

Tesseractjs 前端OCR识别提取图像文本字符工具 支持 100+ 种语言

Tesseract.js 简介Tesseract.js 是一个基于 Tesseract OCR 引擎的开源 JavaScript 库,用于在浏览器和 Node.js 环境中执行光学字符识别 (OCR)。它的特点是无需依赖服务器端支持,完全在客户端执行 OCR 操作。Tessera..

微软发布VS Code AI工具包,集成多模型AI能力

微软发布了VS Code AI工具包,增强了代码编辑器的AI功能!微软推出了VS Code AI工具包,这是一个全新的扩展,旨在将AI功能集成到Visual Studio Code中。该工具包支持多种AI模型,包括本地和远程模型,用户可以配置自..

.NET Core 管道模型中间件及管道模拟实现

管道,PipelineASP.NET Core 路由,认证,绘画,缓存,都是由管道来处理的中间件。MVC WEB API,都是建立在某个特殊的中间件之上。MVC,路由的中间件,请求地址和控制器之间的映射,在此基础上实现了实例化控制器,..

AI文本工具网站有哪些?

AI 文本工具是一类利用人工智能技术实现自然语言处理的在线工具。以下是一些常见的 AI 文本工具站,以及它们的简单介绍和官方网址:OpenAI GPT-3 PlaygroundOpenAI GPT-3 Playground 是由 OpenAI 推出的一个在线 AI ..

AI文本工具合集分享

给大家分享一些比较火的AI文本工具,让人工智能帮助你加轻松高效地完成文本类工作。ChatGPT:官网: https://openai.com/chat-api/功能介绍: ChatGPT是一种基于GPT(Generative Pre-trained Transformer)的自然语言处..

vercel发布免费AI工具 支持GPT3.5 GPT-4 和claude等模型

vercel 免费playground地址:https://play.vercel.ai/无需登录,无需翻墙就可以免费使用vercel AI工具,支持GPT3.5,GPT-4 和 claude 等20多个模型,下面是我是用的截图。速度也很快,国内可以直接使用。..

AI文本工具站分享

以下是一些常见的AI文本工具站,以及它们的简要介绍和官方网站链接:OpenAI GPT-3 Playground:简介:OpenAI的GPT-3 Playground允许用户与GPT-3模型进行交互,进行文本生成和对话。官网:https://beta.openai.com/sig..

开源中文LLaMA2模型 Chinese-Llama-2-7b

Meta开源了LLaMA2模型,但是官方的LLaMA2对中文的支持度不是特别充分,Chinese Llama 2 7B号称是开源社区第一个能下载、能运行的中文 LLaMA2 模型。Chinese Llama 2 7B全部开源,完全可商用的中文版 Llama2 模型及中..

C#把文本中的data:image转成图片

在C#中,要将文本中的 data:image 数据转换为图片,你需要执行以下步骤:解码 Base64 字符串: data:image URI 包含了图像的 Base64 编码数据,因此首先需要提取 Base64 部分,并对其进行解码。保存为图片文件: 解..

JS实现类似文本contains的方法,用indexOf实现

有个需求想用JS筛选页面上的一些文本,对匹配的内容进行高亮显示。需要实现类似c#中的对字符串的contains的功能~str.contains("substr");但是javascript里面没有这个方法去判断字符串str是不是包含substr,而js提供..

LangChain开发环境部署AI大模型

当下在AI应用开发领域,LangChain框架可以说是唯一选择。然而,上手学习的小伙伴们大多被拦在了第一步,没有可供使用的AI大模型底座,没有可供实验的环境进行实操。而各大厂商比如OpenAI提供的API需要付费不说,从注..

批量打开电脑上txt文本中的网址

新建一个 web.txt文件在文件中输入如下代码@echoofffor/f"delims="%%ain(url.txt)dostart%%a然后把 web.txt重命名为web.bat创建url.txt文本,把网址一行一个粘贴到文本中,然后运行web.bat文件即可批量打开网址。..

C#使用System.Speech朗读文本

System.Speech 命名空间提供了在 WinForms 应用程序中进行语音合成(TTS)的功能。您可以使用 System.Speech 中的类和方法来读取文本并将其转换为语音。nuget 安装System.Speech常用属性和方法以下是 System.Speech ..

清华启动ChatGLM对话模型内测

清华大学计算机系技术成果转化公司智谱AI于近日开源了General Language Model (通用语言模型,GLM)系列模型的新成员——中英双语对话模型ChatGLM-6B,支持在单张消费级显卡上进行推理使用。这是继此前开源..

腾讯混元大模型官网发布 价格每1000 token收费0.14元

在发布会上,腾讯宣布混元大模型今日起对外开放,用户可通过腾讯云进行体验,支持直接调用 API 接口,或者将混元作为基底模型,在公有云上进行精调。腾讯混元大模型官网:https://cloud.tencent.com/product/hunyuan..

Tuesday JS视觉小说文本游戏引擎官网

Tuesday JS 是一个免费的开源视觉小说引擎,用于创建视觉小说、互动故事和基于文本的冒险游戏。Tuesday JS 使用JavaScript编写,无需使用任何第三方库。 可以将项目导出到Apache Cordova,以创建移动应用程序或PC程..

使用Ollama在本地安装Google轻量级开源大模型Gemma

Google轻量级开源大模型Gemma在个人电脑上也可以部署体验了!以下是详细部署步骤:一、安装 Ollama1.访问 Ollama 官方网站(https://ollama.com/)。2.下载适合操作系统的 Ollama 版本。3.在 Windows 上,下载后直接..

发表回复

返回顶部

微信分享

微信分享二维码

扫描二维码分享到微信或朋友圈

链接已复制