当前位置:网站首页>Build document editor based on slate
Build document editor based on slate
2022-06-30 20:05:00 【WindrunnerMax】
be based on slate Build document editor
slate.js
Is a fully customizable framework , For building rich text editor , Here we use slate.js
Build a rich text editor that focuses on document editing .
describe
A rich text editor is one that can be embedded in a browser , WYSIWYG text editor . There are many out of the box rich text editors , for example UEditor
、WangEditor
etc. , They are less customizable , But the best thing is to use it out of the box , You can see the effect in a short time . And it's like Draft.js
、Slate.js
, They are rich text editors core
Or called controller
, Not a complete function , In this way, we can have very high customizability , Of course, it will take more time to develop . In practical application or technology selection , We should do more research , Because the framework has no absolute advantages and disadvantages in business , Only fit is not fit .
stay slate
The design principles of the framework are described in the document of , Carry it :
- The plug-in is a first-class citizen ,
slate
The most important part is that the plug-in is a first-class citizen entity , This means that you can completely customize the editing experience , To build something likeMedium
orDropbox
Such a complicated editor , You don't have to fight against the default of the library . - Streamlined
schema
The core ,slate
The core logic of has very few presets for the data structure you edit , This means that when you build complex use cases , Will not be hindered by any prefabricated content . - Nested document model ,
slate
The model used by the document is a nested , Recursive tree , It's likeDOM
equally , This means that for advanced use cases , It is possible to build complex components such as tables or nested references , Of course, you can also use a single hierarchical structure to keep things simple . - And
DOM
identical ,slate
Our data model is based onDOM
, A document is a nested tree , It uses text selectionselections
And scoperanges
, And expose all the standard event handlers , This means that advanced features such as tables or nested references are possible , Almost all of you areDOM
What can be done in , Can be inslate
To achieve . - Intuitive instructions ,
slate
Document execution commandscommands
To edit , It is designed to be advanced and very intuitive for editing and reading , So that the customization function can be as expressive as possible , This greatly improves your ability to understand the code . - Collaborative data model ,
slate
The data model used, especially how operations are applied to documents , Designed to allow collaborative editing at the top level , So if you decide to implement collaborative editing , You don't have to think about completely refactoring . - Clear core divisions , Use the plug-in first structure and streamline the core , Make the boundary between core and customization very clear , This means that the core editing experience will not be troubled by various marginal situations .
Mentioned above slate
Just one. core
, Simply put, it doesn't provide various rich text editing functions , All rich text functions need to be provided by themselves API
To achieve , Even its plug-in mechanism needs to be expanded by itself , So you need to make some strategies for the implementation of plug-ins .slate
Although not particularly detailed , But his examples are very rich , A walkthrough is also provided in the document as a starting point , It's more friendly for beginners . Here we build a rich text editor that focuses on document editing , Interaction and ui
There are many references to flybook documents , On the whole, there are many pits , Especially in terms of interaction strategies , However, there is no problem to implement the basic document editor function after doing a good job . I use it here slate
Version is 0.80.0
, It does not rule out future framework policy adjustments , So you should also pay attention to the version information .
Plug in strategy
We mentioned above ,slate
It does not provide a plug-in registration mechanism , This can be seen directly in the walkthrough section of the document , And you can also see that slate
Exposed some props
So that we can expand slate
The function of , for example renderElement
、renderLeaf
、onKeyDown
wait , It can also be seen that slate
The maintained data is separate from the rendering , What we need to do is maintain the data structure and decide how to render certain types of data , So here we need to implement our own plug-in expansion scheme based on these registration mechanisms .
This is the code for the final implementation of the walkthrough in the document , It's easy to understand slate
Control and processing scheme for , You can see that the block level elements are <CodeElement />
The rendering of is through renderElement
To complete , Inline elements are bold
Style rendering is done by renderLeaf
To complete , stay onKeyDown
We can see that by monitoring the input of the keyboard , We are right. slate
The maintained data passes Transforms
Some processing has been done , By matching the Node
take attributes
The data structure is written , Then through two render
Of props
Render it out , So this is slate
The extension mechanism and data rendering separation structure .
const initialValue = [
{
type: 'paragraph',
children: [{
text: 'A line of text in a paragraph.' }],
},
]
const App = () => {
const [editor] = useState(() => withReact(createEditor()))
const renderElement = useCallback(props => {
switch (props.element.type) {
case 'code':
return <CodeElement {
...props} />
default:
return <DefaultElement {
...props} />
}
}, [])
// Define a leaf rendering function that is memoized with `useCallback`.
const renderLeaf = useCallback(props => {
return <Leaf {
...props} />
}, [])
return (
<Slate editor={
editor} value={
initialValue}>
<Editable
renderElement={
renderElement}
// Pass in the `renderLeaf` function.
renderLeaf={
renderLeaf}
onKeyDown={
event => {
if (!event.ctrlKey) {
return
}
switch (event.key) {
case '`': {
event.preventDefault()
const [match] = Editor.nodes(editor, {
match: n => n.type === 'code',
})
Transforms.setNodes(
editor,
{
type: match ? null : 'code' },
{
match: n => Editor.isBlock(editor, n) }
)
break
}
case 'b': {
event.preventDefault()
Transforms.setNodes(
editor,
{
bold: true },
{
match: n => Text.isText(n), split: true }
)
break
}
}
}}
/>
</Slate>
)
}
const Leaf = props => {
return (
<span
{
...props.attributes}
style={
{
fontWeight: props.leaf.bold ? 'bold' : 'normal' }}
>
{
props.children}
</span>
)
}
Plug in registration
In the last section, we learned slate
Plug-in expansion and data processing scheme , We can also see that this basic plug-in registration method is still troublesome , Then we can implement a plug-in registration scheme by ourselves , Uniformly encapsulate the registration form of plug-ins , To expand slate
. Here, the plug-in is registered through slate-plugins.tsx
To achieve , say concretely , Each plug-in must return one Plugin
Function of type , Of course, it is OK to define an object directly , The advantage of the function is that it can pass parameters during registration , So they are usually defined directly by functions .
key
: Indicates the name of the plug-in , Generally, it cannot be repeated .priority
: Indicates the priority of plug-in execution , Usually the user needs a packagerenderLine
The components of .command
: The command to register the plug-in , Toolbar click or press the shortcut key to execute the function .onKeyDown
: Keyboard event handler , You can use it to make specific actions such as enter or delete .type
: Mark it asblock
Or is itinline
.match
: Only return totrue
That is, the matched plug-in will execute .renderLine
: be used forblock
The components of , It is usually used to wrap a layer of components on its child elements .render
: aboutblock
The component to be rendered is determined by this function , aboutinline
Components are the same asblock
OfrenderLine
Perform the same .
type BasePlugin = {
key: string;
priority?: number; // The higher the priority In the outer layer
command?: CommandFn;
onKeyDown?: (event: React.KeyboardEvent<HTMLDivElement>) => boolean | void;
};
type ElementPlugin = BasePlugin & {
type: typeof EDITOR_ELEMENT_TYPE.BLOCK;
match: (props: RenderElementProps) => boolean;
renderLine?: (context: ElementContext) => JSX.Element;
render?: (context: ElementContext) => JSX.Element;
};
type LeafPlugin = BasePlugin & {
type: typeof EDITOR_ELEMENT_TYPE.INLINE;
match: (props: RenderLeafProps) => boolean;
render?: (context: LeafContext) => JSX.Element;
};
On the concrete implementation , We use the method of instantiating classes , After instantiation, we can continue add
plug-in unit , because toolbar
And other plug-ins are responsible for executing commands , Therefore, you need to first obtain the command of the plug-in that has been registered , After it is passed in, it is registered in the plug-in , The unified plug-in management is realized through this registration mechanism , stay apply
after , We can pass the returned value into <Editable />
in , You can extend the plug-in to slate
In the middle .
const {
renderElement, renderLeaf, onKeyDown, withVoidElements, commands } = useMemo(() => {
const register = new SlatePlugins(
ParagraphPlugin(),
HeadingPlugin(editor),
BoldPlugin(),
QuoteBlockPlugin(editor),
// ...
);
const commands = register.getCommands();
register.add(
DocToolBarPlugin(editor, props.isRender, commands),
// ...
);
return register.apply();
}, [editor, props.isRender]);
Type expansion
stay slate
A good type expansion mechanism is reserved in , Can pass TypeScript
Medium declare module
coordination interface
To expand BlockElement
And TextElement
The type of , Enable the implementation of the plug-in attributes
There is strict type verification .
// base
export type BaseNode = BlockElement | TextElement;
declare module "slate" {
interface BlockElement {
children: BaseNode[];
[key: string]: unknown;
}
interface TextElement {
text: string;
[key: string]: unknown;
}
interface CustomTypes {
Editor: BaseEditor & ReactEditor;
Element: BlockElement;
Text: TextElement;
}
}
// plugin
declare module "slate" {
interface BlockElement {
type?: {
a: string; b: boolean };
}
interface TextElement {
type?: boolean;
}
}
Implementation scheme
Here are the specific plug-in implementation schemes and examples , Each part is an implementation of a type of plug-in , The specific code can be found in Github Find . In terms of plug-in implementation , The whole still relies on HTML5
To complete a variety of styles , This can maintain the semantic integrity of the document label, but it will cause DOM
The structure is deeply nested . Use pure CSS
To complete various plug-ins is no problem , And the implementation is simpler ,context
Provide classList
To operate className
, Just pure CSS
If the style is implemented, the semantic integrity of the tag is lacking . This is mainly a question of trade-offs , The plug-ins implemented here are all based on HTML5
And some custom interaction strategies , The implementation of interaction is triggered after the command is registered by the plug-in .
Leaf
leaf
Type plug-ins are inline elements , For example, bold 、 Italics 、 Underline 、 Delete line, etc , In implementation, you only need to pay attention to the command registration of the plug-in and how to render elements under this command , Here is bold
Plug in implementation , It mainly registers the operation attributes
The order of , And the use of <strong />
As a label for the rendering format .
declare module "slate" {
interface TextElement {
bold?: boolean;
}
}
export const boldPluginKey = "bold";
export const BoldPlugin = (): Plugin => {
return {
key: boldPluginKey,
type: EDITOR_ELEMENT_TYPE.INLINE,
match: props => !!props.leaf[boldPluginKey],
command: (editor, key) => {
Transforms.setNodes(
editor,
{
[key]: true },
{
match: node => Text.isText(node), split: true }
);
},
render: context => <strong>{
context.children}</strong>,
};
};
Element
element
Type plug-ins are block level elements , For example, the title 、 The paragraph 、 Alignment, etc , Simply put, it is an element that acts on a line , In the implementation, we should not only pay attention to the command registration and rendering elements , And pay attention to all kinds of case
, Especially in wrapper
Nested case . In the following heading
Example , Whether it is already in the command phase is handled heading
state , If the status is changed, cancel heading
, Generated id
For later use as an anchor , When handling keyboard events , We need to deal with some case
, Here we realize that we don't want to inherit in the next line when we return the carriage heading
Format , And clicking delete when the cursor is placed in front of the row will delete the row Title Format .
declare module "slate" {
interface BlockElement {
heading?: {
id: string; type: string };
}
}
export const headingPluginKey = "heading";
const headingCommand: CommandFn = (editor, key, data) => {
if (isObject(data) && data.path) {
if (!isMatchedAttributeNode(editor, `${
headingPluginKey}.type`, data.extraKey)) {
setBlockNode(editor, {
[key]: {
type: data.extraKey, id: uuid().slice(0, 8) } }, data.path);
} else {
setBlockNode(editor, getOmitAttributes([headingPluginKey]), data.path);
}
}
};
export const HeadingPlugin = (editor: Editor): Plugin => {
return {
key: headingPluginKey,
type: EDITOR_ELEMENT_TYPE.BLOCK,
command: headingCommand,
match: props => !!props.element[headingPluginKey],
renderLine: context => {
const heading = context.props.element[headingPluginKey];
if (!heading) return context.children;
const id = heading.id;
switch (heading.type) {
case "h1":
return (
<h1 className="doc-heading" id={
id}>
{
context.children}
</h1>
);
case "h2":
return (
<h2 className="doc-heading" id={
id}>
{
context.children}
</h2>
);
case "h3":
return (
<h3 className="doc-heading" id={
id}>
{
context.children}
</h3>
);
default:
return context.children;
}
},
onKeyDown: event => {
if (
isMatchedEvent(event, KEYBOARD.BACKSPACE, KEYBOARD.ENTER) &&
isCollapsed(editor, editor.selection)
) {
const match = getBlockNode(editor, editor.selection);
if (match) {
const {
block, path } = match;
if (!block[headingPluginKey]) return void 0;
if (isSlateElement(block)) {
if (event.key === KEYBOARD.BACKSPACE && isFocusLineStart(editor, path)) {
const properties = getOmitAttributes([headingPluginKey]);
Transforms.setNodes(editor, properties, {
at: path });
event.preventDefault();
}
if (event.key === KEYBOARD.ENTER && isFocusLineEnd(editor, path)) {
const attributes = getBlockAttributes(block, [headingPluginKey]);
if (isWrappedNode(editor)) {
// stay `wrap` There will be problems inserting nodes in the case of Insert one more space before deleting
Transforms.insertNodes(
editor,
{
...attributes, children: [{
text: " " }] },
{
at: editor.selection.focus, select: false }
);
Transforms.move(editor, {
distance: 1 });
Promise.resolve().then(() => editor.deleteForward("character"));
} else {
Transforms.insertNodes(editor, {
...attributes, children: [{
text: "" }] });
}
event.preventDefault();
}
}
}
}
},
};
};
Wrapper
wrapper
Type plug-ins also belong to block level elements , For example, reference block 、 Ordered list 、 Unordered list, etc , Simply put, there is an extra row nested on the row , Therefore, we should not only pay attention to the command registration and rendering elements in the implementation , And pay attention to all kinds of case
, stay wrapper
The next thing to pay attention to case
There are so many , So we also need to implement some strategies to avoid these problems . In the following quote-block
Example , It supports first level block reference , Carriage return inherits the format , As wrapped
Plug ins cannot be used with other wrapped
Plug ins are used in parallel , The line is empty and the action wrapped
Carriage return and deletion on the first or last line will cancel the block reference format of the line , Place the cursor at the front of the line and click delete wrapped
When the first or last line is selected, the block reference format of the line will be cancelled .
declare module "slate" {
interface BlockElement {
"quote-block"?: boolean;
"quote-block-item"?: boolean;
}
}
export const quoteBlockKey = "quote-block";
export const quoteBlockItemKey = "quote-block-item";
const quoteCommand: CommandFn = (editor, key, data) => {
if (isObject(data) && data.path) {
if (!isMatchedAttributeNode(editor, quoteBlockKey, true, data.path)) {
if (!isWrappedNode(editor)) {
setWrapNodes(editor, {
[key]: true }, data.path);
setBlockNode(editor, {
[quoteBlockItemKey]: true });
}
} else {
setUnWrapNodes(editor, quoteBlockKey);
setBlockNode(editor, getOmitAttributes([quoteBlockItemKey, quoteBlockKey]));
}
}
};
export const QuoteBlockPlugin = (editor: Editor): Plugin => {
return {
key: quoteBlockKey,
type: EDITOR_ELEMENT_TYPE.BLOCK,
match: props => !!props.element[quoteBlockKey],
renderLine: context => (
<blockquote className="slate-quote-block">{
context.children}</blockquote>
),
command: quoteCommand,
onKeyDown: event => {
if (
isMatchedEvent(event, KEYBOARD.BACKSPACE, KEYBOARD.ENTER) &&
isCollapsed(editor, editor.selection)
) {
const quoteMatch = getBlockNode(editor, editor.selection, quoteBlockKey);
const quoteItemMatch = getBlockNode(editor, editor.selection, quoteBlockItemKey);
if (quoteMatch && !quoteItemMatch) setUnWrapNodes(editor, quoteBlockKey);
if (!quoteMatch && quoteItemMatch) {
setBlockNode(editor, getOmitAttributes([quoteBlockItemKey]));
}
if (!quoteMatch || !quoteItemMatch) return void 0;
if (isFocusLineStart(editor, quoteItemMatch.path)) {
if (
!isWrappedEdgeNode(editor, editor.selection, quoteBlockKey, quoteBlockItemKey, "or")
) {
if (isMatchedEvent(event, KEYBOARD.BACKSPACE)) {
editor.deleteBackward("block");
event.preventDefault();
}
} else {
setUnWrapNodes(editor, quoteBlockKey);
setBlockNode(editor, getOmitAttributes([quoteBlockItemKey, quoteBlockKey]));
event.preventDefault();
}
}
}
},
};
};
Void
void
Type plug-ins also belong to block level elements , For example, split line 、 picture 、 Video etc. ,void
Element should be an empty element , It will have an empty text child node for rendering , And it is not editable , So it's a separate node type . In the following dividing-line
Example , You need to pay attention to the selection of the dividing line and void
Definition of node .
declare module "slate" {
interface BlockElement {
"dividing-line"?: boolean;
}
}
export const dividingLineKey = "dividing-line";
const DividingLine: React.FC = () => {
const selected = useSelected();
const focused = useFocused();
return <div className={
cs("dividing-line", focused && selected && "selected")}></div>;
};
export const DividingLinePlugin = (): Plugin => {
return {
key: dividingLineKey,
isVoid: true,
type: EDITOR_ELEMENT_TYPE.BLOCK,
command: (editor, key) => {
Transforms.insertNodes(editor, {
[key]: true, children: [{
text: "" }] });
Transforms.insertNodes(editor, {
children: [{
text: "" }] });
},
match: props => existKey(props.element, dividingLineKey),
render: () => <DividingLine></DividingLine>,
};
};
Toolbar
toolbar
A plug-in of type is a separate plug-in belonging to a custom class , It is mainly used to execute commands , Because we registered the command when we defined the plug-in , That means that we can drive the changes of nodes through commands ,toolbar
Is a plug-in for executing commands . In the following doc-toolbar
Example , We can see how to implement the floating menu on the left and the execution of commands .
const DocMenu: React.FC<{
editor: Editor;
element: RenderElementProps["element"];
commands: SlateCommands;
}> = props => {
const [visible, setVisible] = useState(false);
const affixStyles = (param: string) => {
setVisible(false);
const [key, data] = param.split(".");
const path = ReactEditor.findPath(props.editor, props.element);
focusSelection(props.editor, path);
execCommand(props.editor, props.commands, key, {
extraKey: data, path });
};
const MenuPopup = (
<Menu onClickMenuItem={
affixStyles} className="doc-menu-popup">
<Menu.Item key="heading.h1">
<IconH1 />
First level title
</Menu.Item>
<Menu.Item key="heading.h2">
<IconH2 />
Secondary title
</Menu.Item>
<Menu.Item key="heading.h3">
<IconH3 />
Three level title
</Menu.Item>
<Menu.Item key="quote-block">
<IconQuote />
Block level references
</Menu.Item>
<Menu.Item key="ordered-list">
<IconOrderedList />
Ordered list
</Menu.Item>
<Menu.Item key="unordered-list">
<IconUnorderedList />
Unordered list
</Menu.Item>
<Menu.Item key="dividing-line">
<IconEdit />
Split line
</Menu.Item>
</Menu>
);
return (
<Trigger
popup={
() => MenuPopup}
position="bottom"
popupVisible={
visible}
onVisibleChange={
setVisible}
>
<span
className="doc-icon-plus"
onMouseDown={
e => e.preventDefault()} // prevent toolbar from taking focus away from editor
>
<IconPlusCircle />
</span>
</Trigger>
);
};
const NO_DOC_TOOL_BAR = ["quote-block", "ordered-list", "unordered-list", "dividing-line"];
const OFFSET_MAP: Record<string, number> = {
"quote-block-item": 12,
};
export const DocToolBarPlugin = (
editor: Editor,
isRender: boolean,
commands: SlateCommands
): Plugin => {
return {
key: "doc-toolbar",
priority: 13,
type: EDITOR_ELEMENT_TYPE.BLOCK,
match: () => true,
renderLine: context => {
if (isRender) return context.children;
for (const item of NO_DOC_TOOL_BAR) {
if (context.element[item]) return context.children;
}
let offset = 0;
for (const item of Object.keys(OFFSET_MAP)) {
if (context.element[item]) {
offset = OFFSET_MAP[item] || 0;
break;
}
}
return (
<Trigger
popup={
() => <DocMenu editor={
editor} commands={
commands} element={
context.element} />}
position="left"
popupAlign={
{
left: offset }}
mouseLeaveDelay={
200}
mouseEnterDelay={
200}
>
<div>{
context.children}</div>
</Trigger>
);
},
};
};
Shortcut
shortcut
A plug-in of type is a separate plug-in belonging to a custom class , It is also used to execute commands with shortcut keys , This is also an implementation using command driven . In the following shortcut
Example , We can see how to handle the input of shortcut keys and the execution of commands .
const SHORTCUTS: Record<string, string> = {
"1.": "ordered-list",
"-": "unordered-list",
"*": "unordered-list",
">": "quote-block",
"#": "heading.h1",
"##": "heading.h2",
"###": "heading.h3",
"---": "dividing-line",
};
export const ShortCutPlugin = (editor: Editor, commands: SlateCommands): Plugin => {
return {
key: "shortcut",
type: EDITOR_ELEMENT_TYPE.BLOCK,
match: () => false,
onKeyDown: event => {
if (isMatchedEvent(event, KEYBOARD.SPACE) && isCollapsed(editor, editor.selection)) {
const match = getBlockNode(editor);
if (match) {
const {
anchor } = editor.selection;
const {
path } = match;
const start = Editor.start(editor, path);
const range = {
anchor, focus: start };
const beforeText = Editor.string(editor, range);
const param = SHORTCUTS[beforeText.trim()];
if (param) {
Transforms.select(editor, range);
Transforms.delete(editor);
const [key, data] = param.split(".");
execCommand(editor, commands, key, {
extraKey: data, path });
event.preventDefault();
}
}
}
},
};
};
A daily topic
https://github.com/WindrunnerMax/EveryDay
Reference resources
https://docs.slatejs.org/
https://github.com/ianstormtaylor/slate
https://www.slatejs.org/examples/richtext
http://t.zoukankan.com/kagol-p-14820617.html
https://rain120.github.io/athena/zh/slate/Introduction.html
https://www.wangeditor.com/v5/#%E6%8A%80%E6%9C%AF%E8%80%81%E6%97%A7
边栏推荐
- c语言数组截取,C# 字符串按数组截取方法(C/S)
- 科大讯飞活跃竞赛汇总!(12个)
- Audio and video architecture construction in the super video era | science and Intel jointly launched the second season of "architect growth plan"
- VR全景中特效是如何编辑的?细节功能如何展示?
- Application of VoIP push in overseas audio and video services
- 2022 最新 JCR正式发布全球最新影响因子名单(前600名)
- SSM整合流程(整合配置、功能模块开发、接口测试)
- 太湖 “中国健康农产品·手机直播万里行”走进太湖
- Cartoon | has Oracle been abandoned by the new era?
- WeakSet
猜你喜欢
条件编译
数据智能——DTCC2022!中国数据库技术大会即将开幕
无线充U型超声波电动牙刷方案开发
QQmlApplicationEngine failed to load component qrc:/main.qml:-1 No such file or directory
派尔特医疗在港交所招股书二次“失效”,上市计划实质性延迟
实现各种效果和功能的按钮,读这篇文章就够了
Buttons to achieve various effects and functions. Reading this article is enough
Friends in Guangzhou can join us if they have the opportunity
KubeVela 1.4:让应用交付更安全、上手更简单、过程更透明
盘点华为云GaussDB(for Redis)六大秒级能力
随机推荐
Character class of regular series
neo4j load csv 配置和使用
Which brokerage has the lowest commission? In addition, is it safe to open a mobile account?
CADD course learning (2) -- target crystal structure information
【450. 删除二叉搜索树中的节点】
WordPress 博客使用火山引擎 veImageX 进行静态资源 CDN 加速(免费)
台湾SSS鑫创SSS1700替代Cmedia CM6533 24bit 96KHZ USB音频编解码芯片
2022年高考都结束了,还有人真觉得程序员下班后不需要学习吗?
Primary school, session 3 - afternoon: Web_ sessionlfi
arthas调试 确定问题工具包
Tupu software has passed CMMI5 certification| High authority and high-level certification in the international software field
Buttons to achieve various effects and functions. Reading this article is enough
Warmup预热学习率「建议收藏」
盘点华为云GaussDB(for Redis)六大秒级能力
VR云展厅如何给线下实体带来活力?有哪些功能?
Convert seconds to * * hours * * minutes
ABAQUS 2022最新版——完善的现实仿真解决方案
GeoServer安装
Conditional compilation
Enterprise middle office planning and it architecture microservice transformation