基於slate構建文檔編輯器
slate.js
是一個完全可定製的框架,用於構建富文本編輯器,在這裏我們使用slate.js
構建專注於文檔編輯的富文本編輯器。
描述
富文本編輯器是一種可內嵌於瀏覽器,所見即所得的文本編輯器。現在有很多開箱即用的富文本編輯器,例如UEditor
、WangEditor
等,他們的可定製性差一些,但是勝在開箱即用,可以短時間就見到效果。而類似於Draft.js
、Slate.js
,他們是富文本編輯器的core
或者叫做controller
,並不是一個完整的功能,這樣就能夠讓我們有非常高的可定製性,當然也就會造成開發所需要的時間比較多。在實際應用或技術選型的時候,還是要多做一些調研,因爲在業務上框架沒有絕對的優勢與劣勢,只有合適不合適。
在slate
的文檔中有對於框架的設計原則上的描述,搬運一下:
- 插件是一等公民,
slate
最重要的部分就是插件是一等公民實體,這意味着你可以完全定製編輯體驗,去建立像Medium
或是Dropbox
這樣複雜的編輯器,而不必對庫的預設作鬥爭。 - 精簡的
schema
核心,slate
的核心邏輯對你編輯的數據結構進行的預設非常少,這意味着當你構建複雜用例時,不會被任何的預製內容所阻礙。 - 嵌套文檔模型,
slate
文檔所使用的模型是一個嵌套的,遞歸的樹,就像DOM
一樣,這意味着對於高級用例來說,構建像表格或是嵌套引用這樣複雜的組件是可能的,當然你也可以使用單一層次的結構來保持簡單性。 - 與
DOM
相同,slate
的數據模型基於DOM
,文檔是一個嵌套的樹,其使用文本選區selections
和範圍ranges
,並且公開所有的標準事件處理函數,這意味着像是表格或者是嵌套引用這樣的高級特性是可能的,幾乎所有你在DOM
中可以做到的事情,都可以在slate
中做到。 - 直觀的指令,
slate
文檔執行命令commands
來進行編輯,它被設計爲高級並且非常直觀地進行編輯和閱讀,以便定製功能儘可能地具有表現力,這大大的提高了你理解代碼的能力。 - 可協作的數據模型,
slate
使用的數據模型特別是操作如何應用到文檔上,被設計爲允許協同編輯在最頂層,所以如果你決定要實現協同編輯,不必去考慮徹底重構。 - 明確的核心劃分,使用插件優先的結構和精簡核心,使得核心和定製的邊界非常清晰,這意味着核心的編輯體驗不會被各種邊緣情況所困擾。
前邊提到了slate
只是一個core
,簡單來說他本身並不提供各種富文本編輯功能,所有的富文本功能都需要自己來通過其提供的API
來實現,甚至他的插件機制也需要通過自己來拓展,所以在插件的實現方面就需要自己制定一些策略。slate
的文檔雖然不是特別詳細,但是他的示例是非常豐富的,在文檔中也提供了一個演練作爲上手的基礎,對於新手還是比較友好的。在這裏我們構建了專注於文檔編輯的富文本編輯器,交互與ui
方面對於飛書文檔的參考比較多,整體來說坑也是比較多的,尤其是在做交互策略方面,不過做好兜底以後實現基本的文檔編輯器功能是沒有問題的。在這裏我使用的slate
版本爲0.80.0
,不排除之後的框架策略調整,所以對於版本信息也需要注意。
插件策略
上邊我們提到了,slate
本身並沒有提供插件註冊機制,這方面可以直接在文檔的演練部分看出,同時也可以看出slate
暴露了一些props
使我們可以拓展slate
的功能,例如renderElement
、renderLeaf
、onKeyDown
等等,也可以看出slate
維護的數據與渲染是分離的,我們需要做的是維護數據結構以及決定如何渲染某種類型的數據,所以在這裏我們需要基於這些註冊機制來實現自己的插件拓展方案。
這是文檔中演練最後實現的代碼,可以簡單瞭解一下slate
的控制處理方案,可以看到塊級元素即<CodeElement />
的渲染是通過renderElement
來完成的,行內元素即bold
樣式的渲染是通過renderLeaf
來完成的,在onKeyDown
中我們可以看到通過監聽鍵盤的輸入,我們對slate
維護的數據通過Transforms
進行了一些處理,通過匹配Node
將attributes
寫入了數據結構,然後通過兩種render
的props
將其渲染了出來,所以這就是slate
的拓展機制與數據渲染分離結構。
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>
)
}
插件註冊
在上一節我們瞭解了slate
的插件拓展與數據處理方案,那麼我們也可以看到這種最基本的插件註冊方式還是比較麻煩的,那麼我們就可以自己實現一個插件的註冊方案,統一封裝一下插件的註冊形式,用來拓展slate
。在這裏插件註冊時通過slate-plugins.tsx
來實現,具體來說,每個插件都是一個必須返回一個Plugin
類型的函數,當然直接定義一個對象也是沒問題的,函數的好處是可以在註冊的時候傳遞參數,所以一般都是直接用函數定義的。
key
: 表示該插件的名字,一般不能夠重複。priority
: 表示插件執行的優先級,通常用戶需要包裹renderLine
的組件。command
: 註冊該插件的命令,工具欄點擊或者按下快捷鍵需要執行的函數。onKeyDown
: 鍵盤事件的處理函數,可以用他來制定回車或者刪除等操作的具體行爲等。type
: 標記其是block
或者是inline
。match
: 只有返回true
即匹配到的插件纔會執行。renderLine
: 用於block
的組件,通常用作在其子元素上包裹一層組件。render
: 對於block
組件具體渲染的組件由該函數決定,對於inline
組件則與block
的renderLine
表現相同。
type BasePlugin = {
key: string;
priority?: number; // 優先級越高 在越外層
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;
};
在具體的實現上,我們採用了實例化類的方式,當實例化之後我們可以不斷add
插件,因爲toolbar
等插件是負責執行命令的,所以需要首先獲取前邊註冊完成的插件的命令,將其傳入後再註冊到插件當中,通過這種註冊的機制實現了統一的插件管理,在apply
之後,我們可以將返回的值傳入到<Editable />
中,就可以將插件正常的拓展到slate
當中了。
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]);
類型拓展
在slate
中預留了比較好的類型拓展機制,可以通過TypeScript
中的declare module
配合interface
來拓展BlockElement
與TextElement
的類型,使實現插件的attributes
有較爲嚴格的類型校驗。
// 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;
}
}
實現方案
在這裏是具體的插件實現方案與示例,每個部分都是一種類型的插件的實現,具體的代碼都可以在 Github 中找到。在插件實現方面,整體還是藉助了HTML5
的標籤來完成各種樣式,這樣能夠保持文檔的標籤語義完整性但是會造成DOM
結構嵌套比較深。使用純CSS
來完成各種插件也是沒問題的,而且實現上是更簡單一些的,context
提供classList
來操作className
,只不過純CSS
實現樣式的話標籤語義完整性就欠缺一些。這方面主要是個取捨問題,在此處實現的插件都是藉助HTML5
的標籤以及一些自定義的交互策略來完成的,交互的執行上都是通過插件註冊命令後觸發實現的。
Leaf
leaf
類型的插件是行內的元素,例如加粗、斜體、下劃線、刪除線等等,在實現上只需要注意插件的命令註冊與在該命令下如何渲染元素即可,下面是bold
插件的實現,主要是註冊了操作attributes
的命令,以及使用<strong />
作爲渲染格式的標籤。
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
類型的插件是屬於塊級元素,例如標題、段落、對齊等等,簡單來說是作用在行上的元素,在實現上不光要注意命令的註冊和渲染元素,還有注意各種case
,尤其是在wrapper
嵌套下的情況。在下面的heading
示例中,在命令階段處理了是否已經處於heading
狀態,如果處於改狀態那就取消heading
,生成的id
是爲了之後作爲錨點使用,在處理鍵盤事件的時候,就需要處理一些case
,在這裏實現了我們回車的時候不希望在下一行繼承heading
格式,以及當光標置於行最前點擊刪除則會刪除該行標題格式。
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)) {
// 在`wrap`的情況下插入節點會出現問題 先多插入一個空格再刪除
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
類型的插件同樣也是屬於塊級元素,例如引用塊、有序列表、無序列表等,簡單來說是在行上額外嵌套了一行,所以在實現上不光要注意命令的註冊和渲染元素,還有注意各種case
,在wrapper
下需要注意的case
就特別多,所以我們也需要自己實現一些策略來避免這些問題。在下面的quote-block
示例中,實現了支持一級塊引用,回車會繼承格式,作爲wrapped
插件不能與其他wrapped
插件並行使用,行空且該行爲wrapped
首行或尾行時回車和刪除會取消該行塊引用格式,光標置於行最前點擊刪除且該行爲wrapped
首行或尾行時則會取消該行塊引用格式。
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
類型的插件同樣也是屬於塊級元素,例如分割線、圖片、視頻等,void
元素應該是一個空元素,他會有一個空的用於渲染的文本子節點,並且是不可編輯的,所以是一類單獨的節點類型。在下面的dividing-line
示例中,主要需要注意分割線的選中以及void
節點的定義。
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
類型的插件是屬於自定義的一類單獨的插件,主要是用於執行命令,因爲我們在插件定義的時候註冊了命令,那麼也就意味着我們完全可以通過命令來驅動節點的變化,toolbar
就是用於執行命令的插件。在下面的doc-toolbar
示例中,我們可以看到如何實現左側的懸浮菜單以及命令的執行等。
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 />
一級標題
</Menu.Item>
<Menu.Item key="heading.h2">
<IconH2 />
二級標題
</Menu.Item>
<Menu.Item key="heading.h3">
<IconH3 />
三級標題
</Menu.Item>
<Menu.Item key="quote-block">
<IconQuote />
塊級引用
</Menu.Item>
<Menu.Item key="ordered-list">
<IconOrderedList />
有序列表
</Menu.Item>
<Menu.Item key="unordered-list">
<IconUnorderedList />
無序列表
</Menu.Item>
<Menu.Item key="dividing-line">
<IconEdit />
分割線
</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
類型的插件是屬於自定義的一類單獨的插件,同樣也是用於快捷鍵執行命令,這也是使用命令驅動的一種實現。在下面的shortcut
示例中,我們可以看到如何處理快捷鍵的輸入以及命令的執行等。
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();
}
}
}
},
};
};
每日一題
https://github.com/WindrunnerMax/EveryDay
參考
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