Slate文档编辑器-TS类型扩展与节点类型检查

server/2024/12/24 8:07:36/

Slate文档编辑器-TS类型扩展与节点类型检查

在之前我们基于slate实现的文档编辑器探讨了WrapNode数据结构与操作变换,主要是对于嵌套类型的数据结构类型需要关注的NormalizeTransformers,那么接下来我们更专注于文档编辑器的数据结构设计,聊聊基于slate实现的文档编辑器类型系统。

  • 在线编辑: https://windrunnermax.github.io/DocEditor
  • 开源地址: https://github.com/WindRunnerMax/DocEditor

关于slate文档编辑器项目的相关文章:

  • 基于Slate构建文档编辑器
  • Slate文档编辑器-WrapNode数据结构与操作变换
  • Slate文档编辑器-TS类型扩展与节点类型检查
  • Slate文档编辑器-Decorator装饰器渲染调度
  • Slate文档编辑器-Node节点与Path路径映射

TS类型扩展

当我们使用TS引入slate时,可能会发现调用createEditor实例化编辑器时,并没有提供范型的定义类型传入,那么这是不是意味着slate无法在TS中定义类型,而是只能在处理属性时采用as的形式强制类型断言。那么作为成熟的编辑器引擎显然是不能这样的,对于富文本编辑器来说,能够在TS中定义类型是非常重要的,我们可以将富文本理解为带着属性的文本,如果属性的定义不明确,那么维护起来可能会变得越来越困难,而本身维护富文本就充斥着各种问题,所以维护类型的定义是很有必要的。

在研究slate的类型扩展之前,我们需要先看看TypeScript提供的declare module以及interface声明,我们可能经常会遇到对typeinterface定义的接口类型的区别,实际上除了type可以定义联合类型以外,interfacetype还有一个比较重要的区别就是interface可以被合并,也就是可以将定义的接口进行扩展。而type在同一个模块内是无法重新定义类型声明的。

interface A { a: string }
interface A { b: number }
const a: A =  { a: "", b: 1 }type B = { a: string }
// type B = { b: number }
const b = { a: "" }

当我们初步了解时可能会觉得interfacetype的区别不大几乎可以平替,但是在实际应用中interface的合并特性是非常重要的,特别是在declare module的场景下,我们可以用于为模块扩展声明类型,也就是为已经有类型定义的库增加额外类型定义。

declare module "some-library" {export interface A {a: number;}export function fn(): void;
}

那么在这里我们就可以通过declare module + interface的合并特性来扩展模块的类型定义了,这也就是slate实际上定义的类扩展方式,我们可以通过为slate模块扩展类型的方式,来注入我们需要的基本类型,而我们的基本类型都是使用interface关键字定义的,这样就表示我们的类型是可以不断通过declare module的方式进行扩展的。还有一点需要特别关注,当我们实现了declare module的类型扩展时,我们就可以按需加载类型的扩展,也就是说只有当实际引用到我们定义好的类型时,才会加载对应的类型声明,做到按需扩展类型。

// packages/delta/src/interface.ts
import type { BaseEditor } from "slate";declare module "slate" {interface CustomTypes {Editor: BaseEditor;Element: BlockElement;Text: TextElement;}
}export interface BlockElement {text?: never;children: BaseNode[];[key: string]: unknown;
}export interface TextElement {text: string;children?: never;[key: string]: unknown;
}

实际上我是非常推荐将类型定义独立抽离出单独的文件来定义的,否则当我们在IDE中使用跳转到类型定义的功能查看完整slate的类型文件时,发现其跳转的位置是我们上述定义的文件位置,同理当我们将slate及其相关模块独立抽离为单独的包时,会发现类型的跳转变得并不那么方便,即使我们的目标不是扩展类型的定义也会被跳转到我们declare module的文件位置。因此当我们将其抽离出来之后,声明文件单独作为独立的模块处理,这样就不会存在定位问题了,例如下面就是我们声明代码块格式的类型定义。

// packages/plugin/src/codeblock/types/index.ts
declare module "doc-editor-delta/dist/interface" {interface BlockElement {[CODE_BLOCK_KEY]?: boolean;[CODE_BLOCK_CONFIG]?: { language: string };}interface TextElement {[CODE_BLOCK_TYPE]?: string;}
}export const CODE_BLOCK_KEY = "code-block";
export const CODE_BLOCK_TYPE = "code-block-type";
export const CODE_BLOCK_CONFIG = "code-block-config";

节点类型检查

那么在TS方面我们将类型扩展好之后,我们还需要关注实际的节点类型判断,毕竟TS只是帮我们实现了静态类型检查,在实际编译为Js运行在浏览器中时,通常是不会有实际额外代码注入的,而在我们平常使用的过程中是明显需要这些类型的判断的,例如通常我们需要检查当前的选区节点是否正确位于Text节点上,以便对用户的操作做出对应的响应。

那么我们就可以梳理一下在slate当中的节点类型,实际上根据slate导出的CustomTypes我们可以确定最基本的类型就只有ElementText,而我更习惯将其重命名为BlockElementTextElement。而实际上由于我们的业务复杂性,我们通常还需要扩展出InlineElementTextBlockElement两个类型。

那么我们首先来看BlockElement,这是slate中定义的基本块元素,例如我们的代码块最外层的嵌套结构需要是BlockElement,而实际上由于可能存在的复杂嵌套结构,例如表格中嵌套分栏结构,分栏结构中继续嵌套代码块结构等,所以我们可以将其理解为BlockElement是嵌套所需的结构即可,而判断节点类型是否是BlockElement的方式则可以直接调度Editor.isBlock方法即可。

export interface BlockElement {text?: never;children: BaseNode[];[key: string]: unknown;
}export const isBlock = (editor: Editor, node: Node | null): node is BlockElement => {if (!node) return false;return Editor.isBlock(editor, node);
};// https://github.com/ianstormtaylor/slate/blob/25be3b/packages/slate/src/interfaces/editor.ts#L590
export const Editor = {isBlock(editor: Editor, value: any): value is Element {return Element.isElement(value) && !editor.isInline(value)}
}

TextElement被定义为文本节点,需要注意的是这里同样也是可以加入属性的,在slate中被称为marker,无论什么节点类型的嵌套,数据结构即树形结构的叶子节点一定需要是文本节点,而针对于TextElement的判断则同样可以调度Text上的isText方法。

export interface TextElement {text: string;children?: never;[key: string]: unknown;
}export const isText = (node: Node): node is TextElement => Text.isText(node);// https://github.com/ianstormtaylor/slate/blob/25be3b/packages/slate/src/interfaces/text.ts#L53
export const Text = {isText(value: any): value is Text {return isPlainObject(value) && typeof value.text === 'string'},
}

如果仔细看slate判断BlockElement的条件,我们可以发现除了调用Element.isElement方法外,还判断了editor.isInline方法,由于slateInlineElement是一种特殊的BlockElement,我们同样需要为其独立实现类型判断,而判断InlineElement的方式则需要由editor.isInline方法来实现,这部分实际上是由我们在with的时候需要定义好的。

export interface InlineElement {text?: never;children: TextElement[];[key: string]: unknown;
}export const isInline = (editor: Editor, node:Node): node is InlineElement => {return editor.isInline(node);
}

在业务中通常我们还需要判断文本段落实际渲染的节点TextBlockElement,这个判断同样非常重要,因为这里决定了当前的节点是我们实际要渲染的段落了,这在数据转换等场景中非常有用,当我们需要二次解析数据的时候,当遇到文本段落时,我们就可以确定当前的块级结构解析可以结束了,接下来可以构建段落的内容组合了。而由于段落中可能存在TextElementInlineElement两种节点,我们的判断就需要将两种情况都考虑到,在规范的条件下我们通常只需要判断首个节点是否符合条件即可,而在开发模式下我们可以尝试对比所有节点的结构来判断并且校验脏数据。

export interface TextBlockElement {text?: never;children: (TextElement | InlineElement)[];[key: string]: unknown;
}export const isTextBlock = (editor: Editor, node: Node): node is TextBlockElement => {if (!isBlock(editor, node)) return false;const firstNode = node.children[0];const result = firstNode && (isText(firstNode) || isInline(editor, firstNode));if (process.env.NODE_ENV === "development") {const strictInspection = node.children.every(child => isText(firstNode) || isInline(editor, firstNode));if (result !== strictInspection) {console.error("Fatal Error: Text Block Check Fail", node);}}return result;
};

最后

在这里我们更专注于文档编辑器的数据结构设计,聊聊基于slate实现的文档编辑器类型系统。在slate中还有很多额外的概念和操作需要关注,例如RangeOperationEditorElementPath等,那么在后边的文章中我们就主要聊一聊在slatePath的表达,以及在React中是如何控制其内容表达与正确维护Path路径与Element内容渲染的,并且我们还可以聊一聊表格模块的设计与实现。

Blog

https://github.com/WindRunnerMax/EveryDay

http://www.ppmy.cn/server/152714.html

相关文章

hive 两次操作时间间隔大于0.5小时(LAG)

需求 明细表A记录工人的操作记录,create_time 是操作时间,需要统计操作时间间隔大于0.5小时的次数 WITH ordered_actions AS (SELECTwaybill_no,create_time,-- 使用 LAG 函数获取上一条记录的 create_timeLAG(create_time) OVER (PARTITION BY waybil…

微信 SDK 更新 Sample,NCF 文档和模板更新,更多更新日志,欢迎解锁

Senparc.Weixin SDK 更新 Sample,批处理发布单个模块的 Sample 更新 Sample,引用最新版本 SDK NeuCharFramework 文档更新,提供全文检索功能 模板更新,提供菜单设置页面的父层节点置顶,解决菜单项过长,不容易编辑…

nginx学习总结(不包含安装过程)

1. nginx常见配置 http服务上支持【若干虚拟主机】。每个虚拟主机对应一个server配置项,配置项里面包含该虚拟主机相关的配置。 server{listen 80 default;server_name www.yonqin.com;index index.html index.htm index.php;root /data/www;location ~ .*\.(gif|…

Ubuntu概述

Ubuntu文件系统结构 Linux中/是一切的根目录 文件系统结构: Ubuntu磁盘管理 Ubuntu文件压缩 Ubuntu用户与用户组 文件权限变更

QT多媒体开发(三):使用QMediaPlayer播放视频

QMediaPlayer 类不仅能播放音频文件,也可以播放各种常见的视频文件,如 MP4 文件和 WMV 文件。QMediaPlayer 能对视频文件进行解码,并在某个界面组件上显示视频帧。 使用 QMediaPlayer 播放视频时,必须用函数 setVideoOutput()设置…

maven-resources-production:ratel-fast: java.lang.IndexOutOfBoundsException

Maven生产环境中遇到java.lang.IndexOutOfBoundsException的问题,尝试了重启电脑、重启IDEA等常规方法无效,最终通过直接重建工程解决了问题。 Rebuild Project 再启动OK

云起无垠荣获第八届“强网杯”高阶技术专项赛优秀奖

近日,第八届 “强网杯” 全国网络安全挑战赛线下赛于郑州高新区的网络安全科技馆赛博厅圆满落幕。云起无垠创始人兼 CEO 沈凯文博士在活动中发表了题为《安全智能体技术实践与内生安全问题思考》的精彩演讲。 沈凯文博士指出,当前数字化时代下网络安全领…

什么是事务

在数据库管理系统中,事务(Transaction)是执行一系列操作的最小工作单元,这些操作要么全部成功,要么全部失败。为了确保数据的一致性和完整性,事务被设计为具备四大特性,即原子性(Ato…