基于 Lexical 的富文本输入框
这次先放 demo
富文本输入框
最近都在写后台项目,在项目中使用 富文本输入框 的频率很高。也使用过蛮多富文本编辑器,比如 tinymce, quill, jodit 等等。
因为一些特殊的业务需求,比如图片/视频的上传改成从后台库中选择图片,支持 youtube 视频嵌入等等,又不希望使用付费商业版,所以决定二次开发一个开源的高度自定义的富文本编辑器。在调研/使用了多个项目之后决定使用 lexical 开发。
Lexical
Lexical 是一个开源的富文本编辑器框架,由 Facebook 开发,旨在提供灵活和可扩展的文本编辑体验。最主要的优势在于 Lexical 的开发者体验一流。不需要过多与 DOM 的交互,通过声明性的 API 和插件就可以快速实现想要的功能。
在开始之前强烈建议先阅读完整的官方文档 ,后面的内容会从实践出发,不会过多介绍相关概念。
依赖安装
npm install --save lexical @lexical/react
基础使用
先看一个官方提供的基本例子:
app.tsx:
import React from 'react';
import { AutoFocusPlugin } from '@lexical/react/LexicalAutoFocusPlugin';
import { LexicalComposer } from '@lexical/react/LexicalComposer';
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
import { ContentEditable } from '@lexical/react/LexicalContentEditable';
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin';
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary';
interface RichTextEditorProps {}
const RichTextEditor: React.FC<RichTextEditorProps> = (props) => {
const theme = {
paragraph: 'paragraphClassName'
}
const initialConfig = {
namespace: 'Lexical Demo',
onError(error: Error) {
throw error;
},
// 主题
theme,
};
return (
<LexicalComposer initialConfig={initialConfig}>
<RichTextPlugin
contentEditable={<ContentEditable />}
placeholder={<div>Enter some text...</div>}
ErrorBoundary={LexicalErrorBoundary}
/>
<HistoryPlugin />
<AutoFocusPlugin />
</LexicalComposer>
);
};
export default RichTextEditor;
跑起来会发现似乎有点不对劲,为什么没有渲染出富文本编辑器?
run:
这是因为 Lexical 本身不提供任何 UI,他是基于你的框架的编辑器,后面的样例都默认使用 tailwindcss 来添加样式,当然你也可以使用任何你熟悉或者喜欢的方式添加样式。
添加 CSS 样式
为了让 Lexical 编辑器正常工作,我们需要添加一些 CSS 样式。
···
return (
<div className="p-5">
<LexicalComposer initialConfig={initialConfig}>
<div className="rounded-lg border-[#e2e2e2] border-solid border w-full text-black relative text-left">
<RichTextPlugin
ErrorBoundary={LexicalErrorBoundary}
contentEditable={
<div className="min-h-[150px] border-none flex relative outline-none z-0 overflow-auto resize-y">
<ContentEditable
aria-placeholder="Enter some rich text..."
className="min-h-[150px] w-full resize-none caret-[rgb(5,5,5)] relative outline-none py-4 px-6 editor-cell"
placeholder={
<div className="absolute overflow-hidden text-gray-400 truncate pointer-events-none select-none top-4 left-6">
Enter some rich text...
</div>
}
/>
</div>
}
/>
<HistoryPlugin />
<AutoFocusPlugin />
</div>
</LexicalComposer>
</div>
);
···
run:
添加插件
现在编辑器有了样式,但目前只有输入这一项功能,所以需要添加一个 Toolbar 插件来支持更多功能。
ToolbarPlugin.tsx:(省略了一下组件样式,你可能需要自己实现你需要的样式)
const ToolbarPlugin: React.FC = () => {
const [editor] = useLexicalComposerContext();
const [activeEditor, setActiveEditor] = useState(editor);
const toolbarRef = useRef(null);
const [canUndo, setCanUndo] = useState(false);
··· 一些状态 ···
return (
<div
ref={toolbarRef}
className="flex items-center flex-wrap rounded-t-lg p-1 gap-0.5 border-b border-t-0 border-x-0 border-gray-300 border-solid bg-white"
>
{/* Undo/Redo */}
<ToolbarButton disabled={!canUndo} >
<IconUndo />
</ToolbarButton>
<ToolbarButton disabled={!canRedo} >
<IconRedo />
</ToolbarButton>
<Divider />
{/* font size */}
<DropDownFontSize selectionFontSize={fontSize} editor={activeEditor} />
<Divider />
{/* font style */}
<ToolbarButton active={isBold} >
<IconTypeBold />
</ToolbarButton>
<ToolbarButton active={isItalic} >
<IconTypeItalic />
</ToolbarButton>
<ToolbarButton active={isUnderline} >
<IconTypeUnderline />
</ToolbarButton>
<ToolbarButton active={isStrikethrough} >
<IconTypeStrikethrough />
</ToolbarButton>
<ToolbarButton active={isCode} >
<IconCode />
</ToolbarButton>
{/* alignment */}
<ToolbarButton active={elementFormat === 'left'} >
<IconTextLeft />
</ToolbarButton>
<ToolbarButton active={elementFormat === 'center'} >
<IconTextCenter />
</ToolbarButton>
<ToolbarButton active={elementFormat === 'right'} >
<IconTextRight />
</ToolbarButton>
<ToolbarButton active={elementFormat === 'justify'} >
<IconJustify />
</ToolbarButton>
<ToolbarButton >
<IconOutdent />
</ToolbarButton>
<ToolbarButton >
<IconIndent />
</ToolbarButton>
</div>
);
}
接下来将添加一些基础功能
- [ ] 撤销/重做
- [ ] 修改字体大小 / 样式(加粗,斜体等)
- [ ] 修改对齐方式
- [ ] 添加缩进
首先通过 editor.registerCommand(...)
来注册命令 / 添加监听器。(ps:官方建议请在 effect 中使用)
ToolbarPlugin.tsx:
···
const ToolbarPlugin: React.FC = () => {
···
useEffect(() => {
return editor.registerCommand(
SELECTION_CHANGE_COMMAND,//- 触发事件 当选择区域改变时
(_payload, newEditor) => {
setActiveEditor(newEditor); //- 更新编辑器实例
$updateToolbar(); //- 更新工具栏状态
return false;
},
COMMAND_PRIORITY_CRITICAL
//- 优先级有以下选择,根据需求选择
//- COMMAND_PRIORITY_EDITOR
//- COMMAND_PRIORITY_LOW
//- COMMAND_PRIORITY_NORMAL
//- COMMAND_PRIORITY_HIGH
//- COMMAND_PRIORITY_EDITOR
);
}, [editor, $updateToolbar]);
//- 监听器
useEffect(() => {
return mergeRegister(
activeEditor.registerUpdateListener(({ editorState }) => {
editorState.read(() => {
$updateToolbar();
});
}),
activeEditor.registerCommand<boolean>(
CAN_UNDO_COMMAND,
(payload) => {
setCanUndo(payload);
return false;
},
COMMAND_PRIORITY_CRITICAL
),
activeEditor.registerCommand<boolean>(
CAN_REDO_COMMAND,
(payload) => {
setCanRedo(payload);
return false;
},
COMMAND_PRIORITY_CRITICAL
)
);
}, [$updateToolbar, activeEditor]);
···
}
···
ToolbarPlugin.tsx: 根据选择区域更新工具栏状态,updateToolbar function:
const $updateToolbar = useCallback(() => {
const selection = $getSelection();//- 获取选择区域
if ($isRangeSelection(selection)) {
//- 判断段落信息
const anchorNode = selection.anchor.getNode();
let element =
anchorNode.getKey() === 'root'
? anchorNode
: $findMatchingParent(anchorNode, (e) => {
const parent = e.getParent();
return parent !== null && $isRootOrShadowRoot(parent);
});
if (element === null) {
element = anchorNode.getTopLevelElementOrThrow();
}
const node = getSelectedNode(selection);
const parent = node.getParent();
let matchingParent;
if ($isLinkNode(parent)) {
//- 如果节点是 link ,我们需要获取父节点来设置格式
matchingParent = $findMatchingParent(
node,
(parentNode) => $isElementNode(parentNode) && !parentNode.isInline()
);
}
setElementFormat(
$isElementNode(matchingParent)
? matchingParent.getFormatType()
: $isElementNode(node)
? node.getFormatType()
: parent?.getFormatType() || 'left'
);
}
if ($isRangeSelection(selection)) {
//- 判断字体样式
setIsBold(selection.hasFormat('bold'));
setIsItalic(selection.hasFormat('italic'));
setIsUnderline(selection.hasFormat('underline'));
setIsStrikethrough(selection.hasFormat('strikethrough'));
setIsCode(selection.hasFormat('code'));
setFontSize(
$getSelectionStyleValueForProperty(selection, 'font-size', '16px')
);
}
}, []);
最后使用 editor.dispatchCommand(command, payload)
API 来发送命令。
ToolbarPlugin.tsx:
···
<ToolbarButton
disabled={!canUndo}
onClick={() => {
editor.dispatchCommand(UNDO_COMMAND, undefined);
}}
>
<IconUndo />
</ToolbarButton>
···
一些常用的命令:
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold'); //- 修改字体样式,第二个参数为样式
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'left') //- 修改对齐方式
editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined); //- 增加缩进
editor.dispatchCommand(INDENT_CONTENT_COMMAND, undefined); //- 减少缩进
editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined) //- 插入无序列表
editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined) //- 插入有序列表
···
还记得一开始说的 "Lexical 本身不提供任何 UI" 吗,所以你可能需要添加一些样式才能正确显示。
app.tsx:
···
const theme = {
paragraph: 'paragraphClassName',
text: {
bold: 'font-bold',
italic: 'italic',
underline: 'underline',
strikethrough: 'line-through', //- 这里用的是 tailwindcss 样式,你可以替换成你需要的 className
},
};
···
return (
<div className="p-5">
<LexicalComposer initialConfig={initialConfig}>
<div className="rounded-lg border-[#e2e2e2] border-solid border w-full text-black relative text-left">
{/* 在合适的位置放置工具栏 */}
<ToolbarPlugin />
<RichTextPlugin
ErrorBoundary={LexicalErrorBoundary}
contentEditable={
<div className="min-h-[150px] border-none flex relative outline-none z-0 overflow-auto resize-y">
<ContentEditable
aria-placeholder="Enter some rich text..."
className="min-h-[150px] w-full resize-none caret-[rgb(5,5,5)] relative outline-none py-4 px-6 editor-cell"
placeholder={
<div className="absolute overflow-hidden text-gray-400 truncate pointer-events-none select-none top-4 left-6">
Enter some rich text...
</div>
}
/>
</div>
}
/>
<HistoryPlugin />
<AutoFocusPlugin />
</div>
</LexicalComposer>
</div>
)
run:
到这已经完成了最基础的富文本编辑器,接下来将通过创建插件的方式,添加更为复杂的功能。
自定义插件(插入图片)
与其他编辑器框架不同,Lexical 并没有为插件定义提供任何接口。通过访问 LexicalEditor ,插件可以使用 Lexical 提供的任何 API 命令。这也是推荐 Lexical 作为富文本编辑器的原因之一,高度的自定义为开发提供了更多的舒适度。
- 创建节点 LexicalNode
Lexical 提供了一些基础节点,可以通过继承来快速自定义:
- ElementNode – 父节点,可以是块级或者行内。
- TextNode - 包含文本的叶子节点。(不能包含子元素)
- DecoratorNode - 在编辑器中插入任意视图。
这里选用 DecoratorNode 来自定义插入节点,很简单,只需要继承 DecoratorNode 并实现一些基础方法。
imageNode.tsx:
import { Suspense } from 'react';
···
export interface ImagePayload {
altText: string;
height?: number;
key?: NodeKey;
maxWidth?: number;
src: string;
width?: number;
}
function $convertImageElement(domNode: Node): null | DOMConversionOutput {
const img = domNode as HTMLImageElement;
const { alt: altText, src, width, height } = img;
const node = $createImageNode({ altText, height, src, width });
return { node };
}
export type SerializedImageNode = Spread<
{
altText: string;
height?: number;
maxWidth: number;
src: string;
width?: number;
},
SerializedLexicalNode
>;
export class ImageNode extends DecoratorNode<JSX.Element> {
//- img 节点属性,你可以按需添加
__src: string;
__altText: string;
__width: 'inherit' | number;
__height: 'inherit' | number;
__maxWidth: number;
//- 获取节点类型
static getType(): string {
return 'image';
}
//- 复制操作需要用到,拷贝一下即可
static clone(node: ImageNode): ImageNode {
return new ImageNode(
node.__src,
node.__altText,
node.__maxWidth,
node.__width,
node.__height,
node.__key
);
}
//- 反序列化节点
static importJSON(serializedNode: SerializedImageNode): ImageNode {
const { altText, height, width, maxWidth, src } = serializedNode;
const node = $createImageNode({
altText,
height,
maxWidth,
src,
width,
});
return node;
}
//- 导出 dom 节点
exportDOM(): DOMExportOutput {
const element = document.createElement('img');
element.setAttribute('src', this.__src);
element.setAttribute('alt', this.__altText);
element.setAttribute('width', this.__width.toString());
element.setAttribute('height', this.__height.toString());
return { element };
}
//- dom 节点序列化
static importDOM(): DOMConversionMap | null {
return {
img: (node: Node) => ({
conversion: $convertImageElement,
priority: 0,
}),
};
}
constructor(
src: string,
altText: string,
maxWidth: number,
width?: 'inherit' | number,
height?: 'inherit' | number,
key?: NodeKey
) {
super(key);
this.__src = src;
this.__altText = altText;
this.__maxWidth = maxWidth;
this.__width = width || 'inherit';
this.__height = height || 'inherit';
}
//- 序列化节点
exportJSON(): SerializedImageNode {
return {
altText: this.getAltText(),
height: this.__height === 'inherit' ? 0 : this.__height,
maxWidth: this.__maxWidth,
src: this.getSrc(),
type: 'image',
version: 1,
width: this.__width === 'inherit' ? 0 : this.__width,
};
}
//- 这是拓展方法,image resize 会用到,可以忽略
setWidthAndHeight(
width: 'inherit' | number,
height: 'inherit' | number
): void {
const writable = this.getWritable();
writable.__width = width;
writable.__height = height;
}
//- 节点渲染
createDOM(config: EditorConfig): HTMLElement {
const span = document.createElement('span');
const theme = config.theme;
const className = theme.image;
if (className !== undefined) {
span.className = className;
}
return span;
}
updateDOM(): false {
return false;
}
getSrc(): string {
return this.__src;
}
getAltText(): string {
return this.__altText;
}
decorate(): JSX.Element {
return (
<Suspense fallback={null}>
<img
src={this.__src}
alt={this.__altText}
style={{
width: this.__width,
height: this.__height,
maxWidth: this.__maxWidth,
}}
key={this.getKey()}
draggable={false}
/>
</Suspense>
);
}
}
export function $createImageNode({
altText,
height,
maxWidth = 500,
src,
width,
key,
}: ImagePayload): ImageNode {
return $applyNodeReplacement(
new ImageNode(src, altText, maxWidth, width, height, key)
);
}
export function $isImageNode(
node: LexicalNode | null | undefined
): node is ImageNode {
return node instanceof ImageNode;
}
- 注册插件 & 节点
···
const [editor] = useLexicalComposerContext();
const initialConfig = {
namespace: 'Lexical Demo',
nodes: [ImageNode], //- 注册节点
onError(error: Error) {
throw error;
},
theme,
};
useEffect(() => {
if (!editor.hasNodes([ImageNode])) {
throw new Error('ImagesPlugin: ImageNode not registered on editor');
}
return mergeRegister(
//- 注册命令
editor.registerCommand<InsertImagePayload>(
INSERT_IMAGE_COMMAND,
(payload) => {
const imageNode = $createImageNode(payload);
$insertNodes([imageNode]);
if ($isRootOrShadowRoot(imageNode.getParentOrThrow())) {
$wrapNodeInElement(imageNode, $createParagraphNode).selectEnd();
}
return true;
},
COMMAND_PRIORITY_EDITOR
),
);
}, [captionsEnabled, editor]);
···
最后可以通过 INSERT_IMAGE_COMMAND 来插入图片
editor.dispatchCommand(INSERT_IMAGE_COMMAND, {altText, src, width, height})