先前我们总结了浏览器选区模型的交互策略,并且实现了基本的选区操作,还调研了自绘选区的实现。那么相对的,我们还需要设计编辑器的选区表达,也可以称为模型选区,编辑器中应用变更时的操作范围,就是以模型选区为基准来实现的。在这里我们就以编辑器状态为基础,来设计模型选区的结构表达。
- 开源地址: https://github.com/WindRunnerMax/BlockKit
- 在线编辑: https://windrunnermax.github.io/BlockKit/
- 项目笔记: https://github.com/WindRunnerMax/BlockKit/blob/master/NOTE.md
从零实现富文本编辑器项目的相关文章:
- 深感一无所长,准备试着从零开始写个富文本编辑器
- 从零实现富文本编辑器#2-基于MVC模式的编辑器架构设计
- 从零实现富文本编辑器#3-基于Delta的线性数据结构模型
- 从零实现富文本编辑器#4-浏览器选区模型的核心交互策略
- 从零实现富文本编辑器#5-编辑器选区模型的状态结构表达
编辑器选区模型
在编辑器中的选区模型设计是涉及面比较广的命题,因为其作为应用变更或者称为执行命令的基础范围,涉及到了广泛的模块交互。虽然本质上是需要其他模块需要适配选区模型的表达,但明显的如果选区模型不够清晰的话,其他模块的适配工作就会变得复杂,交互自然是需要相互配合来实现的。
而选区模型最直接依赖的就是编辑器的状态模型,而状态模型的设计又非常依赖数据结构的设计。因此,在这里我们会从数据结构以及状态模型的角度出发,先来调研一下当前主流编辑器的选区模型设计。
Quill
Quill是一个现代富文本编辑器,具备良好的兼容性及强大的可扩展性,还提供了部分开箱即用的功能。Quill的出现给富文本编辑器带了很多新的东西,也是目前开源编辑器里面受众非常大的一款编辑器,至今为止的生态已经非常的丰富,目前也推出了2.0版本。
Quill的数据结构表达是基于Delta实现的,当然既然其名为Delta,那么数据的变更也可以基于Delta来实现。那么使用Delta表达基本的富文本数据结构如下所示,可以观察到其不会存在嵌套的数据结构表达,所有内容以及格式表达都是线性的。- {
- ops: [
- { insert: "这样" },
- { insert: "一段文本", attributes: { italic: true } },
- { insert: "的" },
- { insert: "数据结构", attributes: { bold: true } },
- { insert: "如下所示。\n" },
- ],
- };
复制代码 既然数据结构是扁平的表达,那么选区的表达也不需要复杂的结构来实现。直观感受上来说,扁平化结构要特殊处理的Case会更少,状态结构会更好维护,编辑器架构会更加轻量。通常编辑器的选区也会构造Range对象,那么在Quill中的选区结构如下所示:- {
- index: 5, // 光标位置
- length: 10, // 选区长度
- }
复制代码 选区作为编辑器变更的应用范围,不可避免地需要提及应用变更的操作。在应用变更时,自然需要进行状态结构的遍历,以取出需要变更的节点来实际应用变更。那么自然而然地,扁平的结构本身的顺序就是渲染的顺序,不需要嵌套的结构来进行递归地查找,查找的效率自然会更高。
此外,Quill的视图层是重新设计了一套模型parchment,维护了视图和状态模型,虽然在仓库中通过继承的方式重写了部分类结构,例如ScrollBlot类。但是在阅读源码的时候难以确定很多视图模块设计意图,所以对于DOM事件与状态模型的交互暂时还没有很好的理解。
Slate
Slate是一个高度灵活、完全可定制的富文本编辑器框架,其提供了一套核心模型和API,让开发者能够深度定制编辑器的行为,其更像是富文本编辑器的引擎,而不是开箱即用的组件。Slate经历过一次大版本重构,虽然目前仍然处于Beta状态,但是已经有相当多的线上服务使用。
Slate的数据结构表达就不是扁平的内容表示的,依据其TS类型的定义,Node类型是Editor | Element | Text三种类型的元组。当然抛开Editor本身不谈,我们从下面的内容描述上可以很容易看出来Element和Text类型的定义。- {
- type: "paragraph",
- children: [
- { text: "这样" },
- { text: "一段文本", italic: true },
- { text: "的" },
- { text: "数据结构", bold: true },
- { text: "如下所示。" },
- ]
- }
复制代码 其中children属性可以认为是Element类型的节点,而text属性则是Text类型的节点,Element类型的节点可以无限嵌套Element类型以及Text类型节点。那么这种情况下,扁平的选区表达就无法承载这样的结构,因此Slate的选区表达是用端点来实现的。- {
- anchor: { path: [0, 1], offset: 2 }, // 起始位置
- focus: { path: [0, 3], offset: 4 }, // 结束位置
- }
复制代码 这种选区的表达是不是非常眼熟,没错Slate的选区表达是完全与浏览器选区对齐的,因为其从最开始的设计原则就是与DOM对齐的。说句题外的话,Slate对于数据结构的表现也是完全与选区对齐的,例如表达图片时必须要放置一个空的Text作为零宽字符。- {
- type: "image",
- url: "https://example.com/image.png",
- children: [{ text: "" }]
- }
复制代码-
- \u200B
-
-
- <img src="https://example.com/image.png">
-
复制代码 除了类似于图片的Void节点表达,针对于行内的Void节点,更能够表现其内建维护的数据结构是完全对齐DOM的。例如下面的Mention结构表达,由于需要在两侧放置光标,因此在DOM中引入了两个零宽字符,此时内建数据结构也维护了两个Text节点。- [
- { text: "" },
- {
- type: "mention",
- user: "Mace",
- children: [{ text: "" }],
- },
- { text: "" },
- ];
复制代码- <p data-slate-node="element">
-
- \u200B
-
-
-
- \u200B
-
- @Mace
-
-
- \u200B
-
- </p>
复制代码 说回选区模型,Slate在应用数据变更时,同样需要依赖两个端点来进行遍历查找,特别是进行诸如setNode等操作时。那么查找就非常依赖渲染顺序来决定,由于文档整体结构还是二维的,因此通过path + offset对比找到起始/结束的节点,然后从首节点开始递归遍历查找到尾节点就可以了。
嵌套的数据结构针对于批量的变更整体会变得更复杂,因为Op之间的索引关系需要维护,这就依赖transform的实现。但是,针对于单个Op的变更实际上是更清晰的,类似于Delta的变更是不容易非常针对性地处理单个操作变更,而JSON结构下的单次变更非常清晰。
transform这部分也是核心实现,先前基于OT-JSON以及Immer实现的低代码场景的状态管理方案也提到过。单个Op变更时通常不会影响Path,批量的Op变更时是需要考虑到其间相互影响的关系的,例如在Slate的remove-nodes实现中,通过ref维护的影响关系。- // packages/slate/src/transforms-node/remove-nodes.ts
- const depths = Editor.nodes(editor, { at, match, mode, voids })
- const pathRefs = Array.from(depths, ([, p]) => Editor.pathRef(editor, p))
- for (const pathRef of pathRefs) {
- const path = pathRef.unref()!
- if (path) {
- const [node] = Editor.node(editor, path)
- editor.apply({ type: 'remove_node', path, node })
- }
- }
复制代码 Lexical
Lexical是Meta开源的现代化富文本编辑器框架,专注于提供极致的性能、可访问性和可靠性。其作为Draft.js的继任者,提供了更好的可扩展性和灵活性。特别的,Lexical还提供了IOS的原生支持,基于Swift/TextKit编写的可扩展文本编辑器,共享Lexical设计理念与API。
Lexical的数据结构与Slate类似,都是基于树形的嵌套结构来表达内容。但是其整体设计上增加了很多的预设内容,并没有像Slate设计为足够灵活的编辑器引擎,从下面的数据结构表达中也可以看出并没有那么简洁,这已经是精简过后的内容,原本还存在诸如detail等属性。- [
- {
- children: [
- { format: 0, mode: "normal", text: "这样", type: "text" },
- { format: 2, mode: "normal", text: "一段文本", type: "text" },
- { format: 0, mode: "normal", text: "的", type: "text" },
- { format: 1, mode: "normal", text: "数据结构", type: "text" },
- { format: 0, mode: "normal", text: "如下所示。", type: "text" },
- ],
- direction: "ltr",
- indent: 0,
- type: "paragraph",
- version: 1,
- textFormat: 0,
- },
- ]
复制代码 由于其本身是嵌套的数据结构,那么选区的表达也类似于Slate的实现。只不过Lexical的选区类似于ProseMirror的实现,将选区分为了RangeSelection、NodeSelection、TableSelection、null选区类型。
其中Table以及null的选区类型比较特殊,我们就暂且不论。而针对于Range、Node实际上是可以同构表达的,Node是针对于Void类型如图片、分割线的表达,而类似于Quill以及Slate是将其直接融入Range的表达,以零宽字符文本作为选区落点。
实际上特殊的表达自然是有特殊的含义,Quill以及Slate通过零宽字符同构了选区的文本表达,而Lexical的设计是不存在零宽字符占位的,所以无法直接同构文本选区表达。那么对于Lexical的选区,基础的文本RangeSelection选区如下所示:- {
- anchor: { key: "51", offset: 2, type: "text" },
- focus: { key: "51", offset: 3, type: "text" }
- }
复制代码 这里可以看出来虽然数据结构与Slate类似,但是path这部分被替换成了key,而需要注意的是,在我们先前展示的数据结构中是没有key这个标识的。也就是说这个key值是临时的状态值而不是维护在数据结构内的,虽然看起来这个key很像是数字,实际上是字符串来表达唯一id。- // packages/lexical/src/LexicalUtils.ts
- let keyCounter = 1;
- export function generateRandomKey(): string {
- return '' + keyCounter++;
- }
复制代码 实际上这种id生成的方式在很多地方都存在,包括Slate以及我们的BlockKit中,而恰好我们都是通过这种方式来生成key值的。因为我们的视图层是通过React来渲染的,因此不可避免地需要维护唯一的key值,而在Lexical中key值还维护了状态映射的作用。
那么这里的key值本质上是维护了id到path的关系,我们应该可以直接通过key值取得渲染的节点状态。并且通过这种方式实际上就相当于借助了临时的path做到了任意key字符串可比较,并且可以沿着相对应的nextSibling剪枝查找到尾节点,其实这里让我想到了偏序关系的维护。- // packages/lexical/src/LexicalSelection.ts
- const cachedNodes = this._cachedNodes;
- if (cachedNodes !== null) {
- return cachedNodes;
- }
- const range = $getCaretRangeInDirection(
- $caretRangeFromSelection(this),
- 'next',
- );
- const nodes = $getNodesFromCaretRangeCompat(range);
- return nodes;
复制代码 在这里比较重要的是key值变更时的状态保持,因为编辑器的内容实际上是需要编辑的。然而如果做到immutable话,很明显直接根据状态对象的引用来映射key会导致整个编辑器DOM无效的重建。例如调整标题的等级,就由于整个行key的变化导致整行重建。
那么如何尽可能地复用key值就成了需要研究的问题,我们的编辑器行级别的key是被特殊维护的,即实现了immutable以及key值复用。而叶子状态的key依赖了index值,因此如果调研Lexical的实现,同样可以将其应用到我们的key值维护中。
通过在playground中调试可以发现,即使我们不能得知其是否为immutable的实现,依然可以发现Lexical的key是以一种偏左的方式维护。因此在我们的编辑器实现中,也可以借助同样的方式,合并直接以左值为准复用,拆分时若以0起始直接复用,起始非0则创建新key。
- [123456(key1)][789(bold-key2)]文本,将789的加粗取消,整段文本的key值保持为key1。
- [123456789(key1)]]文本,将789这段文本加粗,左侧123456文本的key值保持为key1,789则是新的key。
- [123456789(key1)]]文本,将123这段文本加粗,左侧123文本的key值保持为key1,456789则是新的key。
- [123456789(key1)]]文本,将456这段文本加粗,左侧123文本的key值保持为key1,456和789分别是新的key。
说起来,Lexical其实并不是完全由React作为视图层的,其仅仅是可以支持React组件节点的挂载。而主要的DOM节点,例如加粗、标题等格式还是自行实现的视图层,判断是否是通过React渲染的方式很简单,通过控制台查看是否存在类似__reactFiber$的属性即可。- // Slate
- __reactFiber$lercf2nvv2a: {}
- __reactProps$lercf2nvv2a: {}
- // Lexical
- __lexicalDir: "ltr"
- __lexicalDirTextContent: "这样一段文本的数据结构如下所示。"
- __lexicalKey_siggg: "47"
- __lexicalTextContent: "这样一段文本的数据结构如下所示。\n\n"
复制代码 飞书文档
飞书文档是基于Blocks的设计思想实现的富文本编辑器,其作为飞书的核心应用之一,提供了强大的文档编辑功能。飞书文档深度融合文档、表格、思维笔记等组件,支持多人云端实时协作、深度集成飞书套件、高度移动端适配,是非常不错的商业产品。
飞书文档是商业化的产品,并非开源的编辑器,因此数据结构只能从接口来查阅。如果直接查阅飞书的SSR数据结构,可以发现其基本内容如下,直接在控制台上输出DATA即可。当然由于文本数据是EtherPad的EasySync协同算法的数据,这部分在Delta数据结构的文章也介绍过。- {
- doxcnTYlsMboJlTMcxwXerq6wqc: {
- id: "doxcnTYlsMboJlTMcxwXerq6wqc",
- data: {
- children: ["doxcnXFzVSkuH3XoiT3mqXvOx4b"],
- },
- },
- doxcnXFzVSkuH3XoiT3mqXvOx4b: {
- id: "doxcnXFzVSkuH3XoiT3mqXvOx4b",
- data: {
- type: "text",
- parent_id: "doxcnTYlsMboJlTMcxwXerq6wqc",
- text: {
- apool: {
- nextNum: 3,
- numToAttrib: { "1": ["italic", "true"], "2": ["bold", "true"] },
- },
- initialAttributedTexts: {
- attribs: { "0": "*0+2*0*1+4*0+1*0*2+4*0+5" },
- text: { "0": "这样一段文本的数据结构如下所示。" },
- },
- },
- },
- },
- }
复制代码 当然这个数据结构看起来比较复杂,我们也可以直接从飞书开放平台的服务端API的获取文档所有块中,响应示例中得到整个数据结构的概览。当然看起来这部分数据结构是经历过一次数据转换的,并非直接将数据结构直接响应。其实这部分结构可以认为跟Slate的树结构类似,但每行都是独立实例。
当然看起来飞书文档是扁平化的结构,但是实际上构建出来的状态值还是树形的结构,只不过是使用id来实现类似链式结构,从而可以构建整个树结构。那么对于飞书文档的选区结构来说,自然也是需要适配数据结构状态的模型。对于文本选区而言,飞书文档的结构如下:- // PageMain.editor.selectionAPI.getSelection()
- [
- { id: 2, type: "text", selection: { start: 3, end: 16 } },
- { id: 6, type: "text", selection: { start: 0, end: 2 } },
- { id: 7, type: "text", selection: { start: 0, end: 1 } },
- ];
复制代码 既然飞书文档是Blocks的块结构设计,那么仅仅是纯文本的选区维护自然是不够的。那么在块结构的选区表达,飞书的选区表达如下所示。说起来,飞书文档的结构选区如果执行跨级别的块选区操作,那么飞书文档会在抬起鼠标的时候会将更深层级的块选区合并为同层级的选区。- // PageMain.editor.selectionAPI.getSelection()
- [
- { id: 2, type: "block" },
- { id: 6, type: "block" },
- { id: 7, type: "block" },
- ];
复制代码 针对深度层级的Blocks级别文本选区,类似Editor.js完全不支持文本的跨节点选中;飞书文档支持跨行的文本选区,但是在跨层级的文本选区会提升为同级块选区;Slate/Lexical等编辑器则是支持跨层级的文本选区,当然其本身并非Blocks设计的编辑器,主要是支持节点嵌套。
基于Blocks思想设计的编辑器其实更类似于低代码的设计,相当于实现了一套受限的低代码引擎,准确来说是无代码引擎。这里的受限主要是指的不会像图形画板那么灵活的拖拽操作,而是基于某些规则设计下的编辑器形式,例如块组件需要独占一行、结构不能任意嵌套等。
其实在这种嵌套的数据结构下,选区的表达方式自然有很多可行的表达。飞书文档的同层级提升方法应该是更加合适的,因为这种情况下处理相关的数据会更简单,而且也并非不支持跨节点的文本选区,也不需要像深层次嵌套结构那样在取得相关节点时需要递归查找,属于数据表达与性能的折中取舍。
选区结构设计
那么在调研了当前主流编辑器的选区模型设计后,我们就需要依照类似的原则来设计我们的编辑器选区模型。首先是针对数据结构设计选区对象,也就是编辑器中通常存在的Range对象,其次是针对状态模型的表达,并且需要考虑以此为基准设计编辑器的状态选区结构表达。
RawRange
在浏览器中Range对象是浏览器选区的基础表达,而对于编辑器而言,通常都会实现编辑器本身的Range对象。当然在这种情况下,编辑器Range对象和浏览器Range对象,特别是可能实现IOC DI的情况下,我们可以对浏览器的Range对象独立分配对象名。- import DOMNode = globalThis.Node;
- import DOMText = globalThis.Text;
- import DOMElement = globalThis.Element;
复制代码 那么先前已经提到了我们的编辑器数据结构设计,是基于Delta的实现改造而来的。因此Range对象的设计同样可以与Quill编辑器的选区设计保持一致,毕竟选区设计的直接依赖便是数据结构的设计。为了区分我们后边的设计方案,我们这里命名为RawRange。- export class RawRange {
- constructor(
- /** 起始点 */
- public start: number,
- /** 长度 */
- public len: number
- ) {}
- }
复制代码 由于实际上RawRange对象相当于是表达的一个线性范围,本身仅需要两个number即可以表达。但是为了更好地表达其语义,以及相关的调用方法,例如isBefore以及isAfter等方法,因此我们还有其内部的Point对象的表达。- export class RawPoint {
- constructor(
- /** 起始偏移 */
- public offset: number
- ) {}
- /**
- * 判断 Point1 是否在 Point2 之前
- * - 即 < (p1 p2), 反之则 >= (p2 p1)
- */
- public static isBefore(point1: RawPoint | null, point2: RawPoint | null): boolean {
- if (!point1 || !point2) return false;
- return point1.offset < point2.offset;
- }
- }
复制代码 在此基础上就可以实现诸如intersects、includes等方法,对于选区的各种操作还是比较重要的。例如intersects方法可以用来判断选区块级节点的选中状态,因为void节点本身是非文本的内容,浏览器本身是没有选区状态的。- export class RawRange {
- public static intersects(range1: Range | null, range2: Range | null) {
- if (!range1 || !range2) return false;
- const { start: start1, end: end1 } = range1;
- const { start: start2, end: end2 } = range2;
- // --start1--end1--start2--end2--
- // => --end1--start2--
- // --start1--start2--end1--end2-- ✅
- // => --start2--end1--
- const start = Point.isBefore(start1, start2) ? start2 : start1;
- const end = Point.isBefore(end1, end2) ? end1 : end2;
- return !Point.isAfter(start, end);
- }
- }
复制代码- export class SelectionHOC extends React.PureComponent<Props, State> {
- public onSelectionChange(range: Range | null) {
- const leaf = this.props.leaf;
- const leafRange = leaf.toRange();
- const nextState = range ? Range.intersects(leafRange, range) : false;
- if (this.state.selected !== nextState) {
- this.setState({ selected: nextState });
- }
- }
- public render() {
- const selected = this.state.selected;
- return (
-
- {React.Children.map(this.props.children, child => {
- if (React.isValidElement(child)) {
- const { props } = child;
- return React.cloneElement(child, { ...props, selected: selected });
- }
- return child;
- })}
-
- );
- }
- }
复制代码 Range
既然有RawRange选区对象的设计,那么相对应的自然是Range对象的设计。在这里我们Range对象的设计直接基于编辑器状态的实现,因此其实可以认为,我们的RawRange对象是基于数据结构的实现,Range对象则是基于编辑器状态模型的实现。
先来看Range对象的声明,实际上这里的实现是相对更精细的表达。在Point对象中,我们维护了行索引和行内偏移,而在Range对象中,我们维护了选区的起始点和结束点,此时的Range对象中区间永远是从start指向end,通过isBackward来标记此时是否反选状态。- export class Point {
- constructor(
- /** 行索引 */
- public line: number,
- /** 行内偏移 */
- public offset: number
- ) {}
- }
- export class Range {
- /** 选区起始点 */
- public readonly start: Point;
- /** 选区结束点 */
- public readonly end: Point;
- /** 选区方向反选 */
- public isBackward: boolean;
- /** 选区折叠状态 */
- public isCollapsed: boolean;
- }
复制代码 其中,选区区间永远是从start指向end这点非常重要,在我们后续的浏览器选区与编辑器选区状态同步中会非常有用。因为浏览器的Selection对象得到的anchor和focus并非总是由start指向end,此时维护我们的Range对象需要从Selection取得相关节点。
那么从先前的数据结构上来看,Delta数据结构是不存在任何行相关的数据信息,因此我们需要从编辑器维护的状态上来获取行索引和行内偏移。维护独立的状态变更本身也是一件复杂的事情,这件事我们需要后续再看,此时我们先来看下各个渲染节点状态维护的数据。- export class LeafState {
- /** Op 所属 Line 的索引 */
- public index: number;
- /** Op 起始偏移量 */
- public offset: number;
- /** Op 长度 */
- public readonly length: number;
- }
- export class LineState {
- /** 行 Leaf 数量 */
- public size: number;
- /** 行起始偏移 */
- public start: number;
- /** 行号索引 */
- public index: number;
- /** 行文本总宽度 */
- public length: number;
- /** Leaf 节点 */
- protected leaves: LeafState[] = [];
- }
复制代码 因此,浏览器选区实现的Selection对象都是基于DOM来实现的,那么通过DOM节点来同步编辑器的选区模型同样需要需要处理。不过在这里,我们先以从状态模型中获取选区的方式来构建Range对象。而由于上述实现中我们是基于点Point来实现的,那么自然可以分离点来处理区间。- const leafNode = getLeafNode(node);
- let lineIndex = 0;
- let leafOffset = 0;
- const lineNode = getLineNode(leafNode);
- const lineModel = editor.model.getLineState(lineNode);
- // 在没有 LineModel 的情况, 选区会置于 BlockState 最前
- if (lineModel) {
- lineIndex = lineModel.index;
- }
- const leafModel = editor.model.getLeafState(leafNode);
- // 在没有 LeafModel 的情况, 选区会置于 Line 最前
- if (leafModel) {
- leafOffset = leafModel.offset + offset;
- }
- return new Point(lineIndex, leafOffset);
复制代码 这样看起来,我们分离了LineState和LeafState的状态,然后直接从相关状态中就可以取出Point对象的行索引和行内偏移。注意这里我们得到的是行内偏移而不是叶子结点的偏移。类似Slate计算得到的偏移是Text节点的偏移,这也是对于选区模型的设计相关。
换句话说,当前我们的选区实现是L-O的实现,也就是Line与Offset索引级别的实现,也就是说这里的Offset是会跨越多个实际的LeafState节点的。那么这里的Offset就会导致我们在实现选区查找的时候需要额外的迭代,实际实现很比较灵活的。- const lineNode = editor.model.getLineNode(lineState);
- const selector = `[${LEAF_STRING}], [${ZERO_SPACE_KEY}]`;
- const leaves = Array.from(lineNode.querySelectorAll(selector));
- let start = 0;
- for (let i = 0; i < leaves.length; i++) {
- const leaf = leaves[i];
- let len = leaf.textContent.length;
- const end = start + len;
- if (offset <= end) {
- return { node: leaf, offset: Math.max(offset - start, 0) };
- }
- }
- return { node: null, offset: 0 };
复制代码 总结
在先前我们基于Range对象与Selection对象实现了基本的选区操作,并且举了相关的应用具体场景和示例。与之相对应的,在这里我们总结了调研了现代富文本编辑器的选区模型设计,并且基于数据模型设计了RawRange和Range对象两种选区模型。
接下来我们需要基于编辑器选区模型的表示,然后在浏览器选区相关的API基础上,实现编辑器选区模型与浏览器选区的同步。通过选区模型作为编辑器操作的范围目标,来实现编辑器的基础操作,例如插入、删除、格式化等操作,以及诸多选区相关的边界操作问题。
每日一题
- https://github.com/WindRunnerMax/EveryDay
参考
- https://quilljs.com/docs/api#selection
- https://lexical.dev/docs/concepts/selection
- https://prosemirror.net/docs/ref/#state.Selection
- https://tiptap.dev/docs/editor/api/commands/selection
- https://docs.slatejs.org/concepts/03-locations#selection
- https://open.larkoffice.com/document/server-docs/docs/docs/docx-v1/document/list
来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作! |