使用 tiptap 实现简易富文本编辑器
前言
在工作中遇到实现富文本编辑器的功能,最后是使用了 tiptap 来实现,记录一下。
简单使用
直接看官网的快速开始
教程即可,安装@tiptap/react
、@tiptap/pm
、@tiptap/starter-kit
这三剑客。
使用方法也很简单,使用useEditor
Hook 得到 Editor 对象,然后赋值给EditorContent
的 editor 属性即可
EditorX.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| import { EditorContent, useEditor } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit";
import "./index.css";
const extensions = [StarterKit];
type EditorXProps = { defaultValue?: string; onChange?: (val?: string) => void; };
const EditorX = (props: EditorXProps) => { const { defaultValue: content, onChange } = props;
const editor = useEditor({ extensions: [...extensions], content, onUpdate({ editor }) { if (!editor.getText()) { onChange?.(undefined); } else { onChange?.(editor.getText()); } }, });
return ( <> <EditorContent editor={editor} /> </> ); };
export default EditorX;
|
index.css
1 2 3 4 5 6 7 8 9 10 11 12
| .tiptap.ProseMirror { min-height: 62px; outline: none; }
.tiptap p.is-editor-empty:first-child::before { color: #adb5bd; content: attr(data-placeholder); float: left; height: 0; pointer-events: none; }
|
app.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| import { useState } from "react"; import EditorX from "./components/EditorX";
function App() { const [outerValue, setOuterValue] = useState("clz");
const handleChange = (value?: string) => { if (value) { setOuterValue(value); } };
return ( <> {outerValue} <EditorX defaultValue="clz" onChange={handleChange} /> </> ); }
export default App;
|
使用插件@tiptap/extension-placeholder
tiptap 编辑器并不是 input 这种输入框,而是通过普通的 div 元素,只是通过contenteditable
这个属性来让 dom 元素可以编辑来实现的。所以自然不能直接使用 placeholder
。tiptap 使用placeholder
需要安装插件。
插件使用:
引入
1
| import Placeholder from "@tiptap/extension-placeholder";
|
extensions 添加插件
1 2 3 4 5 6 7 8 9
| const editor = useEditor({ extensions: [ ...extensions, Placeholder.configure({ placeholder: "赤蓝紫到此一游", }), ], });
|
添加操作菜单
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| const EditorX = () => { const handleBold = () => { editor?.commands.toggleBold(); };
return ( <> <EditorContent editor={editor} />
{editor && ( <BubbleMenu editor={editor}> <span className="menu-bold" onClick={handleBold}> B </span> </BubbleMenu> )} </> );
};
|
样式
1 2 3 4 5 6 7 8 9 10
| .menu-bold { display: block; padding: 0 12px; font-weight: bold; cursor: pointer; }
.menu-bold:hover { background-color: #eee; }
|
通过BubbleMenu
展示操作菜单,并利用editor?.commands
提供的 api 进行 tiptap 内置的一些操作,如上面的加粗。
粘贴图片
useEditor
参数对象可以接收editorProps
属性,而editorProps
属性里面有一个handlePaste
方法,该方法的第二个方法就可以获取粘贴的文件信息,然后再利用FileReader
就可以把文件以base64
码的形式读取。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44
| const handlePaste = async (view: EditorView, event: ClipboardEvent) => { const files = event.clipboardData?.files; const file = files?.item(0);
if (!file || !file.type.startsWith("image/")) { return; }
const reader = new FileReader(); reader.readAsDataURL(file);
reader.onload = () => { if (reader.result) { console.log(reader.result); } }; };
const editor = useEditor({ extensions: [ ...extensions, Placeholder.configure({ placeholder: "赤蓝紫到此一游", }), Image.configure({ inline: true, }), ], content, onUpdate({ editor }) { console.log(editor);
if (!editor.getText()) { onChange?.(undefined); } else { onChange?.(editor.getText()); } }, editorProps: { handlePaste(view, event) { handlePaste(view, event); }, }, });
|
然后通过添加@tiptap/extension-image
插件把图片添加到编辑器上即可。
$\color{red}{需要注意的是,需要配置inline
为true
,否则会出现图片粘贴上去,立马就被移出DOM树}$
1 2 3 4 5 6 7 8 9 10 11
| reader.onload = () => { if (reader.result) { editorRef.current ?.chain() .focus() .setImage({ src: reader.result as string, }) .run(); } };
|
完整代码
EditorX.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90
| import { useRef } from "react"; import { BubbleMenu, Editor, EditorContent, useEditor } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; import Placeholder from "@tiptap/extension-placeholder"; import Image from "@tiptap/extension-image";
import { EditorView } from "@tiptap/pm/view"; import "./index.css";
const extensions = [ StarterKit, Placeholder.configure({ placeholder: "赤蓝紫到此一游", }), Image.configure({ inline: true, }), ];
type EditorXProps = { defaultValue?: string; onChange?: (val?: string) => void; };
const EditorX = (props: EditorXProps) => { const { defaultValue: content, onChange } = props;
const editorRef = useRef<Editor | null>(null); const handlePaste = async (view: EditorView, event: ClipboardEvent) => { const files = event.clipboardData?.files; const file = files?.item(0);
if (!file || !file.type.startsWith("image/")) { return; }
const reader = new FileReader(); reader.readAsDataURL(file);
reader.onload = () => { if (reader.result) { editorRef.current ?.chain() .focus() .setImage({ src: reader.result as string, }) .run(); } }; };
const editor = useEditor({ extensions: extensions, content, onUpdate({ editor }) { if (!editor.getText()) { onChange?.(undefined); } else { onChange?.(editor.getText()); } }, editorProps: { handlePaste(view, event) { handlePaste(view, event); }, }, }); editorRef.current = editor;
const handleBold = () => { editor?.commands.toggleBold(); };
return ( <> <EditorContent editor={editor} />
{editor && ( <BubbleMenu editor={editor}> <span className="menu-bold" onClick={handleBold}> B </span> </BubbleMenu> )} </> ); };
export default EditorX;
|
index.css
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| .tiptap.ProseMirror { min-height: 62px; outline: none; }
.tiptap p.is-editor-empty:first-child::before { color: #adb5bd; content: attr(data-placeholder); float: left; height: 0; pointer-events: none; }
.menu-bold { display: block; padding: 0 12px; font-weight: bold; cursor: pointer; }
.menu-bold:hover { background-color: #eee; }
|